Step-Up Authentication with Passkeys
Not every action deserves the same assurance. Reading a dashboard is fine on a day-old session; moving money, changing a recovery method, or deleting an account should demand a fresh proof of possession. Step-up authentication re-runs the passkey ceremony at the moment of a sensitive action, with userVerification required and the challenge bound to the specific transaction. This page shows how to implement it on top of your existing sessions, within the server-side session management with passkeys cluster that is its parent.
Trigger and Requirement Reference
| Sensitive action | Freshness window | UV | Transaction binding |
|---|---|---|---|
| Payment / transfer | ≤ 60 s | required | amount + payee in challenge context |
| Change recovery method | ≤ 120 s | required | account id in context |
| Delete account | ≤ 120 s | required | account id in context |
| Add a new passkey | ≤ 300 s | required | — |
| View sensitive data | session aal2 claim |
— (session) | — |
Step-up maps to two regimes: NIST SP 800-63B reauthentication (a fresh AAL2/AAL3 event) and PSD2 SCA dynamic linking (the challenge must be cryptographically tied to the transaction amount and payee).
Root Cause Analysis (of weak step-up)
1. Trusting the session instead of re-verifying. Gating on the login session alone means an attacker who captured a session performs the sensitive action unchallenged.
2. No transaction binding. A generic step-up challenge lets a valid assertion authorise a different transaction (a signing oracle). PSD2 requires the amount/payee be bound in.
3. Accepting a stale assertion. Without a freshness window, a replayed or delayed assertion satisfies the check.
4. UV not required. Step-up without userVerification: 'required' does not raise assurance at all.
Step-by-Step Resolution
Step 1 — Encode the transaction into the challenge context
// Bind amount + payee so the assertion authorises THIS transaction only
const context = { action: 'transfer', amount: '250.00', payee: 'acct_9f2', ts: Date.now() };
const challenge = randomBytes(32);
await storeStepUpChallenge(userId, challenge, context, { ttlMs: 60_000 }); // 60s window
const options = await generateAuthenticationOptions({
rpID: 'example.com',
userVerification: 'required', // mandatory for step-up
allowCredentials: userCreds.map((c) => ({ id: c.credentialId, type: 'public-key' })),
challenge,
});
Step 2 — Verify freshness, UV, and binding on return
const stored = await consumeStepUpChallenge(userId); // single-use; also returns context + issuedAt
if (Date.now() - stored.issuedAt > 60_000) throw Object.assign(new Error('step-up expired'), { status: 401 });
const { verified, authenticationInfo } = await verifyAuthenticationResponse({
response, expectedChallenge: stored.challenge, expectedRPID: 'example.com',
expectedOrigin: 'https://app.example.com', requireUserVerification: true,
});
if (!verified) throw Object.assign(new Error('step-up failed'), { status: 401 });
Step 3 — Authorise only the bound transaction
// Use stored.context, NOT client-resupplied values, to execute the action
await executeTransfer({ amount: stored.context.amount, payee: stored.context.payee, userId });
Step 4 — Record the reauthentication event
Stamp the session/audit log with a fresh aal and timestamp so subsequent checks within the window can trust it, reusing the aal claim from issuing JWT sessions.
Verification and Testing
// Expired step-up is rejected
jest.setSystemTime(start + 61_000);
await expect(verifyStepUp(userId, response)).rejects.toMatchObject({ status: 401 });
// The executed transaction matches the bound context, not client input
expect(executed.amount).toBe(stored.context.amount);
Assert requireUserVerification: true is enforced (a UV-absent assertion fails). Confirm the challenge is single-use (a replay fails). Verify the action executes with server-stored context so a tampered client payload cannot redirect the transfer.
Pitfalls
1. Session-only gating. Require a fresh assertion for sensitive actions.
2. No transaction binding. Encode amount/payee in the challenge context (PSD2 dynamic linking).
3. Missing UV. Set userVerification: 'required' for every step-up.
Related
- Server-Side Session Management with Passkeys — the parent cluster on session and token lifecycle
- Issuing JWT Sessions After a WebAuthn Assertion — the
aalclaim step-up updates - Best Practices for FIDO2 Challenge Generation — CSPRNG challenges and single-use enforcement for step-up