Require fresh MFA for sensitive actions using acr and amr claims
For developers adding step-up authentication before high-risk actions like payout changes, password resets, or API key reveal. You will wire an OpenID Connect authorization request that forces a fresh MFA, then reject the action unless the returned ID token or session contains the expected acr/amr evidence.
TL;DR — For sensitive actions, do not trust a stale login session. Redirect the user through a new OIDC authorization request with
prompt=login,max_age=0, and your provider's MFA requirement parameter, then allow the action only if the resulting token/session shows the expectedamrvalue (for examplemfa,otp,hwk) and, if your provider emits it, anacrvalue that matches your required assurance level. Reading time: ~5 min
Goal
When you are done, a sensitive endpoint in your app will force the user through a fresh MFA challenge and will only complete the action if the post-login token or server-side session contains the required amr and optionally acr claims from that fresh authentication.
Prerequisites
- An OpenID Connect provider that issues ID tokens or a userinfo/session payload with
amrand optionallyacr - Permission to edit your app's OIDC client settings and redirect URIs in your identity provider
- A test user enrolled in MFA
- Node.js >= 20 — check with:
node --version
curl>= 8 — check with:
curl --version | head -n 1
- Your app's OIDC values at hand:
ISSUERlikehttps://idp.example.comCLIENT_IDCLIENT_SECRETREDIRECT_URIlikehttps://app.example.com/oidc/callback
- A server-side session store already in place; do not base step-up decisions only on browser-visible state
Steps
Step 1: Decide the exact actions that require step-up
Add a hard-coded list in your app for actions that must require fresh MFA.
{
"stepUpRequiredFor": [
"POST /settings/change-email",
"POST /settings/change-password",
"POST /billing/update-bank-account",
"POST /api-keys/:id/reveal",
"POST /admin/users/:id/impersonate"
]
}
Success looks like: you can point to the exact routes that will redirect for step-up instead of trying to infer risk dynamically later.
Step 2: Build the authorization request that asks for a fresh login plus MFA
Use a dedicated step-up start endpoint. The exact MFA parameter is provider-specific; keep the generic OIDC parts fixed and add your provider's documented MFA request parameter in one place.
cat > step-up-url.js <<'EOF'
const crypto = require('node:crypto');
const qs = new URLSearchParams({
response_type: 'code',
client_id: process.env.CLIENT_ID,
redirect_uri: process.env.REDIRECT_URI,
scope: 'openid profile email',
prompt: 'login',
max_age: '0',
state: crypto.randomBytes(16).toString('hex'),
nonce: crypto.randomBytes(16).toString('hex')
});
// Add ONE provider-specific MFA hint here, for example:
// qs.set('acr_values', 'urn:example:aal2');
// or qs.set('claims', JSON.stringify({ id_token: { acr: { essential: true, values: ['urn:example:aal2'] } } }));
// or provider-documented MFA enforcement parameter.
console.log(`${process.env.ISSUER}/authorize?${qs.toString()}`);
EOF
node step-up-url.js
Success looks like: you get a full /authorize?... URL containing prompt=login and max_age=0.
Step 3: Save the intended action and redirect the user to the IdP
In your app, store the action being attempted in the server-side session before redirecting.
app.post('/api-keys/:id/reveal', async (req, res) => {
req.session.stepUp = {
action: 'POST /api-keys/:id/reveal',
resourceId: req.params.id,
startedAt: Date.now()
};
res.redirect(302, buildStepUpAuthorizeUrl(req.session));
});
Success looks like: the browser receives a 302 to your IdP instead of revealing the key immediately.
Step 4: Exchange the code and inspect acr, amr, and auth time
Handle the callback server-side, verify the token, and persist only a short-lived "step-up passed" marker in the session.
import express from 'express';
import session from 'express-session';
import { Issuer } from 'openid-client';
const app = express();
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));
const issuer = await Issuer.discover(process.env.ISSUER);
const client = new issuer.Client({
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
redirect_uris: [process.env.REDIRECT_URI],
response_types: ['code']
});
function hasRequiredAmr(amr = []) {
const values = new Set(amr.map(v => String(v).toLowerCase()));
return values.has('mfa') || values.has('otp') || values.has('hwk') || values.has('swk');
}
function hasRequiredAcr(acr) {
if (!process.env.REQUIRED_ACR) return true;
return acr === process.env.REQUIRED_ACR;
}
app.get('/oidc/callback', async (req, res, next) => {
try {
const params = client.callbackParams(req);
const tokenSet = await client.callback(process.env.REDIRECT_URI, params, {
state: req.session.oidcState,
nonce: req.session.oidcNonce
});
const claims = tokenSet.claims();
const authTime = claims.auth_time ? claims.auth_time * 1000 : 0;
const freshEnough = authTime && (Date.now() - authTime) < 2 * 60 * 1000;
if (!freshEnough) return res.status(403).send('step_up_failed: auth_time too old or missing');
if (!hasRequiredAmr(claims.amr)) return res.status(403).send(`step_up_failed: amr=${JSON.stringify(claims.amr || [])}`);
if (!hasRequiredAcr(claims.acr)) return res.status(403).send(`step_up_failed: acr=${claims.acr || 'missing'}`);
req.session.stepUpPassed = {
action: req.session.stepUp?.action,
at: Date.now(),
amr: claims.amr || [],
acr: claims.acr || null,
sub: claims.sub
};
res.redirect(303, '/complete-sensitive-action');
} catch (err) {
next(err);
}
});
Success looks like: after the callback, the session contains stepUpPassed with the expected amr and optional acr values.
Step 5: Enforce the step-up marker on the sensitive endpoint
Reject the action unless the step-up was recent, for the same user, and for the same action.
function requireRecentStepUp(expectedAction, maxAgeMs = 5 * 60 * 1000) {
return (req, res, next) => {
const s = req.session.stepUpPassed;
if (!s) return res.status(403).send('step_up_required');
if (s.action !== expectedAction) return res.status(403).send('step_up_failed: wrong action');
if ((Date.now() - s.at) > maxAgeMs) return res.status(403).send('step_up_failed: expired');
if (s.sub !== req.session.user.sub) return res.status(403).send('step_up_failed: wrong subject');
next();
};
}
app.post('/api-keys/:id/reveal',
requireRecentStepUp('POST /api-keys/:id/reveal'),
async (req, res) => {
res.json({ secret: 'revealed-value' });
}
);
Success looks like: direct calls to the endpoint return 403 step_up_required, while the same call after step-up succeeds.
Step 6: Test the redirect and callback path from the terminal
Check the initial redirect and confirm your callback is reachable.
curl -i https://app.example.com/api-keys/123/reveal
Expected shape on first hit:
HTTP/2 302
location: https://idp.example.com/authorize?response_type=code&client_id=app123&redirect_uri=https%3A%2F%2Fapp.example.com%2Foidc%2Fcallback&scope=openid+profile+email&prompt=login&max_age=0&state=...&nonce=...
set-cookie: sid=...; Path=/; HttpOnly; Secure; SameSite=Lax
Then check the callback route itself is not blocked by your proxy:
curl -I https://app.example.com/oidc/callback
Expected shape:
HTTP/2 400
content-type: text/plain; charset=utf-8
A 400 here is fine without query params; a 404 means your route or reverse proxy is wrong.
Verify it works
Run the full browser flow with a test user enrolled in MFA, then verify both failure and success cases.
View as text
[Browser] ---- 1. POST sensitive action ----> [App] [App] ---- 2. 302 step-up authorize ----> [IdP] [IdP] ---- 3. password + MFA ----> [Browser] [Browser] ---- 4. GET /oidc/callback?code=... ----> [App] [App] ---- 5. token check acr/amr ----> [Session] [Browser] ---- 6. retry sensitive action ----> [App]
Use these checks:
curl -i https://app.example.com/api-keys/123/reveal
Expected before step-up:
HTTP/2 302
location: https://idp.example.com/authorize?...prompt=login&max_age=0...
After completing login + MFA in the browser, retry the action in the same browser session. Expected result: 200 with the protected response.
If you log the claims server-side, expected shapes are:
{
"sub": "00u123...",
"auth_time": 1793358123,
"amr": ["pwd", "otp"],
"acr": "urn:example:aal2"
}
Reject these shapes:
{
"sub": "00u123...",
"auth_time": 1793357000,
"amr": ["pwd"]
}
Common pitfalls
prompt=login without an MFA requirement
Mistake: you only send prompt=login&max_age=0 and assume MFA will happen.
Symptom: the user is asked for password again, but the token comes back with amr: ["pwd"] and your endpoint returns 403 step_up_failed: amr=["pwd"].
Fix: add your provider's documented MFA enforcement parameter, then keep checking amr on return.
Trusting acr names across providers
Mistake: you hard-code acr=loa2 or acr=urn:mace:incommon:iap:silver without confirming what your provider emits.
Symptom: every callback fails with 403 step_up_failed: acr=missing or a different value than expected.
Fix: log one real token from your provider, set REQUIRED_ACR to that exact emitted value, or rely on amr if acr is not provided.
Accepting old MFA from a long-lived session
Mistake: you check only amr and ignore auth_time.
Symptom: a user who completed MFA hours ago can still perform the sensitive action without a fresh challenge.
Fix: require auth_time and reject if it is older than your step-up window, for example 2 minutes at callback and 5 minutes at action execution.
Storing step-up state in the browser
Mistake: you put stepUpPassed=true in localStorage or a signed but user-visible cookie.
Symptom: the browser can replay or copy the marker to unrelated actions or users.
Fix: store stepUpPassed server-side and bind it to sub, action name, and a short expiry.
Callback route mismatch
Mistake: the redirect URI in the app and the identity provider do not match exactly, including scheme, host, path, or trailing slash.
Symptom: the IdP returns an error like invalid_redirect_uri, or your app sees no callback at all.
Fix: copy the exact callback URI string into both places, then verify with:
curl -I https://app.example.com/oidc/callback
A reachable route should not return 404.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI