Skip to content

Add MFA step-up to a Next.js app

You have a Next.js app. The user is signed in. They navigate to "Payout destination" and click "Change bank account". Right there, before letting them proceed, you want them to prove they still hold their second factor. That's step-up.

This tutorial wires it up end to end — frontend trigger, backend AAL check, SDK prompt, retry. We use Next.js 14+ App Router; the pattern adapts to Pages Router or other frameworks with small changes.

Before you begin
  • A Next.js 14+ app with @intelliauth/react-sdk already integrated
  • A backend route (API route or external service) where the sensitive operation lives
  • The user has at least one MFA factor enrolled (passkey or TOTP)
  1. User clicks "Change bank account". Frontend calls POST /api/payout-destination.
  2. The backend reads the user's current AAL from the access token. If it's less than AAL 2 or older than 5 minutes, return 401 step_up_required.
  3. The frontend sees step_up_required, calls the SDK's stepUp().
  4. The SDK renders the MFA prompt. User confirms with Touch ID / TOTP.
  5. The SDK receives a fresh access token with elevated AAL.
  6. The frontend retries the original POST. This time the backend accepts.

The user sees one extra "confirm it's you" tap inserted into the flow. The code is short.

A sensitive Next.js Route Handler at app/api/payout-destination/route.ts:

import { NextRequest, NextResponse } from 'next/server'
import { verifyAccessToken } from '@intelliauth/node-sdk'
const TENANT_URL = process.env.INTELLIAUTH_TENANT_URL!
const AUDIENCE = 'https://api.cymmetri.com'
const REQUIRED_ACR = 'aal2'
const MAX_AGE_SECONDS = 300 // 5 minutes
export async function POST(req: NextRequest) {
const authz = req.headers.get('authorization') ?? ''
const result = await verifyAccessToken(authz, { tenantUrl: TENANT_URL, audience: AUDIENCE })
if (!result.valid) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
const { acr, auth_time } = result.token!
const tokenAgeSeconds = Math.floor(Date.now() / 1000) - auth_time
if (acr !== REQUIRED_ACR || tokenAgeSeconds > MAX_AGE_SECONDS) {
return NextResponse.json({
error: 'step_up_required',
required_acr: REQUIRED_ACR,
max_age: MAX_AGE_SECONDS,
}, { status: 401 })
}
const body = await req.json()
// ...validate, persist...
return NextResponse.json({ data: { ok: true } })
}

Three checks: the token is valid, its acr is high enough, its auth_time is recent enough. If any fail, return step_up_required with the details the frontend needs to drive the prompt.

Write a reusable hook that takes a normal fetch call and wraps it with step-up retry:

hooks/useStepUpFetch.ts
import { useIntelliAuth } from '@intelliauth/react-sdk'
export function useStepUpFetch() {
const { getAccessToken, stepUp } = useIntelliAuth()
return async function stepUpFetch(input: RequestInfo, init?: RequestInit): Promise<Response> {
async function go(): Promise<Response> {
const token = await getAccessToken()
return fetch(input, {
...init,
headers: {
...init?.headers,
'Authorization': `Bearer ${token}`,
},
})
}
let res = await go()
if (res.status === 401) {
const body = await res.clone().json()
if (body.error === 'step_up_required') {
const result = await stepUp({
requiredAcr: body.required_acr,
maxAge: body.max_age,
})
if (result.kind === 'success') {
// SDK has fresh tokens. Retry.
res = await go()
} else {
// User cancelled the prompt. Bubble the original 401 up.
}
}
}
return res
}
}

The hook handles the whole round trip — first call, step-up if needed, retry. The caller doesn't care which path was taken.

'use client'
import { useState } from 'react'
import { useStepUpFetch } from '@/hooks/useStepUpFetch'
export function ChangeBankAccount() {
const stepUpFetch = useStepUpFetch()
const [iban, setIban] = useState('')
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
async function save() {
setStatus('saving')
const res = await stepUpFetch('/api/payout-destination', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ iban }),
})
if (res.ok) {
setStatus('saved')
} else {
setStatus('error')
}
}
return (
<form onSubmit={(e) => { e.preventDefault(); save() }}>
<label>
New IBAN:
<input value={iban} onChange={(e) => setIban(e.target.value)} />
</label>
<button type="submit" disabled={status === 'saving'}>Save</button>
{status === 'saved' && <p>Saved.</p>}
{status === 'error' && <p>Couldn't save. Try again.</p>}
</form>
)
}

The component looks like any other form. The step-up prompt appears between "Save" and "Saved." without the component knowing it happened.

Sign in to your app. Don't enrol any MFA yet — sign in with email + password. Click "Save" on the bank account form.

The backend rejects with step_up_required. The SDK opens the step-up prompt. Since you have no factors enrolled, the prompt offers to enrol one first; you scan a QR code with an authenticator; you enter the code. The SDK receives the new tokens; the form retries; you see "Saved."

Next time you save (within 5 minutes), no prompt — the AAL is high enough and the token is fresh enough. After 5 minutes, the prompt comes back.

5 minutes is a sensible default — long enough that a user doing several sensitive actions in a row isn't pestered, short enough that a stolen access token can't drift into sensitive operations.

Adjust per-route. A "delete account" route might use MAX_AGE_SECONDS = 60. A "change marketing preference" route shouldn't require step-up at all.

The right way to think about it: how bad is it if this action is performed by someone who isn't the user? Bad = tight window. Mild = no step-up.

What about routes where the user might not have MFA enrolled yet?

Section titled “What about routes where the user might not have MFA enrolled yet?”

The SDK's stepUp() handles enrolment automatically — if the user has no factor that satisfies the required AAL, the prompt becomes an enrolment prompt. This is the default behaviour; you don't write extra code for it.

If your tenant's policy doesn't require MFA enrolment at sign-in, this is the first moment a user encounters MFA. Make the copy welcoming — "Set up Touch ID once and approving sensitive actions becomes a tap" rather than "MFA is required."

What about routes where step-up itself isn't appropriate?

Section titled “What about routes where step-up itself isn&#39;t appropriate?”

For machine-to-machine calls (no user, just a service token), acr will be missing or low and the AAL check should be replaced with a scope check. Different code path:

if (result.token!.client_id) {
// Machine-to-machine; AAL is not meaningful. Check scope instead.
if (!result.scopes!.includes('payouts:write')) {
return NextResponse.json({ error: 'insufficient_scope' }, { status: 403 })
}
} else {
// Human user; enforce step-up.
// ...the AAL check from above
}

In practice, sensitive endpoints often have BOTH a human-only path (with step-up) and a machine path (with strong scope). The check above lets one endpoint serve both.

Step-up has a real cost — each prompt is friction. Reserve it for actions that really matter:

  • Changing primary email or phone.
  • Payout destination, withdrawal address, paying out.
  • Generating a long-lived API key.
  • Modifying MFA factors.
  • Accessing a sensitive admin area.

Don't step-up:

  • Routine reads.
  • Changes the user can undo themselves (preferences, sort orders).
  • Anything where the impact of unauthorised action is low.

Treating step-up as a "security knob to turn up" trains users to dismiss prompts reflexively, which weakens the prompt's signal when you really need it.