Enforcing Step-Up Authentication for Sensitive Actions

A user logged in hours ago over coffee and now wants to wire money, rotate an API key, or change the account email — trusting that stale session for an irreversible action is how account takeovers turn into losses. Step-up authentication, part of the Multi-Factor Authentication: TOTP and FIDO2 guide, demands a fresh, often stronger factor immediately before the sensitive operation rather than relying on the assurance the session had at login.

Why a Single Login Assurance Is Not Enough

Authentication is an event, but a session is a duration. The login proved something at time T, but the longer the session lives, the weaker that proof is as evidence that the legitimate user is still at the keyboard. OIDC (RFC 6749 / OpenID Connect Core) models this with two ideas that step-up reuses directly: the auth_time claim records when authentication happened, and the acr (Authentication Context Class Reference) and amr (Authentication Methods References) claims record how strong it was and which methods were used (e.g. pwd, otp, hwk for a hardware key).

Step-up is the policy that says: for action class X, require auth_time within the last N seconds and an assurance of at least AAL2. If the session does not meet it, force a fresh factor, record the new auth_time and assurance, and only then permit the action. The elevation is time-boxed — it expires, so it cannot be reused for a different sensitive action an hour later.

flowchart TD
    A["Request sensitive action"]:::client --> B{"Session meets\nAAL + freshness?"}:::idp
    B -- "Yes" --> C["Allow action"]:::rs
    B -- "No" --> D["Challenge fresh factor\nTOTP or FIDO2"]:::store
    D --> E["Verify factor"]:::idp
    E --> F["Stamp authTime + aal\nset elevation expiry"]:::idp
    F --> C
    classDef client fill:#fff0ee,stroke:#c0392b,stroke-width:2px,color:#1a1614
    classDef idp    fill:#eef0ff,stroke:#2c3e8c,stroke-width:2px,color:#1a1614
    classDef store  fill:#fffbec,stroke:#d4840a,stroke-width:2px,color:#1a1614
    classDef rs     fill:#ebf5fb,stroke:#2980b9,stroke-width:2px,color:#1a1614

Step 1 — Record Assurance and Auth-Time on the Session

Whatever your session model — server-side store or signed cookie — stamp the assurance level and the moment each factor was verified. These fields are the input to every step-up decision.

type Aal = "aal1" | "aal2" | "aal3";

interface AuthState {
  userId: string;
  aal: Aal; // highest assurance reached this session
  authTime: number; // unix seconds of the most recent factor verification
  amr: string[]; // e.g. ["pwd", "otp"] or ["hwk"]
  elevationExpiresAt?: number; // when a step-up elevation lapses
}

// Call this whenever ANY factor is successfully verified.
export function stampFactor(state: AuthState, method: string, aal: Aal): AuthState {
  return {
    ...state,
    aal: maxAal(state.aal, aal),
    authTime: Math.floor(Date.now() / 1000),
    amr: Array.from(new Set([...state.amr, method])),
  };
}

function maxAal(a: Aal, b: Aal): Aal {
  const order: Aal[] = ["aal1", "aal2", "aal3"];
  return order.indexOf(a) >= order.indexOf(b) ? a : b;
}

Step 2 — Define a Policy for Action Classes

Map each sensitive operation to the assurance and freshness it requires. Keep this declarative so it is auditable.

interface StepUpPolicy {
  minAal: Aal;
  maxAuthAgeSeconds: number; // factor must be fresher than this
}

const POLICIES: Record<string, StepUpPolicy> = {
  "account.change_email": { minAal: "aal2", maxAuthAgeSeconds: 300 },
  "payment.transfer": { minAal: "aal2", maxAuthAgeSeconds: 120 },
  "apikey.rotate": { minAal: "aal2", maxAuthAgeSeconds: 300 },
  "account.delete": { minAal: "aal3", maxAuthAgeSeconds: 120 }, // require a hardware key
};

Step 3 — Evaluate the Guard Before the Action

const AAL_RANK: Record<Aal, number> = { aal1: 1, aal2: 2, aal3: 3 };

export function meetsStepUp(state: AuthState, action: string): boolean {
  const policy = POLICIES[action];
  if (!policy) return true; // not a guarded action
  const now = Math.floor(Date.now() / 1000);
  const freshEnough = now - state.authTime <= policy.maxAuthAgeSeconds;
  const strongEnough = AAL_RANK[state.aal] >= AAL_RANK[policy.minAal];
  return freshEnough && strongEnough;
}

// Express-style guard.
export function requireStepUp(action: string) {
  return (req, res, next) => {
    if (meetsStepUp(req.authState, action)) return next();
    // Tell the client which challenge to mount; do not perform the action.
    return res.status(401).json({
      error: "STEP_UP_REQUIRED",
      action,
      required: POLICIES[action],
    });
  };
}

When the client receives STEP_UP_REQUIRED, it prompts for a fresh factor (a TOTP code from TOTP enrollment and verification, or a WebAuthn assertion for AAL3), the server verifies it, calls stampFactor, and the retried request now passes the guard.

Step 4 — Time-Box the Elevation

Re-verifying a factor should grant a short, explicit elevation window, not a permanently elevated session. Otherwise one step-up unlocks every sensitive action for the rest of the session.

export function grantElevation(state: AuthState, seconds = 120): AuthState {
  return { ...state, elevationExpiresAt: Math.floor(Date.now() / 1000) + seconds };
}

export function isElevated(state: AuthState): boolean {
  return !!state.elevationExpiresAt && state.elevationExpiresAt > Math.floor(Date.now() / 1000);
}

Bind the elevation to the specific action where you can — a confirmed transfer of $5,000 should not silently authorize a second transfer. For the strictest flows, require the step-up per action rather than relying on a shared elevation window.

Reading acr/amr From an OIDC Provider

If an external identity provider performs the step-up, request it with the acr_values parameter on the authorization request, then verify the returned ID token’s claims rather than trusting the client.

import { jwtVerify, createRemoteJWKSet } from "jose";

const JWKS = createRemoteJWKSet(new URL("https://idp.example.com/.well-known/jwks.json"));

export async function readAssurance(idToken: string) {
  const { payload } = await jwtVerify(idToken, JWKS, {
    issuer: "https://idp.example.com",
    audience: "your-client-id",
    algorithms: ["RS256"], // explicit allowlist; never accept alg:none or HS256 here
  });
  return {
    acr: payload.acr as string | undefined,
    amr: (payload.amr as string[]) ?? [],
    authTime: payload.auth_time as number | undefined,
  };
}

Validate auth_time against your freshness policy server-side. A provider can return an old auth_time even on a “fresh” request if it silently reused an existing IdP session; if you need a genuine re-prompt, send prompt=login (or max_age=0) alongside acr_values. Token validation against the provider’s JWKS is the same discipline used throughout secure token refresh and rotation patterns.

Security Implications

  • Stale-session abuse: the core risk step-up addresses — an attacker who hijacks a long-lived session still cannot perform guarded actions without a fresh factor they do not possess.
  • Client-supplied assurance: never trust an aal, acr, or amr value the browser sends; derive it server-side from a factor you just verified or an ID token you validated against the IdP JWKS.
  • Elevation reuse: an un-scoped, long elevation window is a privilege amplifier. Keep windows in the low minutes and prefer per-action elevation for the highest-risk operations.
  • Downgrade via fallback: if a recovery-code login can satisfy a high-AAL guard, the guard is meaningless. Recovery flows should carry reduced assurance; pair them with the discipline in the recovery-codes guide.

Prevention & Monitoring Hooks

  • Log every STEP_UP_REQUIRED and every subsequent successful elevation with action, amr, and IP — a sensitive action that never triggers step-up is a missing guard.
  • Alert on elevation grants immediately followed by multiple distinct guarded actions (possible session theft after a single phished factor).
  • Record auth_time deltas; actions consistently riding the edge of maxAuthAgeSeconds may indicate a too-lax policy.
  • Treat any guarded action that executed without a matching elevation event in the audit log as a P1 control bypass.

Frequently Asked Questions

How is step-up different from just requiring MFA at login?

Login MFA proves identity once, at the start of a session that may live for hours. Step-up re-proves it at the moment of a sensitive action, with a freshness requirement measured in minutes. They compose: MFA establishes the session’s baseline AAL, and step-up demands a newer (and sometimes stronger) factor before specific operations. A session can be fully MFA-authenticated and still fail a step-up guard because its auth_time is too old.

Should the step-up factor be stronger than the login factor?

For the highest-risk actions, yes. A reasonable pattern is password+TOTP (AAL2) for normal login but require a WebAuthn/FIDO2 assertion (AAL3, phishing-resistant) for irreversible operations like account deletion or large transfers. Encode that in the policy’s minAal. For most guarded actions, re-presenting the same AAL2 factor with a fresh auth_time is sufficient.

How do I force a real re-prompt from an upstream OIDC provider?

Send prompt=login or max_age=0 on the authorization request alongside your acr_values. Without these, the provider may satisfy the request from an existing IdP session and return a stale auth_time, so your freshness check passes against authentication that did not actually just happen. Always re-validate auth_time from the returned ID token rather than assuming the prompt was honored.

How long should a step-up elevation last?

Short — typically 60 to 300 seconds, matched to how long the action takes to complete. The window exists to let the user finish the operation they were challenged for, not to pre-authorize a batch of later actions. For the most sensitive operations, drop the shared window entirely and require step-up per action so each one carries its own fresh proof.