- Sessions remember how and when the user proved who they are (
session.auth). - Approvals can demand a fresh passkey or TOTP code from the person approving.
- The MCP OAuth server honors
acr_valuesandmax_age, putsacr,amrandauth_timein access tokens, andrequireMcpAuthcan answer with theinsufficient_user_authenticationchallenge from RFC 9470.
What a session knows
Every session built bycreateSessionManager carries an auth object:
events holds one entry per method, the last time it was proven. The other fields are derived from it.
One tap, SIWE and device sign-ins record nothing. A session that started that way reports
acr: "urn:theauth:acr:none" until the user proves something theAuth can rate. A session created before this feature, or by your own code without authMethods, reports the same.
A social sign-in counts as one factor (single-factor). A TOTP code or a passkey on top of it is a second, different kind of proof, so the session reaches mfa. When the provider returns an ID token with an amr claim (RFC 8176), the registered values are added to the session amr so you can see what the provider said. They are hints only. theAuth cannot check what the provider really did, so they never raise acr, and mfa from a provider is dropped. The hints are read from the ID token that came straight from the token endpoint (OpenID Connect Core section 3.1.3.7). They are not kept when the sign-in goes through a second factor challenge or the native app code exchange.
The classes are ordered, weakest first:
The ordering is a theAuth rule: a stronger class satisfies a request for a weaker one. RFC 9470 itself treats
acr values as opaque strings. The constants are exported as ACR and the list as ACR_ORDER.
Freshness is judged per class
max_age does not look only at the newest event. A password typed a minute ago must not make a passkey from last week look fresh. With a maximum age, the class needs a recent event that can vouch for it:
- single factor: any recent event
- multi factor: a recent TOTP code or passkey (a password alone does not count)
- phishing resistant: a recent passkey
Recording a step-up
POST /auth/2fa/verify and POST /auth/2fa/otp/verify record the proof on the session that made the request, when the code is correct. The headless handlers (theauth.totp.handleRequest for /auth/2fa/verify, theauth.passkey.handleRequest for /auth/passkey/authenticate/verify) do the same when the request carries a session of that user.
A passkey sign-in at /auth/passkey/authenticate/verify creates a new session. To step up the session the user already has, call the step-up pair instead. Both need a signed-in user:
phishing-resistant; a presence only assertion counts as passkey_presence.
If you verify a proof yourself, record it after the check:
recordAuth refreshes authTime and can raise acr. Call it only after the proof was verified. If you use the session cookie cache, call its clear helper afterwards so the cached copy does not hide the step-up for up to its maxAge.
A session refresh keeps its stored events. Refreshing never makes a login look newer.
Approvals that need a strong approver
An approval is a human saying yes to an agent action. When the action is risky, you can require that the human just proved who they are.requiredAcr, maxAuthAge), so the approval UI can tell the user what to do before they click. A single request can also carry its own rule, which beats both settings above:
- Approving without an assertion, or with a weak or old one, throws
StepUpRequiredError. The request stays pending. - Denying never needs step-up. Saying no is always safe.
- The audit row for the approval records
approverAcr,approverAmrandapproverAuthTime. approval.decide(id, { decision, decidedBy, authContext })takes the same assertion.
adminApi) enforces the same rule on POST /approvals/:id/approve. A refused approval answers 401 with WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="...", max_age="..." and the body error code STEP_UP_REQUIRED. It reads the approver’s assertion from the session behind the Bearer token, or from resolveApproverAuth if you sign admins in another way. With neither, a request that has a rule cannot be approved there.
Risk tiers
Risk tiers in the policy engine can name the rule. A tier withrequire: "step_up" and no explicit rule asks for multi factor within five minutes:
policy.evaluate(input, { openApproval: true }) opens the approval with the tier’s rule. When the agent retries with approvalId, spending the approval checks that it was granted under a rule at least as strong as the tier demands. An ordinary approval cannot stand in for a step-up one: the call is denied with approval_step_up_insufficient.
The MCP OAuth server
Give the module a way to read the signed-in user’ssession.auth:
resolveAuthContext set:
- The authorization endpoint accepts
acr_values(space separated, any one acceptable) andmax_age. - If the session already meets them, the code is issued. If not, the answer is
LOGIN_REQUIREDand the built-in routes redirect tologinPagewithreturnTo,acr_values,max_ageandprompt=login. Your login page asks for the missing proof, records it (see above) and sends the user back toreturnTo. - If the server cannot judge, or every
acr_valuesentry is unknown to it, the answer isunmet_authentication_requirements(RFC 9470 section 5). That stops a client from looping on a token the resource would refuse. max_agebelow 30 seconds is raised to 30, so the round trip to the login page cannot loop.- Access tokens carry
acr,amrandauth_time. A refreshed token keeps the original values. Introspection returns them as top-level members. - The authorization server metadata lists
acr_values_supported.
resolveAuthContext nothing changes, except that a request that asks for acr_values or max_age gets unmet_authentication_requirements instead of being ignored.
If you use a consent page, read the user’s session.auth on the server when you approve and pass it to approveConsent({ ..., auth: { acr, amr, authTime } }). Never take it from the request.
Asking from the resource side
requireMcpAuth and requireScopes can demand more than scopes:
401 with:
acr_values and max_age and retries with the new token. A token without an acr claim, such as one from the client credentials grant, never satisfies a requirement. Agents acting alone cannot pass a human step-up.
Things to know
- The
acrandauth_timein a token describe the login that produced it. They do not change while the token lives. A resource that needs a recent login should setmaxAge, and clients get a new token by going through authorize again. - RFC 9470 says a client must not read these claims out of an access token. Only the resource server does.
acr_valuescan leak what you require. Do not put anything sensitive in a custom class name.- Passkeys count as phishing resistant only when the assertion had the user verified flag. Set
userVerification: "required"in the passkey config if you rely on it.