DeepConcepts

Security / security / authentication / oidc

A nonce Is Not a Second state Parameter

The misconception

That nonce is a second, redundant anti-CSRF token: state already proves the callback belongs to this browser, so the nonce is decoration you can leave unverified in the authorization code flow. state is checked at the redirect, against the browser session. nonce is checked after the token exchange, against a claim inside the ID Token. In an authorization code injection attack the attacker starts their own flow and swaps in a stolen code, so their own state comes back and matches by construction — the state check passes, and the only thing that says the ID Token describes someone else is the nonce claim the client was told it could skip.

15 min

The nonce is not a second state parameter. It is checked at a different moment, against a different artifact, by a different piece of your code. state is compared when the browser comes back from the redirect. nonce is compared after the token endpoint replies, against a claim inside the ID Token. There is an attack that walks straight through the first check and is stopped only by the second.

OpenID Connect — OIDC, the identity layer that sits on top of OAuth 2.0 — adds one JSON Web Token to the authorization code flow: the ID Token, a signed statement from the identity provider saying who just authenticated. The nonce is a value your client makes up, sends with the authentication request, and expects to find copied verbatim into that ID Token. Comparing the two is how the client knows the ID Token describes this login and not some other one.

The panel below is a relying party — a client application that logs users in through an identity provider. Choose where it keeps the nonce, how many tabs the user opened, and what is happening to it. The log prints every check the client ran and the reason for its verdict, including the ones contributed by state and by PKCE, the Proof Key for Code Exchange, so you can see which control actually did the work.

Start with no attacker at all. Set the store to one slot per browser session and drag tabs to 3.

What is happening
Where the client keeps the nonce

Every tab starts its own authentication request. All of them are sent before the first one comes back — which is what happens when someone middle-clicks three links out of an email.

The client
outcome
logins completed
honest logins rejected
attack stopped at
What the client did, in order

the control did its job · the check ran and could not help here · where the harm happened · nothing to check

The same run, against all four stores

Two axes, and they pull against each other: a store that holds one value cannot serve concurrent tabs, and a store that never expires an entry cannot detect a second use of it. In this model the client always keeps state per request, so the nonce store is the only thing changing — see the last section for what happens when a real implementation keeps both in the same slot.

Three tabs against a single session slot completes one login and rejects two. Nobody attacked anything. The third request overwrote the nonce the first two were waiting on, so when their ID Tokens arrive carrying n-1 and n-2 the client is holding n-3 and refuses them both. Move the slider to 4 and three logins break. This is what most intermittent nonce mismatch bug reports actually are, and the reason the popular fix is to turn the check off. Put the slider back to 3 and untick Compares the nonce claim: you do indeed get 3 logins and 0 rejections, and you have handed away everything the parameter was for.

Tick Compares the nonce claim again before you read on. Everything from here is about what that check is for, and with it off the panel will agree with the attacker in every scenario below.

What state cannot see

Set the threat to authorization code injection, set the store back to one entry per authentication request, and read the trace. The outcome readout should say rejected, stopped at the nonce check; if it says anything else, the nonce comparison is still unticked from the last section.

The attack is RFC 9700 §4.5.1, where it is numbered in six steps. Someone obtains an authorization code belonging to a user — from a referrer header, a proxy log, a shared device, a mis-registered redirect URI. From their own browser they start a completely ordinary login with your client. When the authorization response comes back through their own device, they replace the code in it with the stolen one. Your client sends that code to the token endpoint with its correct client credentials. The authorization server checks the credentials, checks the code was issued to this client_id, checks the redirect URI, and issues tokens. Your client now holds the user's tokens and binds them to the attacker's session.

The state row in the log is amber, and that is the whole point. state did exactly what it is for: it confirmed that the callback belongs to a flow this browser started. It does belong to a flow this browser started. The attacker started it. RFC 9700 §4.5.2 walks through the server-side checks for the same reason and reaches the same conclusion — client authentication does not stop this, because the legitimate client is the one authenticating.

The nonce sees it because the nonce is compared against something that came back from the token endpoint, not the redirect. The code was minted against the user's authentication request, so the ID Token the token endpoint returns carries the user's nonce. The attacker's session is holding its own. Two different values, and the client refuses. RFC 9700 §4.5.3.2 states the assumption this rests on in one line: an attacker cannot get hold of the user agent state on the victim's device.

Which is also why OpenID Connect Core §3.1.2.1 marking nonce as OPTIONAL for the code flow is so misleading in practice. It is optional to send. §2 is not optional about what happens once you do: If present in the ID Token, Clients MUST verify that the nonce Claim Value is equal to the value of the nonce parameter sent in the Authentication Request. And the authorization server has a matching MUST — if a nonce was in the request, a nonce claim goes in the ID Token, and §2 adds that servers SHOULD perform no other processing on nonce values used. The value is yours. Its meaning is entirely a property of what your client does with it when it comes home.

Four places to keep a nonce, and what each one costs

The store is the whole mechanism. The comparison is one line of code; where the left-hand side of that comparison lives decides everything.

No nonce. Every threat in the panel gets through. This is the default for plain OAuth clients that never asked for an ID Token, and it is fine right up until someone adds scope=openid and starts logging people in with the result.

One slot per browser session. One cookie, one value, overwritten by each new authentication request. It catches all three threats. It also cannot count: at 3 tabs it completes 1 login and rejects 2, and at 4 tabs it rejects 3. The rejections are indistinguishable in the logs from a real attack, which is why these bug reports run to twenty comments before anyone says the word tabs.

One entry per authentication request, keyed by state. The store becomes a small map, and the client deletes the entry the moment it uses it. At 3 tabs: 3 logins, 0 rejections. It stops the injection, it stops a replay from another browser, and because the entry is gone after first use it stops the same ID Token being presented twice in the session that legitimately obtained it. This is the shape RFC 9700 §2.1.1 requires when it says the nonce MUST be transaction-specific and securely bound to the client and the user agent in which the transaction was started. The cost is real: you now have per-transaction state with a lifetime, which is what login worked, then failed after the tab sat open for twenty minutes bug reports are made of. OIDC Core §3.1.3.7 gives you the lever for that — the iat claim can reject tokens issued too far in the past, limiting the amount of time that nonces need to be stored.

A hash of the session cookie. OIDC Core §15.5.2 describes this one directly: store a cryptographically random value as an HttpOnly session cookie and send a cryptographic hash of it as the nonce, so that the nonce in the returned ID Token is compared to the hash of the session cookie to detect ID Token replay by third parties. No server-side state, no map, no expiry to get wrong, and at 3 tabs it completes 3 logins and rejects 0, because every tab in one session legitimately gets the same value.

Now switch the threat to the same ID Token posted a second time from the user's own browser session and watch that store alone let it through. The spec's own wording is precise and worth re-reading: it detects replay by third parties. A different browser has a different session cookie and therefore a different hash, so that case is caught. The same browser has the same cookie, so the same hash, so a token presented twice matches twice. Nothing is being violated — §15.5.2 never claimed otherwise — but a nonce that is per session is not transaction-specific, and RFC 9700 §2.1.1, written eleven years later, asks for transaction-specific. The 2014 implementation note and the 2025 best-current-practice do not agree, and if you implemented §15.5.2 you should know which of the two you are complying with.

OIDC Core §3.1.3.7 is where this is left to you, in as many words: The Client SHOULD check the nonce value for replay attacks. The precise method for detecting replay attacks is Client specific. Comparing is mandatory. Not reusing is a SHOULD with no algorithm attached, and the difference between the last two rows of the comparison strip is the entire content of that sentence.

Where the nonce stops working

Public clients. Untick Confidential with the injection threat running. The log stops saying rejected and starts saying no injection needed. A public client — a single-page app, a mobile app, anything that cannot keep a secret — has a token endpoint that authenticates nobody, so there is nothing to inject into. Whoever holds the code redeems it themselves. RFC 9700 §4.5.3.2 ends on exactly this: nonce does not protect authorization codes of public clients, as an attacker does not need to execute an authorization code injection attack. Tick Also sends PKCE and the run is stopped at the token endpoint instead, because PKCE binds the code itself rather than the identity assertion that follows it. This is why §2.1.1 says public clients MUST use PKCE and only lets confidential OpenID Connect clients use nonce with additional precautions.

Validating after you have already acted. Put the client back the way it was first — tick Confidential again and untick Also sends PKCE, so the injection is once more being stopped by the nonce and nothing else — then tick Creates the session from UserInfo first. The nonce check still runs, still fails, still logs a rejection — and the outcome readout still says the attacker is logged in as the user, because the session was created from the access token before the check completed. RFC 9700 §4.5.3.2 spells out two clauses, and this is the second: a client MUST ensure that, unless and until that check succeeds, all tokens (ID Tokens and the access token) are disregarded and not used for any other purpose. The first clause is subtler and the panel does not model it: if you use a hybrid response type and receive an ID Token in the authorization response as well, you MUST validate the nonce in the one from the token endpoint, because that is the one bound to the code you exchanged.

An attacker who can set the nonce. RFC 9700 §4.5.4 is honest about the limit of both countermeasures: someone who can modify the nonce or the code_challenge in the user's authorization request can set them to values from their own session, and then injection works even with PKCE and nonce in place. It requires the victim's flow to start after theirs and close interaction between the two, and it is the reason the RFC still insists on keeping authorization responses unreadable in the first place rather than treating nonce as the last line.

The store is usually a cookie, so cookie rules apply. A nonce kept in a cookie on a cross-site redirect from the identity provider needs SameSite=None and Secure, or the browser will not send it back and every login fails with a mismatch that looks like an attack. That is one of the most common real causes of these reports, and it has nothing to do with OIDC at all.

Refresh does not carry it. OIDC Core §12.2 says an ID Token obtained by refreshing SHOULD NOT have a nonce Claim, even when the ID Token issued at the time of the original authentication contained nonce, and that if one is present its value MUST match the original. A validator that requires a nonce claim unconditionally will fail every silent renewal — which is one of the two things behind the intermittent failures people fix by disabling the check. The nonce is a control on the authentication event, not on every token that descends from it, and the same is true of every other lifecycle question in calling a token back.

Checking it in a real system

Everything below is a thing you can look at tomorrow, in order of how much it tells you.

1. Decode a real ID Token from your own login and look for the claim. Take the token from your identity provider's response — not from a library log line, from the wire — base64url-decode the payload, and check whether nonce is present. If your client sends the parameter and the claim is absent, your authorization server is violating a MUST in OIDC Core §2 and no amount of client code will save you. If the claim is present, the next question is whether anything in your code reads it.

2. Find the comparison, or prove it does not exist. Grep for the place your library reads the value back, not the place it generates it. Three concrete ones:

  • Spring Security compares in OidcAuthorizationCodeAuthenticationProvider.validateNonce. It reads the stored value out of the authorization request attributes, and the first thing it does is if (requestNonce == null) return; — no stored nonce, no check, no error. What it sends as the nonce parameter is createHash of the stored value, a SHA-256 digest, so the raw value never leaves the server. The failure is an OAuth2Error with code invalid_nonce. Grep your logs for that string.
  • ASP.NET Core puts the switch at OpenIdConnectOptions.ProtocolValidator.RequireNonce, and the error when it fires is IDX21323. That identifier appearing in a pull request diff next to the word false is worth a conversation.
  • openid-client for Node takes the expected nonce as an input at the callback step rather than remembering it for you. If you never pass one in, nothing is compared, and nothing tells you so.

If you cannot point at the line that compares two values, you do not have a nonce; you have a query parameter.

3. Work out which of the four stores you actually have. Read the repository your library saves the authorization request into, and ask one question: can it hold two at once? Spring Security's default, HttpSessionOAuth2AuthorizationRequestRepository, stores a single OAuth2AuthorizationRequest under one session attribute — not a map — and loadAuthorizationRequest returns it only when the incoming state parameter equals the stored request's state. That is the single-slot row of the comparison strip, with one difference worth knowing before you go looking: because state lives in the same slot as the nonce, the second tab fails on the lookup rather than on the comparison, and the error you get is authorization_request_not_found, not invalid_nonce. Same cause, different message, and the message is the reason nobody connects it to the nonce. If you need concurrent authorizations you supply your own repository keyed by state — which is exactly the third row of the strip.

4. Count your mismatches and split them by user agent and by elapsed time. This is the measurement that settles the argument. Emit one metric with the reason attached, then look at the distribution. Mismatches concentrated in sessions with several near-simultaneous authorization requests are the single-slot store, and the fix is the store, not the check. Mismatches concentrated at a fixed age — twenty minutes, an hour — are an expiry, and the fix is either a longer lifetime or an iat-based bound as §3.1.3.7 suggests. Mismatches concentrated in a particular browser or in third-party-cookie-blocking mode are the SameSite problem. A mismatch with no such cluster, arriving with a valid state, is the one worth waking someone for.

5. Test the injection directly. In a staging environment, start a login in browser A and stop at the callback. Start a login in browser B and, at its callback, replace the code parameter with A's while leaving B's state untouched. A correct client rejects it at the nonce comparison and logs a mismatch. A client that logs you in as A's user has just told you what your production configuration does. If it rejects at the token endpoint with invalid_grant instead, PKCE caught it first, which is also a pass — and if it rejects on state, your test is wrong, because you changed something you should not have.

6. Check what happens on renewal. Leave a session open past the access token's lifetime and confirm the silent refresh does not fail on a missing nonce claim. That is the failure that gets the check turned off six months after someone correctly turned it on.

A confidential client stores one nonce per authentication request, deletes it on use, and compares it correctly. It calls UserInfo with the access token to build the user's profile, creates the session, and then validates the ID Token. Under authorization code injection, what happens?

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.