Passkey Reauthentication (Step-Up Verification)
When to Use
Use to re-verify the signed-in user's identity before a sensitive operation (password change, financial transaction, account deletion). Unlike sign-in, reauthentication constrains the passkey prompt to the current user's registered credentials.
Decision
| Dimension | Sign-In (Authentication) | Reauthentication |
|---|---|---|
allowCredentials |
[] — discoverable, any user |
Array of the current user's credential IDs |
| Session state | Not yet authenticated | Active authenticated session required |
| Trigger | Autofill or button | Button only — no autofill |
| Server ownership check | Not applicable | MUST verify returned credential belongs to req.user.id |
Pattern
Server — generate constrained options:
router.post('/api/reauth/options', enforceActiveSession, async (req, res) => {
const userPasskeys = await db.findCredentialsByUserId(req.user.id);
const options = {
challenge: serverBase64UrlChallenge,
rpId: 'example.com',
allowCredentials: userPasskeys.map(cred => ({
type: 'public-key',
id: cred.id,
transports: cred.transports, // Speeds up authenticator resolution
})),
};
req.session.reauthChallenge = serverBase64UrlChallenge;
return res.json(options);
});
// After signature verification:
if (storedCredential.passkeyUserId !== req.user.id) {
return res.status(403).json({ error: 'Credential does not belong to the active user.' });
}
Client — button trigger only:
import 'webauthn-polyfills';
let reauthAbortController = new AbortController();
async function triggerReauth() {
reauthAbortController.abort();
reauthAbortController = new AbortController();
const optionsJSON = await (await fetch('/api/reauth/options', { method: 'POST' })).json();
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);
try {
const credential = await navigator.credentials.get({ publicKey, signal: reauthAbortController.signal });
const encoded = credential.toJSON();
const res = await fetch('/api/reauth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(encoded),
});
if (res.ok) showTransactionSuccessUI();
else if (res.status === 404 && PublicKeyCredential.signalUnknownCredential) {
await PublicKeyCredential.signalUnknownCredential({ rpId: 'example.com', credentialId: encoded.id });
}
} catch (err) {
if (err.name === 'NotAllowedError') { /* user cancelled — no UI */ }
}
}
document.getElementById('reauth-btn').addEventListener('click', triggerReauth);
Common Mistakes
allowCredentials: []in reauthentication → Becomes a discoverable flow; on a shared device, any user's passkey satisfies the challenge, breaking session integrity- No server-side ownership check → A valid passkey belonging to user B can satisfy a reauthentication for user A if the server only checks the signature
- Attaching reauthentication to form autofill → Reauthentication is always explicit and button-initiated; never surface passkey suggestions proactively
- Full sign-out and re-sign-in instead of step-up → Unnecessary session disruption; reauthentication is exactly the mechanism to avoid this
See Also
- passkey-management — managing the credentials used in
allowCredentials - passkeys-overview — prerequisites and environment setup
- authentication-best-practices — sensitive-action patterns, session management, MFA step-up
- Reference: MDN parseRequestOptionsFromJSON()