In this article
August 18, 2026
August 18, 2026

RFC 9470 explained: The OAuth step-up authentication challenge protocol

How an API tells a client that the user needs to authenticate again, why acr_values is only a request while max_age is a requirement, and what the auth_time claim actually proves.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Every OAuth deployment eventually runs into the same wall. The authorization server decides how hard the user has to work to sign in, and it makes that decision once, up front, based on what it knows at provisioning time: the client, the scopes, the resource. But the thing that actually knows whether a given request is risky is the API handling it, and it finds out at request time.

An e-commerce API knows a purchase is for 12 dollars or 12,000. A platform API knows the delete call targets an empty test environment or the production one. The authorization server knows none of that, and by the time the API does know, the token has already been issued.

RFC 9470 closes that loop. It gives the resource server a standard way to say "this token's authentication is not good enough, here is what would be," and gives the client a standard way to go get it.

TL;DR

  • RFC 9470 is the OAuth 2.0 Step Up Authentication Challenge Protocol, published September 2023 by Vittorio Bertocci (Auth0/Okta) and Brian Campbell (Ping Identity). It is Standards Track.
  • It adds one error code, insufficient_user_authentication, that a resource server returns in a 401 with a WWW-Authenticate header.
  • It adds two challenge parameters, acr_values (authentication strength) and max_age (authentication recency), which reuse the OIDC authorization request parameters of the same names.
  • The most important practical detail: acr_values is advisory and max_age is enforceable. OIDC says the authorization server MAY try to satisfy acr_values but MUST attempt re-authentication when max_age is exceeded.
  • The auth_time and acr claims are set at authentication time and do not change when an access token is refreshed. That is by design, and it is what makes them trustworthy.

What RFC 9470 actually is

RFC 9470, "OAuth 2.0 Step Up Authentication Challenge Protocol," defines a mechanism for a resource server to signal to a client that the authentication event behind the current access token does not meet the resource server's requirements, and to describe what would meet them.

It is a small specification, and deliberately so. It adds one error code and two challenge parameters, then leans entirely on OpenID Connect for the parameters the client sends next. The RFC is explicit that this is the point: an authorization server that already implements OIDC "will be able to participate in the flow described here with little or no changes."

The flow has six steps:

  1. The client calls the API with an access token it already has.
  2. The API decides the authentication behind that token is too weak or too old, and returns a 401 with a challenge describing what it needs.
  3. The client sends the user to the authorization server with acr_values and/or max_age set from the challenge.
  4. The user authenticates accordingly. The authorization server issues a new access token carrying information about the new authentication event.
  5. The client retries the original request with the new token.
  6. The API checks the authentication event, is satisfied, and serves the request.

Note what is not in that list: the resource server never learns the user's credentials, never prompts for anything, and never talks to the authorization server. It states a requirement and gets back a token that either meets it or does not.

The challenge

Here is a resource server saying the authentication was not strong enough:

  
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication",
  error_description="A different authentication level is required",
  acr_values="myACR"
  

And here is one saying it was not recent enough:

  
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication",
  error_description="More recent authentication is required",
  max_age="5"
  

Both parameters can appear in the same challenge when you need both strength and recency. If the token is also missing scopes, you can add the scope attribute from RFC 6750 alongside them.

Two things about max_age that are easy to get wrong. It is measured in seconds since the last active authentication, where active means the user interacted with the authorization server in response to a prompt. And although the header value can be sent as a token or a quoted string, it has to represent a non-negative integer.

The RFC is careful to say that how the resource server decides a request needs a step up is entirely out of scope. Your risk logic, your policy engine, your "this tool is destructive" registry: none of that is standardized, and none of it needs to be.

The client's move

A client receiving insufficient_user_authentication SHOULD parse the header and carry those values into a fresh authorization request:

  
https://as.example.net/authorize?client_id=s6BhdRkqt3
  &response_type=code&scope=purchase&max_age=5
  

That is it. acr_values and max_age are ordinary OIDC authorization request parameters from section 3.1.2.1 of OpenID Connect Core. RFC 9470 introduces no new authorization server behaviour for processing them.

The part everyone gets wrong: acr_values is a request, max_age is a requirement

If you take one thing from this article, take this. The two parameters look symmetrical. They are not, and the asymmetry is written into OpenID Connect Core in plain language.

On max_age:

Specifies the allowable elapsed time in seconds since the last time the End-User was actively authenticated by the OP. If the elapsed time is greater than this value, the OP MUST attempt to actively re-authenticate the End-User. [...] When max_age is used, the ID Token returned MUST include an auth_time Claim Value.

On acr_values:

Space-separated string that specifies the acr values that the Authorization Server is being requested to use for processing this Authentication Request, with the values appearing in order of preference. [...] The acr Claim is requested as a Voluntary Claim by this parameter.

Two-column comparison. Requesting acr_values means the authorization server MAY attempt to satisfy it, because acr is a voluntary claim, so the token you get back may simply describe the session the user already had, and you must verify the acr claim yourself. Requesting max_age means the server MUST attempt to re-authenticate a stale session and must return an auth_time claim, so compliance is arithmetic: now minus auth_time must be less than or equal to max_age.
The two parameters look symmetrical. Only one of them is backed by a MUST.

A voluntary claim is one the authorization server may decline to satisfy. RFC 9470 spells out the consequence: the authorization server MAY attempt to authenticate the user in a way that satisfies the requested class, and if it cannot, it SHOULD return a value reflecting the authentication level of the current session instead.

Read that again with a security hat on. You ask for a stronger authentication. The user does nothing new. You get back a perfectly valid token carrying an acr claim describing the authentication they already had. If your resource server checks only "did I get a token," you have just been told no in a way that looks like yes.

This is not theoretical. It shows up as bug reports across implementations: Keycloak has had an issue where passing multiple acr_values resolves to the lowest level of assurance rather than the highest, Okta documents a fixed ACR registry where custom values are not supported, and FusionAuth has had acr_values support open as a feature request for years.

RFC 9470 knows about this problem and patches it for access tokens specifically. Its guidance is that when the requested acr cannot be met, the authorization request should fail with unmet_authentication_requirements rather than issue a token that quietly does not comply. The stated reason is worth quoting because it names the failure mode exactly:

The recommended behavior will help prevent clients getting stuck in a loop where the authorization server keeps returning tokens that the resource server already identified as not meeting its requirements.

What to do with this in practice:

  • If you need a hard guarantee, express your requirement as recency with max_age. It is the half of the protocol with MUST behind it.
  • If you use acr_values, verify the acr claim on the token you get back. Never assume the request was honoured. Treat a mismatch the same as a failed authentication.
  • Check whether your authorization server actually implements RFC 9470's fail-closed recommendation, or the OIDC default of returning the session's existing level. These behave very differently under attack and the difference is usually undocumented.

What auth_time proves, and why a refresh does not move it

auth_time is a Unix timestamp, in seconds, of the user's last active authentication. It is the claim your resource server reads to answer "how long ago did a human actually prove they were here?"

The property that makes it useful is stated in section 6.1 of RFC 9470:

the values of those two parameters are established at user-authentication time and will not change in the event of access token renewals.
Timeline across one working day. A user signs in at 09:00, which sets auth_time to 09:00. Four token refreshes follow at 10:30, 12:00, 14:00 and 16:00, and auth_time stays at 09:00 through all of them. Only the step-up at 17:00, an active re-authentication, moves auth_time forward to 17:00.
A refresh proves the client still holds a refresh token. It proves nothing about the human.

So a session that has been silently refreshing in the background for eight hours still reports an auth_time of eight hours ago. That is correct, and it is the entire point. A refresh proves the client still holds a refresh token. It proves nothing about whether the human is still at the keyboard.

This trips people up constantly, usually in the form of a bug report. Spring Security has gone back and forth on whether auth_time should change across refreshes. ZITADEL has an open issue demonstrating a case where max_age was accepted and then bypassed. Better Auth shipped a freshness window that recomputed on every refresh, which meant sessions stayed "fresh" indefinitely, and fixed it in 1.6.

If a freshness check ever appears to pass when it should not, the refresh path is the first place to look.

One more detail worth knowing: OIDC Core notes that max_age=0 is equivalent to prompt=login. Both force a fresh authentication every time.

Reading the result

RFC 9470 covers two ways for a resource server to inspect the authentication event behind a token.

JWT access tokens. Per RFC 9068, the auth_time and acr claims travel in the token itself:

  
{
  "iss": "https://as.example.net",
  "sub": "someone@example.net",
  "aud": "https://rs.example.com",
  "client_id": "s6BhdRkqt3",
  "scope": "purchase",
  "auth_time": 1646340198,
  "acr": "myACR"
}
  

Token introspection. RFC 9470 registers acr and auth_time as top-level members of the RFC 7662 introspection response, so a resource server validating opaque tokens gets the same two facts.

Claim Type Answers
auth_time Unix seconds How recently did the user actively authenticate?
acr String Which authentication context class did that event satisfy?
amr Array of strings Which methods were used? (OIDC, not RFC 9470)

A note on amr: it is often reached for in step-up discussions, and it is the weakest of the three for this purpose. The values are not standardized in a way you can rely on across providers, and OIDC puts the meaning of acr values out of scope too. auth_time is a number with one meaning everywhere. Prefer it.

How to tell whether your authorization server supports this

Section 7 gives you a discovery signal. An authorization server advertises support by including acr_values_supported in its RFC 8414 metadata document:

  
curl https://your-authorization-server/.well-known/oauth-authorization-server | jq .acr_values_supported
  

Its presence signals that the server will understand and honour both acr_values and max_age on incoming authorization requests. Its absence does not necessarily mean max_age is unsupported, since max_age is plain OIDC, but it does mean you should test rather than assume.

What RFC 9470 deliberately does not solve

This is the section most explainers skip, and it is the reason developers implementing step-up run out of guidance so quickly. The RFC is unusually candid about its boundaries.

  • The session layer. From the security considerations: "this document assumes the existence of a session without going into the details of how it is established or maintained." So how you represent elevation in a cookie, whether you rotate the session identifier when a user steps up, and how you return the user to the half-filled form they abandoned are all yours to figure out.
  • The policy layer. How a resource server decides a request needs a step up is out of scope, stated twice.
  • What acr values mean. OIDC puts the semantics of acr values out of scope, which is exactly why implementations diverge.
  • Token caching. The RFC notes that a stepped-up token may have a shorter lifetime, so a client may want to keep both the old and new tokens and choose per call. It explicitly declines to recommend a strategy, and reminds you that clients must treat access tokens as opaque and must not inspect them to decide.
  • User experience. Section 8 acknowledges that a resource server and authorization server pair can, between them, impose requirements "that are impossible for users to comply with or that lead to an undesirable user-experience outcome." The protocol will faithfully transmit a demand nobody can satisfy.

Two security considerations people skip

Your challenge leaks information. Section 9 points out that acr_values in a challenge can disclose things about the user, the resource, and your policy. The concrete example given: a resource server that demands a high assurance level for some users and not others has just published a list of high-privilege accounts worth spearphishing. Think about what your challenge reveals before you make it descriptive.

You can challenge before validating, but weigh it. The RFC permits returning a challenge without first verifying the presented token is valid. That is convenient, and it leaks the authentication requirements of your resource to anyone who has not proven they can get a token for it at all.

There is also a note aimed at authorization server implementers: because this specification lets a resource server trigger user interaction, a malicious resource server can abuse that to spam prompts at users.

What this looks like in AuthKit

This article has named four ways step-up quietly fails:

  • a voluntary acr that comes back unsatisfied
  • a refresh that resets your freshness window
  • a challenge the user cannot actually complete
  • and a re-authentication that destroys the session it was meant to protect.

Here is how AuthKit answers each one.

Requiring a fresh authentication is one parameter. No policy objects, no custom post-login code, no authentication context registry to maintain:

  
const authorizationUrl = workos.userManagement.getAuthorizationUrl({
  provider: 'authkit',
  clientId: process.env.WORKOS_CLIENT_ID!,
  redirectUri: process.env.WORKOS_REDIRECT_URI!,
  maxAge: 300,
});
  

It is built on the enforceable half. AuthKit expresses step-up through max_age and auth_time, the side of the protocol backed by MUST. There is no voluntary claim to second-guess, and no code path where you get a token back and have to work out whether your requirement was honoured.

auth_time never moves on a refresh. Only a real interactive authentication advances it. That is the exact behaviour Spring Security went back and forth on, ZITADEL has an open issue about, and Better Auth had to fix in 1.6. A session that has been refreshing for eight hours reports eight hours, because that is the truth.

The challenge fits the user. AuthKit selects the method from the factors the user actually has: password re-entry, an MFA factor, or a round trip back through their SSO provider. You do not build a method selector, and you do not have to decide what to do about the SSO user who has no local password. That case, the one with an open Keycloak issue attached to it since 2023, is just handled.

The session survives. The session ID is unchanged across a step-up. The user is re-verified, not signed out and signed back in, so you get no orphaned sessions, no lost application state, and no second session row to reconcile. An authentication.reauthenticated event fires with the user, session, method and new auth_time, which is the audit record a SOC 2 or PCI-DSS reviewer is actually asking for.

In the framework SDKs, the check is a function call that fails closed:

  
const { isStale } = await checkRecentAuth({ maxAge: 300 });
if (isStale) return { status: 'reauth_required' };
  

A token with a missing or malformed auth_time reports as stale rather than passing, which is the behaviour you want from a security control on a bad day.

What AuthKit deliberately does not do is decide which of your operations are risky. Emitting the 401 with insufficient_user_authentication stays in your resource server, because your resource server is the only component that knows a given request is a 12 dollar purchase or a 12,000 dollar one. That division is not a gap, it is the entire premise of RFC 9470.

If you want to see it end to end, the Next.js tutorial gates a destructive action on a five minute freshness window, and the AI agent tutorial does the same for an MCP tool call. The reauthentication docs are the reference.

The short version

RFC 9470 is a small, well-designed specification that solves one problem cleanly: letting the component that understands the risk state its requirements to the component that can satisfy them.

The trap is assuming its two parameters carry equal weight. max_age is backed by a MUST and produces a claim you can verify with arithmetic. acr_values is a voluntary claim whose meaning is out of scope, which the RFC itself patches with a SHOULD. Build your guarantees on the first, verify anything you get back from the second, and remember that a refreshed token is not a re-authenticated user.

References