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.

Assurance Rises With Consequence, Freshness Falls Four tiers of operation. Ordinary edits need only a valid session. Security-relevant changes such as password or factor enrolment need a recent factor. Money movement needs a very recent factor bound to the destination. Irreversible actions need a challenge for that specific request, with no reuse window at all. Ordinary — a valid session is enough profile edits, preferences, reading data · no additional challenge, no freshness requirement Security-relevant — a factor within 5 minutes password change, factor enrolment or removal, session revocation, API key creation Money movement — a factor within 2 minutes, bound to the destination payout account changes, transfers, subscription upgrades on a shared account Irreversible — challenge this request, no window deleting an organisation, purging data, transferring ownership

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.

What a Good Step-Up Looks Like to the User The client receives a structured step-up response rather than a bare 401. It keeps the form state, presents the challenge inline with the operation named, and replays the original request once the challenge is answered. The user never loses their work and always knows what they are approving. 401 step_up_required with a challenge id Form state kept nothing the user typed is lost Challenge inline names the exact operation Replayed once, bound A bare 401 sends the user back to a login screen and discards the form, which is why teams disable step-up after the first support complaint. The structured response is what makes the control survive contact with users.

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.

An Unbound Approval Is a Blank Cheque With an unbound challenge the user approves a generic prompt and the resulting elevated session authorises any sensitive operation, including one the attacker chooses. With a bound challenge the approval names the operation and its parameters, so it authorises exactly that action and nothing else. Unbound challenge prompt says: "confirm it is you" approval elevates the whole session any sensitive operation now passes An attacker triggers a benign-looking prompt and spends it on the payout change. Bound challenge prompt names the action and the target approval records operation and parameters verification compares both, single use The user sees what they are approving, which is also the point of the prompt.

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.