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