Step-Up Authentication for High-Value Operations
Authentication at login answers a question about the start of a session. It says nothing about who is holding that session forty minutes later when someone changes the payout bank account. Step-up authentication closes the gap by re-challenging at the moment of consequence, which is also the moment a user is most willing to accept friction because they can see why it exists.
This guide implements it properly: assurance levels assigned per operation, the satisfying factor and timestamp recorded in the session, challenges bound to the specific action so a satisfied challenge cannot be reused for something else, and short freshness windows with an audit trail. It is part of the MFA Enforcement Patterns guide within Secure Authentication & Session Architecture.
Prerequisites
- At least one enrolled factor per user, ideally an origin-bound one
- A session store that can hold structured claims about how the session was established
- A list of operations with real consequences: money movement, permission changes, data export, deletion
- An audit log that records who did what, when, and under which assurance
Expected Outcomes
- Every sensitive operation declares the assurance level and freshness it requires
- Sessions record which factor satisfied them and when
- Challenges are bound to the operation and its parameters, not merely to the session
- Elevated assurance expires in minutes, with each elevation audited
Step 1: Classify Operations Rather Than Guarding Routes
Route-by-route guards drift the moment someone adds an endpoint. Declare the requirement next to the operation instead.
export const OPERATIONS = {
'profile.update': { assurance: 'session', freshness: null },
'password.change': { assurance: 'factor', freshness: 300 },
'mfa.enroll': { assurance: 'factor', freshness: 300 },
'payout.account.change': { assurance: 'factor', freshness: 120, bindParams: ['iban'] },
'org.member.promote': { assurance: 'factor', freshness: 300, bindParams: ['userId'] },
'org.delete': { assurance: 'factor', freshness: 0, bindParams: ['orgId'] },
'data.export': { assurance: 'factor', freshness: 600 },
} as const;
A freshness of zero means “challenge for this request”, which is the correct setting for anything irreversible. The bindParams list is what stops a challenge satisfied for one target being spent on another.
Step 2: Record Assurance in the Session and Evaluate It Once
type SessionAssurance = {
method: 'password' | 'totp' | 'webauthn';
at: number; // epoch seconds when the factor was satisfied
boundTo?: { op: string; params: Record<string, string> };
};
export function requireAssurance(op: keyof typeof OPERATIONS) {
return async (req: Request, res: Response, next: NextFunction) => {
const spec = OPERATIONS[op];
const a: SessionAssurance | undefined = req.session.assurance;
const satisfied =
spec.assurance === 'session' ||
(a?.method === 'webauthn' || a?.method === 'totp') &&
(spec.freshness !== 0 && a && (Date.now() / 1000 - a.at) <= spec.freshness) &&
bindingMatches(spec, a, req.body);
if (!satisfied) {
const challenge = await mfa.createChallenge(req.session.userId, op, pick(req.body, spec.bindParams));
return res.status(401).json({ error: 'step_up_required', challengeId: challenge.id, op });
}
next();
};
}
Returning a structured step_up_required with a challenge identifier is what lets a single-page application respond gracefully instead of dumping the user back at a login screen and losing the form they had filled in.
Step 3: Bind the Challenge to the Operation and Its Parameters
Binding is the difference between a real step-up and a decorative one.
// Challenge creation records what it will authorise.
const challenge = await mfa.createChallenge(userId, 'payout.account.change', { iban: newIban });
// Verification checks that the answered challenge is the one for THIS request.
export async function verifyStepUp(req: Request, op: string) {
const c = await mfa.getChallenge(req.body.challengeId);
if (!c || c.userId !== req.session.userId) throw new Unauthorized();
if (c.op !== op) throw new Unauthorized(); // not transferable across operations
if (!deepEqual(c.params, pick(req.body, OPERATIONS[op].bindParams))) {
throw new Unauthorized(); // not transferable across targets
}
await mfa.verifyAndConsume(c, req.body.factorResponse); // single use
req.session.assurance = { method: c.method, at: nowSeconds(), boundTo: { op, params: c.params } };
}
Without the parameter comparison, an attacker who can get the victim to approve a benign-looking challenge can spend that approval on a different destination account. With it, the approval is worthless anywhere except the exact operation that was described.
Verification
it('refuses a sensitive operation without a recent factor', async () => {
const s = await loginWithPasswordOnly();
const res = await api.post('/api/payout-account').auth(s).send({ iban: MY_IBAN });
expect(res.status).toBe(401);
expect(res.body.error).toBe('step_up_required');
});
it('refuses a challenge answered for a different target', async () => {
const s = await loginWithFactor();
const c = await requestChallenge(s, 'payout.account.change', { iban: MY_IBAN });
await answerChallenge(s, c);
const res = await api.post('/api/payout-account').auth(s).send({ iban: ATTACKER_IBAN });
expect(res.status).toBe(401); // binding mismatch, not merely a stale window
});
it('expires elevated assurance after the freshness window', async () => {
const s = await loginWithFactor();
await stepUp(s, 'payout.account.change', { iban: MY_IBAN });
await advanceClock(130); // freshness is 120 seconds
const res = await api.post('/api/payout-account').auth(s).send({ iban: MY_IBAN });
expect(res.status).toBe(401);
});
The middle test is the one that separates a real implementation from a decorative one, and it is the one most codebases fail on the first run.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Users challenged repeatedly within one task | Freshness shorter than the task takes | Raise the window for that operation class, or elevate once for a defined sequence |
| A challenge authorises a different action | Assurance stored without operation binding | Record the operation and parameters and compare them at verification |
| Single-page app dumps users at the login screen | 401 not distinguished from step-up | Return a structured step_up_required with a challenge identifier |
| API clients bypass the requirement | Requirement enforced only in interface routes | Enforce in the shared operation layer, not per transport |
| Elevated assurance survives a factor removal | Assurance not invalidated on credential change | Clear session assurance whenever factors or the password change |
| Audit shows the action but not the assurance | Log written before verification | Emit the audit entry after verification, including method and challenge identifier |
Common Implementation Mistakes
Frequently Asked Questions
How long should elevated assurance last?
Minutes rather than hours, and shorter still for irreversible operations. A five-minute window comfortably covers a user completing a related sequence — change the payout account, then confirm it — without leaving an elevated session waiting on an unattended laptop. For a single irreversible action such as deleting an organisation, use no window at all and require the challenge for that specific request.
Is re-entering the password a valid step-up?
It is weak. Anyone who obtained the session by phishing the password can re-enter it, and anyone who stole the session cookie may well find the password saved in the browser. Prefer an origin-bound factor, which the attacker cannot satisfy from their own device. Keep password re-entry only as a fallback for users with no enrolled factor — and treat that gap as something to close rather than to design around.
Should step-up apply to API clients too?
For operations a human ultimately authorises, yes: surface the challenge in whatever flow the client drives. For genuine machine-to-machine calls there is no human to challenge, so the equivalent controls are a narrowly scoped credential, an approval workflow for the sensitive operation, and a complete audit trail. What you must not do is silently exempt API paths from a rule the interface enforces, because that exemption is exactly the route an attacker will take.
Related
- MFA Enforcement Patterns — the parent guide covering enrolment, assurance and recovery
- Implementing WebAuthn/FIDO2 in TypeScript — the origin-bound factor this pattern prefers
- Session Revocation and Logout Across Devices — what to do when assurance turns out to have been misplaced
- Access Control & IDOR Prevention — the authorization layer step-up sits on top of