Skip to content
Vulnerabilities

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.

7 min read

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.

Ways a step-up check fails while appearing to work
FailureWhat it looks like
Enforced in the clientThe interface asks for a code and the endpoint does not require one. Calling the endpoint directly skips it entirely.
One route protectedThe web flow challenges and the mobile client, public API or bulk import reaching the same function does not.
Challenge not bound to the actionA code approves any transaction rather than the one it was issued for, so one obtained legitimately authorises a different request.
No limit on attemptsA six digit code with unlimited guesses is a six digit code for about a minute.
Reusable or long-livedThe same code works twice, or is still valid long after it should have expired.
Verified in the wrong placeThe 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

  1. Perform a sensitive action normally and capture the request that completes it.
  2. Start the action again, and replay that final request without completing the challenge.
  3. If it succeeds, the check was in the interface only.
  4. Then try a code issued for one transaction against a different one. If it is accepted, the challenge is not bound to the action.
  5. 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.

FAQ

Questions this raises

What is step-up authentication?

Requiring a fresh proof of identity at the moment of a sensitive action, rather than relying on the sign-in that started the session. It answers a different question from login: not who are you, but did you mean this, now.

Which actions should require it?

Anything hard to reverse or valuable to somebody else: moving money, changing where money goes, altering recovery details, disabling a security control, deleting an account, and bulk operations. Changing the recovery email or phone is the highest value and most frequently unprotected.

We already require MFA at login. Is that enough?

It protects the sign-in, not the session afterwards. Anyone who obtains a live session - a captured token, an unlocked device, a session that outlived a logout - is past that check and stays past it until the session ends.

Why must the code be bound to the transaction?

Otherwise a code obtained for one action authorises another. If the server only checks that a valid code was supplied, an attacker who can cause any legitimate challenge to be issued can use it to approve their own request.

Does this apply to APIs as well as the web interface?

Especially to APIs. The most common version of this finding is a web flow that challenges correctly beside a mobile client or public API reaching the same function without one. Test every route to the action, not just the one with the screen.