Skip to content

Step-up authentication

Step-up is the pattern of asking the user to re-authenticate, more strongly, before performing a sensitive action — even though they are already signed in. The classic case: a user signed in an hour ago with a password. They are now trying to change their account email. Before letting them, you want them to prove they still hold a strong factor (passkey, fresh password entry, TOTP).

The pattern shows up in:

  • Changing primary email or phone number.
  • Initiating a payment over a threshold.
  • Generating an API key.
  • Accessing a sensitive admin area inside your product.
  • Modifying MFA factors.
Step-up round trip — the 401 is the trigger, the new access token is the result.

Step-up is rooted in the Authentication Assurance Level (AAL) concept. Roughly:

  • AAL 1 — the user signed in somehow. Could be just a password.
  • AAL 2 — the user signed in with two factors. Standard MFA.
  • AAL 3 — the user signed in with phishing-resistant MFA and a hardware-backed key, all in a recent window.

Each session carries its current AAL. Each protected action can require a minimum AAL. If the session's AAL is below the action's requirement, step-up kicks in.

See the AAL concept topic for the full model. This topic covers how you use it.

const { stepUp } = useIntelliAuth()
async function changeEmail(newEmail: string) {
const result = await stepUp({ requiredAal: 2, reason: 'change_email' })
if (result.kind === 'success') {
// The session's AAL has been raised. Proceed with the API call.
await api.put('/me/email', { email: newEmail })
} else if (result.kind === 'cancelled') {
// User chose not to step up. Don't proceed; show a polite explanation.
} else {
// Inspect result.error.
}
}

The SDK:

  1. Tells IntelliAuth which AAL the action requires.
  2. IntelliAuth decides which factor the user must complete to reach that AAL.
  3. The SDK renders the appropriate prompt (passkey, TOTP, SMS — whichever factors the user has and the platform deems suitable).
  4. On success, the session is updated and the new AAL is reflected in the next access token.

The platform's policy engine looks at:

  • The user's current AAL.
  • The factors the user has enrolled.
  • The tenant's step-up policy (which factors satisfy which AAL).
  • Risk signals — IP reputation, device fingerprint, recent failed sign-ins.

If the user has WebAuthn enrolled, that's typically the platform's first choice — a single tap, strong assurance. If they don't, the platform falls back to TOTP, then SMS.

You can hint a preferred factor:

await stepUp({ requiredAal: 2, preferredFactor: 'webauthn' })

But the platform makes the final call; if the user does not have the preferred factor, it picks something they do have.

Step-up only works if your backend actually checks the AAL on protected actions. The access token carries an acr claim (Authentication Context Class Reference) and an auth_time claim. Common pattern (illustrative — verifyAccessToken is whichever JWT helper your stack uses):

// On the protected endpoint
const token = verifyAccessToken(req)
const tokenAge = Date.now() / 1000 - token.auth_time
if (token.acr !== 'aal2' || tokenAge > 300) {
// The session isn't fresh enough or strong enough.
return res.status(401).json({
error: 'step_up_required',
required_acr: 'aal2',
max_age: 300,
})
}
// Proceed with the sensitive operation.

When your frontend sees step_up_required, it calls stepUp(). The SDK runs the prompt. The user completes the new factor. The session is refreshed. The frontend retries the original call with the new token.

This is the full round trip. Most of the friction is intentional — sensitive operations should feel different from routine ones.

A step-up doesn't last forever. The session's AAL is "anchored" to when the factor was completed; once the anchor is older than the policy's max-age window, the AAL effectively drops back to baseline and the user must step up again.

Common defaults (confirm in your tenant's policy — they're tunable):

  • AAL 2 anchor good for 15 minutes.
  • AAL 3 anchor good for 5 minutes.

Tune these in the tenant admin console. Tight windows are friendly to "do several sensitive things in a row"; loose windows reduce prompts at the cost of slightly stale assurance.

Beyond explicit "you asked for AAL 2", the platform can decide on its own to demand step-up — risk-based step-up. Examples:

  • Sign-in from a brand-new device → demand step-up before the next sensitive action.
  • IP address reputation drops below threshold mid-session → demand step-up.
  • Velocity anomaly (the user signed in from two cities five minutes apart) → demand step-up.

Configure adaptive step-up policies in the tenant admin console. The pattern from your code's perspective is the same — you handle step_up_required returns and call stepUp(). You do not need to know the specific reason; the platform sorts it out.

Step-up prompts have a small but real cognitive cost. Frame them well:

  • Do say what they're protecting. "Confirm it's you before changing your password."
  • Don't say "for security reasons" without specifics — users tune that out.
  • Do offer a way out. "Not now" should leave them on the same page, not log them out.
  • Don't demand a stronger factor than the operation warrants. Step-up is for sensitive actions; if you ask for it routinely, users will start dismissing prompts reflexively.