Security / security / authentication / oidc
A nonce Is Not a Second state Parameter
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.
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.
the control did its job · the check ran and could not help here · where the harm happened · nothing to check
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 isif (requestNonce == null) return;— no stored nonce, no check, no error. What it sends as thenonceparameter iscreateHashof the stored value, a SHA-256 digest, so the raw value never leaves the server. The failure is anOAuth2Errorwith codeinvalid_nonce. Grep your logs for that string. -
ASP.NET Core puts the switch at
OpenIdConnectOptions.ProtocolValidator.RequireNonce, and the error when it fires isIDX21323. That identifier appearing in a pull request diff next to the wordfalseis 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?