Not every sign-in proves the same level of confidence. A user typing a password proves they know the password — that's some confidence. The same user adding a TOTP code from their authenticator app proves they also possess a specific device — more confidence. Adding a hardware security key proves possession of a phishing-resistant credential — strongest confidence.
The platform models this on a three-tier scale called AAL — Authentication Assurance Level — borrowed from NIST 800-63B. Your access tokens carry the achieved AAL as a claim, and high-value endpoints can require a minimum AAL before they serve a request.
The three levels
Section titled “The three levels”- AAL1 — single-factor authentication. Usually password-only, or magic link, or a social-login session. Good enough for "this is probably the user", not good enough for changing payment methods.
- AAL2 — two-factor authentication. Password + TOTP, or password + SMS code, or password + push notification. Standard for "definitely the user" confidence.
- AAL3 — phishing-resistant authentication. WebAuthn / passkey / hardware security key. The highest level the platform supports; required for high-stakes flows (changing email, transferring funds, deleting accounts).
The platform also tracks AAL0: the user is unauthenticated (no session at all). You won't typically see this in token claims, but the term comes up in policy configuration.
Where AAL lives
Section titled “Where AAL lives”The auth_level claim on the access token says which AAL the current session has achieved:
{ "sub": "usr_01HZX...", "aud": "api.banking.cymmetri.com", "scope": "openid profile read:transactions", "auth_level": "AAL2", // ← session is at AAL2 "amr": ["pwd", "totp"], // ← achieved via password + TOTP "exp": 1750000000}The amr claim (Authentication Methods References) names the specific factors used to reach this AAL — pwd for password, totp for time-based codes, webauthn for security keys, sms for SMS, mlink for magic link, etc.
Enforcing minimum AAL on an endpoint
Section titled “Enforcing minimum AAL on an endpoint”Your API checks the AAL claim before serving:
function requireAal(min: 'AAL1' | 'AAL2' | 'AAL3') { const order = { AAL1: 1, AAL2: 2, AAL3: 3 } return (req: Request, res: Response, next: NextFunction) => { const have = order[req.user.auth_level as 'AAL1' | 'AAL2' | 'AAL3'] ?? 0 const need = order[min] if (have < need) { return res.status(403).json({ error: 'insufficient_auth_level', required: min, achieved: req.user.auth_level, }) } next() }}
app.get('/transactions', requireSession, handler) // AAL1 is fineapp.post('/wire-transfer', requireSession, requireAal('AAL2'), handler)app.delete('/account', requireSession, requireAal('AAL3'), handler)When the API returns 403 with insufficient_auth_level, the client should trigger a step-up flow — a fresh challenge to raise the session's AAL. See Step-up authentication for the SDK shape.
Tenant-level AAL policy
Section titled “Tenant-level AAL policy”The tenant admin sets policy that bumps the AAL up or down for whole flows:
- Sign-in always requires AAL2. Every user has to set up MFA before they can sign in. Strictest.
- Sign-in requires AAL1; specific endpoints require AAL2. Most apps — let users get in easily; ask for MFA at the moment they do something sensitive.
- Sign-in requires AAL1; risk engine bumps to AAL2 when something looks off. Adaptive — only ask for MFA when the platform's risk signals trip a threshold.
Whichever policy the tenant picks, your code reads the AAL claim and reacts. You don't have to know how the AAL was reached — just whether it's high enough.
AAL versus MFA
Section titled “AAL versus MFA”A common confusion: AAL is the level; MFA is the mechanism. A user with MFA enrolled but who hasn't recently challenged is still at AAL1 — having MFA available isn't the same as having used it.
The session's AAL is set by what factors were actually exercised during sign-in (or during the most recent challenge). MFA enrolment is state; AAL is the current session's claim.