A code at login is not authorisation
Signing in proves who somebody is at that moment. It does not prove they intended the transfer that happens forty minutes later, from a session somebody else may now be holding. Those are different questions, and a great many applications only ask the first one.
Key takeaways
- Authentication answers who you are. Authorisation of an action answers whether you meant this, now. A code at the door does not answer the second.
- The gap is exploited by anything that gets hold of a live session: a stolen token, an unlocked device, or a cross-site request.
- Step-up should be triggered by the action and its risk, not by how long ago somebody logged in.
- A challenge that does not name what it is approving trains people to approve anything.
- Where step-up exists, it is often missing from one route to the same function: an API, a bulk import, or a mobile client.
The gap
Most applications treat authentication as an event at the beginning. You prove yourself, you receive a session, and every subsequent action is permitted because the session exists. For reading a dashboard that is fine. For moving money, changing a payout destination or deleting an account, it is a different question left unasked.
The distance between the two matters because sessions are portable. A token captured from a log, a device left unlocked, a shared computer, a session that survived a logout: in every case somebody now holds an authenticated session they did not authenticate for.
Step-up closes that distance by attaching the check to the action rather than to the session. Whoever holds the session still has to demonstrate, at the moment of the transaction, that they are the account owner and that they intended this specific thing.
Which actions need it
The test is not how sensitive the data is. It is how hard the action would be to reverse, and how much somebody else would gain from performing it.
- Moving money, or changing where money goes: payout accounts, beneficiaries, saved payment methods.
- Changing the credentials or contact details that control account recovery, which is how a temporary session becomes permanent access.
- Disabling a security control, including removing a second factor.
- Anything irreversible: deleting an account, closing a record, publishing something externally.
- Bulk operations, where a single request affects many records at once.
The one people skip
Changing the email address or phone number on an account is the highest-value action in most applications and is frequently the least protected. Whoever controls the recovery address controls the account permanently, so it deserves a stronger check than the payment it will later be used to authorise.
How it goes wrong when it exists
Step-up being present is not the same as step-up working. These are the recurring failures, and all of them look correct from the interface.
| Failure | What it looks like |
|---|---|
| Enforced in the client | The interface asks for a code and the endpoint does not require one. Calling the endpoint directly skips it entirely. |
| One route protected | The web flow challenges and the mobile client, public API or bulk import reaching the same function does not. |
| Challenge not bound to the action | A code approves any transaction rather than the one it was issued for, so one obtained legitimately authorises a different request. |
| No limit on attempts | A six digit code with unlimited guesses is a six digit code for about a minute. |
| Reusable or long-lived | The same code works twice, or is still valid long after it should have expired. |
| Verified in the wrong place | The code is checked, and the transaction is submitted separately, so the two can be decoupled. |
What the challenge should say
A prompt that says only "enter your code" teaches people to enter their code. If an attacker triggers a transfer and the victim is expecting an unrelated notification, an unlabelled prompt gets approved.
The challenge should name the action and its material details, and it should do so in the message itself rather than only on the screen. A code delivered with the amount and the destination in the same message is one the recipient can refuse.
- Name the operation, the amount, and where it is going.
- Bind the code to those details on the server, so approving it cannot authorise anything else.
- Make the code single use and short lived.
- Limit attempts, and take care that the limit cannot itself be used to lock somebody out.
That last point matters more than it sounds: adding a limit to a verification endpoint is exactly where a control turns into a denial of service, as the rate limiting guide sets out.
Testing for it
- Perform a sensitive action normally and capture the request that completes it.
- Start the action again, and replay that final request without completing the challenge.
- If it succeeds, the check was in the interface only.
- Then try a code issued for one transaction against a different one. If it is accepted, the challenge is not bound to the action.
- Repeat both through every client you ship, because one of them usually lacks the check.
The second test is the one most often skipped and most often positive. A challenge that is not bound to what it approves is a challenge that approves anything.