Skip to content

Authentication levels (AAL1, AAL2, AAL3)

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.

  • 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.

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.

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 fine
app.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.

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.

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.