A banking-style API has two kinds of calls. Reading an account needs a logged-in user. Moving money needs a user who signed in with a second factor in the last few minutes. The API cannot know that when the user first signs in, because the risk depends on the request. So the first response to a transfer has to be “this token is not enough, authenticate again”.

RFC 9470 (OAuth 2.0 Step Up Authentication Challenge Protocol) standardizes that message. It is short. I read it end to end and built a toy authorization server, resource server and client to find what it settles and what it does not.

TL;DR

  • The challenge is WWW-Authenticate: Bearer error="insufficient_user_authentication", with optional acr_values and max_age parameters. Every example in the RFC uses HTTP 401. Do not copy the 403 that goes with insufficient_scope.
  • The client turns those parameters into a normal authorization request, using the same acr_values and max_age parameters that OpenID Connect already defines. A native app does this in the system browser with PKCE (RFC 8252).
  • The RFC does not say what the client does when several requests are challenged at once, or when the user cannot reach the level asked for. In my toy client, three parallel transfers produced three prompts until I made the step-up single-flight. With an authorization server that cannot do the asked level, nothing in the protocol stops a client from prompting again and again, so the client needs its own stop.
  • Key anything that caches an authorization-dependent response by the token (or its level), never by the user alone. A response cache keyed by user returned the old, reduced view after a successful step-up in my tests.
  • When my toy resource server cannot determine the level, it refuses the risky route with a 503: no challenge, no allow. That is my design choice; the RFC does not prescribe it.

The protocol in one picture

Now the details, in the order the RFC gives them.

Section 3: the challenge

The RFC adds one error code, insufficient_user_authentication, and two auth-params:

  • acr_values: a space-separated list of authentication context class references, “in order of preference”. The protected resource requires one of them.
  • max_age: the allowed elapsed time, in seconds, since the last active authentication event, that is, a user interacting with the authorization server. It “has to represent a non-negative integer”, although it may be a token or a quoted-string.

Both examples in the RFC (Figures 2 and 3) are HTTP/1.1 401 Unauthorized. RFC 6750 pairs insufficient_scope with 403, and it is easy to carry that over. In this protocol the problem is not what the token allows, it is how the user logged in, and the response is a fresh-authentication request, so 401 fits. If your client library only retries on 401, the right status matters.

Parsing is where a first bug can sit, since the value can be quoted or bare. My parser handles both:

export function parseBearerChallenge(header) {
  if (!/^Bearer\s/i.test(header ?? '')) return null;
  const params = {};
  for (const m of header.slice(7).matchAll(/([a-z_]+)=(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/gi))
    params[m[1].toLowerCase()] = m[2] ?? m[3];
  return params;
}

It reads max_age="5" and max_age=5 alike, and I tested it on the RFC’s Figure 2 and 3 forms. It is a regular expression over one header; a production client should use a real WWW-Authenticate parser, since a header can carry several challenges.

Sections 4 and 5: the authorization request and its answer

The client “SHOULD parse the WWW-Authenticate header for acr_values and max_age and use them” in a new authorization request. These two are existing OpenID Connect request parameters (OIDC Core 3.1.2.1): max_age forces an active re-authentication if the last one is older, and the server must then report auth_time. A native app has two requirements from RFC 8252: public clients must use PKCE (section 6), and the request goes through an external user agent, not an embedded web view (section 8.12).

Section 5 deals with a situation that matters in practice. An authorization server may be unable to meet the requested level, say the user has no second factor enrolled. OIDC lets it return the level the session actually has. For access tokens this RFC says the server SHOULD instead fail with unmet_authentication_requirements, because otherwise “clients [get] stuck in a loop where the authorization server keeps returning tokens that the resource server already identified as not meeting its requirements.”

That sentence describes the bug you will write if you take the happy path only. A server that follows the OIDC default will hand back a token at the old level, the resource server will challenge again, and the client will prompt the user again. The RFC makes that the server’s problem; the client still has to not depend on it.

What the RFC leaves to the client

I built the client to hold three decisions the RFC cannot make for you.

One prompt for many challenges. An app screen often fires several requests together. If three transfers are challenged at once and each starts its own login, the user sees three prompts. The fix is a shared promise: the first challenge starts the step-up, the others wait for it and then retry with the token the first one produced.

A loop guard. After one step-up the retry may be challenged again, for instance when the authorization server reached pwd instead of mfa. The client counts step-ups per request and gives up after one, returning the 401 to the caller to explain.

Reading the token at send time. A request that was queued before the step-up completed must not go out with the old token. The client takes current at the moment of sending.

export function makeClient({ rs, as, token, maxStepUps = 1 }) {
  let current = token, stepUp = null;
  const doStepUp = async (params) => {
    stepUp ??= (async () => {                                   // single-flight
      const code_verifier = randomBytes(24).toString('base64url');
      const code_challenge = createHash('sha256').update(code_verifier).digest('base64url');
      const code = as.authorize({ acr_values: params.acr_values, max_age: params.max_age, code_challenge });
      current = as.token({ code, code_verifier }).access_token;
    })().finally(() => { stepUp = null; });
    return stepUp;
  };
  return {
    async call(method, path) {
      for (let attempt = 0; ; attempt++) {
        const bearerUsed = current;                              // read at send time
        const res = rs(method, path, bearerUsed);
        const ch = res.status === 401 ? parseBearerChallenge(res.headers['www-authenticate']) : null;
        if (ch?.error !== 'insufficient_user_authentication') return res;
        if (attempt >= maxStepUps) return { ...res, gaveUp: true };   // loop guard
        if (current === bearerUsed) await doStepUp(ch); else if (stepUp) await stepUp;
      }
    },
  };
}

In the real app as.authorize is a system-browser round trip and the await includes the user’s time. Section 2 of the RFC also says the client must treat the access token as opaque and not read the level out of it; here the client learns the level only from challenges. The same section notes that the new token does not always replace the old one: a client may keep both and pick per call. My toy client keeps a single current token, which is a simplification.

Section 6: where the resource server learns the level

The resource server needs acr and auth_time for the token. For JWT access tokens they are claims (RFC 9068); for opaque tokens they come back from introspection. My toy server keeps them in a table and applies a policy per route:

const policy = {
  'GET /account':   { acr: ['pwd', 'mfa'] },
  'POST /transfer': { acr: ['mfa'], maxAgeSec: 300, onUnknown: 'closed' },
};

maxAgeSec is checked against auth_time. After the step-up, max_age makes the same token stale again after five minutes, and the next transfer is challenged anew. I tested that with an injected clock.

The onUnknown field is the failure choice. When introspection is down, the toy server answers the risky route with 503 and Retry-After, and no challenge: a challenge would send a user through a login that cannot succeed, and allowing the call would make the safeguard disappear exactly when the system is unwell. The low-risk route serves a basic view. Which side to fail on is yours to decide; what matters is that it is a decision.

Run the eight tests

mkdir stepup && cd stepup
# put stepup.mjs and stepup.test.mjs from the lab here
node --test stepup.test.mjs

The eight tests cover: parsing the RFC examples; a low-risk call with no prompt; the challenge, one prompt and a successful retry; three parallel calls with one prompt; an authorization server that cannot do mfa (one prompt, then gaveUp); max_age expiry; the cache case below; and the fail-closed route. To see why the shared promise matters, change stepUp ??= to stepUp = and rerun: the parallel test then counts three prompts instead of one.

Ways a step-up flow breaks

  • Treating the challenge like insufficient_scope. Symptom: the client asks the user for more consent, or never retries, because it looks for 403. Fix: branch on the error value in WWW-Authenticate for 401, and keep the scope path separate.
  • Parallel prompts. Symptom: the user is asked to authenticate several times in a row, and some of the new tokens are discarded. Fix: single-flight the step-up and read the token at send time.
  • An endless loop. Symptom: the login screen reappears after every success. The cause is a token whose level is still too low, which happens when the authorization server returns the session’s current level. Fix: cap step-ups per request, surface the failure, and make the server return unmet_authentication_requirements.
  • A cache keyed by user. Symptom: after a successful step-up the screen still shows the reduced view. In my test a response cache keyed by user served basic after the user held an mfa token; keyed by token it served full. Fix: include the token (or its level) in anything that caches an authorization-dependent response.
  • Following the challenge anywhere. The RFC notes that a malicious resource server might abuse the ability to trigger user interaction. Fix: the client should step up only against the authorization server it is configured for, and only for resource servers it already calls, never to a URL taken from the challenge.
  • Leaking too much in acr_values. The RFC warns that the values can disclose which users or resources need higher assurance. Fix: choose neutral values, and consider returning the challenge only after the token has been validated.

When a step-up fits, and when it does not

Use it when the needed assurance depends on the request: payments, changing credentials, exporting data, and similar actions. Then ordinary calls stay light and only the risky one asks for more.

If every call needs the same level, ask for that level at sign-in and skip the protocol. If your resource server cannot see how the user authenticated (an opaque token without introspection, or an authorization server that does not record it), the challenge has nothing to decide on. The RFC itself says the step-up experience depends on policies the resource server and authorization server agree on, and that it is perfectly possible to build requirements users cannot meet (section 8).

What ran, and what is only reading

Verified, by running the lab on Node.js 22.23.3 (node --test, 8 tests passing): the parser on the RFC’s example forms, the single prompt for three parallel calls (and that removing the shared promise gives three), the loop guard, max_age expiry with an injected clock, the cache-key effect, and the fail-closed route. I read the RFC text for the 401 examples, the acr_values and max_age definitions, the section 5 behavior, the section 2 note about opaque tokens, and the section 9 considerations.

Not verified: everything on a real authorization server. The toy server is in-process and has no network, no real browser round trip, no refresh tokens, and no JWT validation. I did not check how any real product names acr values, handles max_age, or reports unmet_authentication_requirements. I also did not run this on a phone; the system-browser step is only represented by a function call.

What to carry out of the RFC

The RFC gives you a standard way to say “not enough, log in again”. The work around it is not in the standard: share one prompt, count your retries, and key every cache by the token. Add those three to the happy path and the 401 stops being a trap.