Authorization · Information security · 9 min read
The State Parameter in OAuth 2.0: CSRF, PKCE and RFC 9700
Short answer
The state parameter is a random, single-use value that an OAuth client sends with the authorization request and checks when the user comes back; it ties the authorization response to the browser session that started the flow, which stops cross-site request forgery that would otherwise let an attacker inject their own authorization code. RFC 9700 allows PKCE to provide the same CSRF protection when the authorization server is known to support it, but state remains the standard way to carry and verify per-request context.
Key takeaways
- Without CSRF protection, an attacker can make a victim's browser complete an OAuth flow with the attacker's code, silently linking the attacker's account to the victim's session.
- Generate state with at least 128 bits from a cryptographically secure random source, bind it to the user's session, compare it exactly and use it once.
- RFC 9700 section 4.7: if the authorization server supports PKCE, PKCE can serve as CSRF protection; otherwise state or nonce MUST be used.
- If you put application data in state, protect it against tampering and swapping, for example by storing it server-side under the random value.
- Check state on error responses too, and reject responses that arrive with no matching pending request.
The state parameter is easy to overlook. It is a short, optional-looking value in the authorization request, and many tutorials either skip it or set it to a constant. Yet it closes one of the oldest attacks on OAuth: cross-site request forgery against the client's redirect URI. This article explains the attack, shows how to implement state properly, and sets out how state, PKCE and the OpenID Connect nonce share the job under the current best practice, RFC 9700.
What is the state parameter?
state is an opaque value the client creates for each authorization request and expects to receive back, unchanged, with the authorization response.
In the authorization code flow, the client sends the browser to the authorization server:
GET /authorize?response_type=code
&client_id=app123
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid%20profile
&state=Vn1kq0M9cGq2sU8y8rB3cJw7xTzQe1aP4hF6dLmN0oY
&code_challenge=...&code_challenge_method=S256
After the user signs in and approves, the server redirects back:
GET /callback?code=SplxlOBeZQQYbYS6WxSbIA&state=Vn1kq0M9cGq2sU8y8rB3cJw7xTzQe1aP4hF6dLmN0oY
The authorization server does nothing with state except return it. Its meaning is entirely the client's business. RFC 6749 section 4.1.1 calls it RECOMMENDED and describes it as the value the client uses to maintain state between the request and the callback, adding that it should be used to prevent CSRF.
What attack does state prevent?
It prevents an attacker from making a victim's browser deliver the attacker's authorization code to the client, which would sign the victim in to the attacker's account or link the attacker's account to the victim's.
The attack, described in RFC 6749 section 10.12, works like this:
- The attacker starts a normal OAuth flow with the client, using their own account at the authorization server, and stops just before the redirect back. They now hold a valid
codefor their own account. - They craft a link or an invisible image pointing to the client's callback:
https://app.example.com/callback?code=ATTACKER_CODE. - The victim, already signed in to the client, visits a page with that link.
- The victim's browser calls the callback. The client, seeing no reason for suspicion, redeems the code and associates the resulting tokens with the victim's session.
What happens next depends on the app. In a "connect your cloud storage" feature, the victim's uploads now land in the attacker's storage. In a social login flow, the victim may be signed in as the attacker and type private data into the attacker's account. In account-linking flows, the attacker's identity can become a sign-in method for the victim's account, which is a full account takeover.
None of this requires breaking any cryptography. The client simply had no way to know that the response was not the result of a request the victim's own browser had made. state gives it that way.
How should a client generate state?
Use a cryptographically secure random generator to produce at least 128 bits, encode it URL-safely, and store it with the user's current session before redirecting.
Practical rules:
- Randomness. Use the platform's secure generator:
crypto.getRandomValuesin browsers,crypto.randomBytesin Node.js,secrets.token_urlsafein Python,SecureRandomin Java,RandomNumberGeneratorin .NET. Never use timestamps, counters, user IDs orMath.random. - Length. 32 random bytes encoded as base64url is 43 characters. That is plenty. Our PKCE Generator produces state and nonce values of this size alongside the PKCE pair.
- Binding. Store the value where only this user's browser session can reach it: a server-side session, or a cookie that is
HttpOnly,SecureandSameSite=Lax, scoped to the callback path. For single-page apps,sessionStorageis the usual place. - One per request. Create a fresh value for every authorization request. If a user opens two tabs and starts two logins, keep both pending values, keyed by the state itself.
- Expiry. Pending state should expire after a few minutes.
A minimal Node.js sketch:
// starting the flow
const state = crypto.randomBytes(32).toString("base64url");
req.session.oauth = { state, verifier, returnTo: "/reports", created: Date.now() };
res.redirect(buildAuthorizeUrl({ state, code_challenge }));
// at the callback
const pending = req.session.oauth;
delete req.session.oauth; // single use
if (!pending || Date.now() - pending.created > 10 * 60 * 1000) return fail(res);
const got = Buffer.from(String(req.query.state || ""));
const want = Buffer.from(pending.state);
if (got.length !== want.length || !crypto.timingSafeEqual(got, want)) return fail(res);
// only now redeem req.query.code with pending.verifier
How should the client check state on return?
Compare the returned value exactly with the stored one, delete the stored value, and refuse to continue on any mismatch, before touching the code.
The order matters. Check state first, then redeem the code. Treat these as failures:
- No
stateparameter in the response. - A value that matches no pending request for this session.
- A value that has already been used or has expired.
Check state on error responses as well. An error=access_denied response with an unknown state should not, for example, clear a user's pending checkout or trigger retries. And log mismatches as security events, without logging the authorization code.
How do state, PKCE and nonce divide the work?
PKCE binds the code to the client instance that requested it, nonce binds the ID token to the request, and state binds the response to the browser session; RFC 9700 lets PKCE or nonce stand in for state as CSRF protection under specific conditions.
PKCE (RFC 7636) was designed against code interception on mobile platforms, but it also defeats the CSRF attack above. The attacker's code was issued against the attacker's code_challenge. When the client redeems it with the victim session's code_verifier, the authorization server rejects the exchange. RFC 9700 section 4.7 recognizes this: clients may rely on PKCE for CSRF protection, but they MUST first ensure the authorization server supports PKCE, and if it does not, state or nonce MUST be used.
| Mechanism | Defined in | Bound to | Checked by | Stops |
|---|---|---|---|---|
state |
RFC 6749 | User's browser session | Client, at the redirect | CSRF on the callback, mixed-up responses within the client |
| PKCE | RFC 7636 | The client instance that started the flow | Authorization server, at the token endpoint | Code interception, code injection, CSRF (if the server supports it) |
nonce |
OpenID Connect Core | The authentication request | Client, inside the ID token | ID token replay and injection; CSRF in OIDC flows |
iss response parameter |
RFC 9207 | The authorization server | Client, at the redirect | Mix-up attacks between multiple servers |
Two details are worth knowing. First, RFC 9700 section 4.8 describes a PKCE downgrade attack, in which an attacker removes the code_challenge from the request; authorization servers must reject a token request that contains a code_verifier when the original request had no challenge. Second, PKCE only protects you if your client always sends it; a fallback path without PKCE reopens the gap.
The OAuth 2.1 draft builds on this: it requires PKCE for the authorization code flow and describes state as the way to carry application state, with CSRF protection provided by PKCE when the server supports it. Using both remains the most widely compatible choice.
Can state carry application data?
Yes, but the data must be protected against tampering and swapping, so the safest pattern is a random state used as a key to data stored on the client's side.
Apps often need to remember where the user was going, which tenant they selected, or which feature started the flow. Three patterns:
- Server-side lookup (recommended).
stateis random; the data lives in the session under that key. Nothing sensitive travels through the browser. - Signed state.
stateis a signed JWT or MAC'd blob containing the data plus a random value and a binding to the session (for example a hash of the session ID). Signatures stop tampering; the session binding stops an attacker from swapping in a state value issued to them. - Plain encoded data. Something like base64 of
{"returnTo":"/reports"}. Do not do this: it is guessable and forgeable, and turnsreturnTointo an open redirect.
RFC 9700 says it directly: clients that use state to carry application state MUST protect it against tampering and swapping. And never put tokens, secrets or personal data in state; it shows up in browser history, server logs and analytics.
What are the most common mistakes?
Static values, values not bound to a session, missing checks, and reuse.
- A constant state such as
state=xyzor the client ID. Looks compliant, protects nothing. - State derived from public data, like a hash of the user ID. An attacker can compute it.
- Not bound to the browser session. Storing pending state in a global cache keyed only by the value lets an attacker use a state they started themselves.
- Checking only when present. If the code path accepts a response without
state, the attacker simply omits it. - Allowing reuse. Keep pending state single-use and short-lived.
- Comparing loosely. Trim, case-fold or prefix comparisons are unnecessary; compare exactly, ideally in constant time.
- Forgetting framework defaults. Most OAuth and OpenID Connect libraries handle state correctly out of the box, and custom code that bypasses them often loses the check.
How do I test my implementation?
Try the attack against your own client in a test environment.
- Start a login with a test account A and copy the callback URL with its
codeandstateinstead of loading it. - In another browser, sign in to the client as test account B.
- Load the copied callback URL in browser B. The client must refuse it.
- Repeat with the
stateparameter removed, then with a random value. Both must fail. - Load the original callback in browser A twice. The second attempt must fail.
To review the request side, paste your authorization URL into the Authorization URL Builder. It warns when state is missing or short and when PKCE is absent, and it explains every parameter. For a broader review of OAuth threats, see the OAuth security guide and the OAuth downgrade attacks guide.
Questions
Is state required by OAuth 2.0?
RFC 6749 marks state as RECOMMENDED and requires clients to implement CSRF protection for their redirect URI. RFC 9700 keeps the CSRF requirement and lets PKCE fulfil it when the authorization server supports PKCE; otherwise state or the OpenID Connect nonce must be used.
If I use PKCE, can I drop state?
Only if you have confirmed the authorization server supports and enforces PKCE, for example through code_challenge_methods_supported in its metadata. Many teams keep state anyway because it is cheap, works with every server and carries per-request context such as where to send the user afterwards.
How long should the state value be?
At least 128 bits of entropy. A 32-byte random value encoded as base64url gives 43 characters and 256 bits, which is a comfortable default.
Can I use a JWT as the state value?
You can, if it is signed (or encrypted) by the client and still bound to the user's session, for example by including a hash of a session cookie. A simpler pattern is a random state value used as a key to data stored server-side.
What is the difference between state and nonce?
state is checked by the client on the authorization response at the redirect URI. nonce is an OpenID Connect value that the authorization server copies into the ID token, so the client can check that the ID token belongs to this request. They protect different messages, and many clients use both.
What should the client do when state does not match?
Stop the flow, do not redeem the code, discard any pending login data, log the event without the code itself, and show the user a neutral error with a way to start again.
Sources
- RFC 6749: The OAuth 2.0 Authorization Framework, sections 4.1.1 and 10.12
- RFC 9700: Best Current Practice for OAuth 2.0 Security, section 4.7
- RFC 7636: Proof Key for Code Exchange (PKCE)
- RFC 9207: OAuth 2.0 Authorization Server Issuer Identification
- OpenID Connect Core 1.0, nonce and ID token validation
- draft-ietf-oauth-v2-1: The OAuth 2.1 Authorization Framework
Tagged: OAuth 2.0, OAuth, CSRF, Authorization code flow, Attacks, Security, Authorization