In this article
April 14, 2026
April 14, 2026

Understanding state, nonce, and PKCE

Three mechanisms guard three different checkpoints in OAuth and OpenID Connect. Here is why none of them is optional.

OAuth 2.0 and OpenID Connect lean on a handful of short, cryptic strings to keep authentication flows safe. Three mechanisms cause the most confusion: the state parameter, the nonce claim, and the PKCE pair (code_verifier and code_challenge). Developers routinely wonder whether they overlap, whether one makes another redundant, or whether using all three is overkill.

They do not overlap, and using all three is not overkill. Each one neutralizes a distinct class of attack at a distinct point in the protocol. Drop any one of them and you leave a specific, exploitable gap. This article walks through what each mechanism does, the exact threat it counters, and why no combination of two can substitute for all three.

A quick model of the flow

Before diving into each mechanism, it helps to have a shared picture of the authorization code flow with OIDC. The sequence has four key moments:

  1. Your application redirects the user's browser to the authorization server with a set of query parameters.
  2. The user authenticates (enters credentials, completes MFA, etc.) at the authorization server.
  3. The authorization server redirects the browser back to your application's callback URL, carrying an authorization code.
  4. Your application exchanges that code, server to server, for an access token and (if you requested it) an ID token.

Each of the three defends a different transition in this sequence. Understanding which transition each one guards is the key to understanding why all three are necessary.

State: Binding the response to the request

State protects the redirect in step 3. Specifically, state ensures your application only processes callback responses that it actually initiated.

The threat

An attacker constructs a URL that looks like a legitimate callback, complete with an authorization code the attacker controls, and tricks the victim's browser into loading it. If the application blindly accepts the code, it may end up linking the victim's session to the attacker's account or leaking data in other ways.

This works because HTTP redirects carry no inherent proof of origin. Your callback endpoint receives a GET request with a code parameter and has no built-in way to know whether it came from a flow your application started or from a link the attacker planted in an email. The technique is called cross-site request forgery (CSRF), and without state, the callback endpoint is wide open to it.

How state works

Before redirecting the user in step 1, your application generates a random, unguessable string and stores it somewhere tied to the current browser session (a server-side session store or a secure, HttpOnly cookie). It appends this string to the authorization URL as the state query parameter.

The authorization server treats state as opaque. It does not interpret, validate, or modify it. It simply echoes the value back, unchanged, in the redirect to your callback.

When the callback fires, your application's first check is to pull the state value out of the URL and compare it to the one stored in the session. A match means this response is tied to a request your application made for this particular user session. A mismatch, or a missing value, means the request is unsolicited and must be rejected.

The critical property here is that the stored value is bound to the user's session. An attacker cannot guess it, and even if they somehow learn the value, they cannot inject it into the victim's session store.

What state does not do

State does not protect the authorization code itself. It does not verify anything about the tokens your application receives later. It operates entirely in the browser redirect layer and says nothing about what happens at the token endpoint.

Nonce: Binding the ID token to the session

Nonce protects the ID token received in step 4. Specifically, nonce ensures the ID token was minted for the login your application just initiated, not replayed from a previous session.

The threat

Suppose an attacker obtains a valid, properly signed ID token from a prior authentication, whether through network interception, log exposure, or a compromised browser. They then attempt to present it during a new login flow. The token's cryptographic signature checks out because it was legitimately issued. Without a nonce, the application has no way to distinguish a freshly minted token from a recycled one. This is known as an ID token replay attack.

This threat is specific to OpenID Connect. Plain OAuth 2.0 does not issue ID tokens, so if you are only requesting access tokens from a pure OAuth 2.0 authorization server, nonce is not part of the picture.

How nonce works

Like state, nonce starts as a random, unguessable string generated before the redirect and stored in the user's session. It is sent as a query parameter in the authorization request.

The difference is in what the authorization server does with it. Instead of echoing it back as a URL parameter, the server embeds the nonce value as a claim inside the ID token's JWT payload:

  
{
  "iss": "https://api.workos.com",
  "sub": "user_8273",
  "aud": "your_client_id",
  "nonce": "a8F3xQ9v2mK",
  "iat": 1713100000,
  "exp": 1713103600
}
  

When your application receives the ID token after the code exchange, it decodes the JWT and compares the nonce claim to the value it stored in the session. A match confirms this token was created in response to this specific authentication request. A mismatch means the token belongs to a different session and must be discarded.

Why state cannot do nonce's job

State is verified at the callback, before the token exchange. Nonce is verified inside the token, after the exchange. An attacker who compromises the token endpoint response (or replays an old token into a new session) would not be caught by state, because the state check already passed during the redirect. Nonce catches this because it lives inside the token itself and must match the current session.

PKCE: Proving the code belongs to you

PKCE protects the code exchange in step 4. Specifically, PKCE ensures that the party redeeming the authorization code at the token endpoint is the same party that requested it in step 1.

The threat

On platforms where the redirect back to your application can be intercepted by a different process (a malicious app registered to the same custom URL scheme on a mobile device, or a browser extension with access to navigation events), an attacker can steal the authorization code and race to exchange it before your application does.

For public clients (mobile apps, single-page applications, CLI tools), this is especially dangerous because they have no client secret. The token endpoint has no way to verify the caller's identity. Any process that presents a valid code gets tokens. This is called authorization code interception, and it was the original motivation for PKCE.

Even for confidential clients that do have a client secret, PKCE adds a layer of defense. The OAuth 2.0 Security Best Current Practice (RFC 9700) recommends PKCE for all client types.

How it works

PKCE introduces two values. Before the redirect, your application generates a high-entropy random string called the code_verifier. It then derives a code_challenge by hashing the verifier with SHA-256 and base64url-encoding the result.

The application stores the code_verifier locally and sends only the code_challenge (along with the method, S256) in the authorization request. The authorization server records the challenge and associates it with the authorization code it issues.

When your application later exchanges the code at the token endpoint, it sends the original code_verifier alongside the code. The authorization server hashes the verifier, compares the result to the stored challenge, and issues tokens only if they match.

An attacker who intercepts the code does not have the verifier. The challenge, which was sent over the front channel, is a one-way hash and cannot be reversed to recover the verifier. Without the verifier, the stolen code is worthless.

Why state and nonce cannot do PKCE's job

State is checked by your application at the callback. It tells your application that the redirect is legitimate. But it says nothing to the authorization server about who is presenting the code at the token endpoint.

Nonce is embedded in the ID token and checked after tokens are issued. It cannot prevent the tokens from being issued to the wrong party in the first place.

PKCE is the only mechanism that lets the authorization server itself verify the caller's identity during the code exchange. It operates at the token endpoint, which is a different trust boundary from the redirect (state) and the token payload (nonce).

Where each check happens

A useful way to keep the three straight is to note where each check happens:

  • The state parameter is verified by your application when the browser arrives at the callback URL. It guards the redirect.
  • The nonce claim is verified by your application when it decodes and validates the ID token after the token exchange. It guards the token's freshness.
  • The code_verifier is verified by the authorization server when your application sends it to the token endpoint. It guards the code exchange.

Three different verifiers, three different parties or moments, three different attacks neutralized. No single parameter can cover another's checkpoint.

Common mistakes

A few patterns come up repeatedly in production code:

  • Using a static or predictable state value. Some implementations use a hash of the session ID or a timestamp. If the attacker can predict the value, CSRF protection collapses. State must be generated from a cryptographically secure random source for every authorization request.
  • Skipping nonce validation when using the authorization code flow. Developers sometimes assume that because the code exchange happens over a secure back channel, the ID token does not need a nonce check. But the nonce protects against replay at the application layer, not the transport layer. TLS prevents eavesdropping; nonce prevents reuse.
  • Treating PKCE as optional for confidential clients. The argument is that a client secret already authenticates the client at the token endpoint. This is true, but PKCE defends against a different vector: it proves that the specific authorization request and the token request share a common origin. A leaked or rotated client secret does not retroactively compromise past PKCE challenges.
  • Reusing values across parameters. State, nonce, and code verifier must be independent, separately generated random strings. Reusing the same value for two of them collapses their independence and can create subtle vulnerabilities where compromising one value compromises both protections.

Putting it together

A well-secured OIDC authorization code flow includes all three mechanisms in the initial request and verifies each one at the correct checkpoint. The authorization URL will contain state, nonce, code_challenge, and code_challenge_method alongside the usual client_id, redirect_uri, response_type, and scope.

On the way back, state is checked at the callback. The code is exchanged with the verifier at the token endpoint. The ID token is decoded and the nonce is confirmed against the session. Only after all three checks pass should your application consider the user authenticated.

None of these mechanisms is a silver bullet, and none is redundant. They form a chain of verification that spans the entire flow, from the first redirect to the final token validation. Removing any link in that chain reopens a specific, well-documented attack surface.

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.