ASVS 5.0 V6.6.2 requires out of band authentication requests, codes and tokens to be bound to the original authentication request that produced them, and unusable for any previous or later one. It is a Level 2 requirement.
Why it matters
If a code is only tied to a user, an attacker can start their own login for a victim, wait for the victim to receive a code in response to something else, and use it. Push approvals have the same issue: a victim approves a prompt and the approval is applied to the attacker's session.
How to implement it
Create a pending authentication record when the first factor passes, and tie the code to that record's ID.
def start_second_factor(user):
attempt_id = secrets.token_urlsafe(24)
code = f"{secrets.randbelow(10**7):07d}"
db.attempts.insert(id=attempt_id, user_id=user.id,
code_hash=hash_code(code), expires_at=in_minutes(10),
client_binding=bind(request)) # cookie or device id
send_code(user, code)
return attempt_id # held by the browser that started the login
def finish(attempt_id, code):
a = db.attempts.get(attempt_id)
if not a or a.client_binding != bind(request) or a.used or expired(a):
raise Denied()
if not verify_hash(a.code_hash, code):
raise Denied()
db.attempts.mark_used(a.id)
Issuing a new code should invalidate the previous attempt for that user.
How to test it
- Start two login attempts for one user. Use the code from attempt B on attempt A. It must fail.
- Complete attempt A, then try its code again. It must fail.
- Start an attempt in one browser and submit the code from another. It must fail.
Common mistakes
- Looking up the latest code by user only.
- Leaving older codes valid after a new one is sent.
- Not tying push approvals to a specific session.
Quick check
What ties a code to a request?
A server side attempt identifier and a binding to the client that started it, checked on every submission.