What does the OAuth 2.0 `state` authorization-request parameter protect a client against, and what does it not prove?
answer
- the client invents it, the server echoes
- opaque, unguessable, one per request
- bound to this browser's session
- rejects a callback nobody asked for
- says nothing about which server answered
basics
~20 sThe state parameter is an opaque value a client generates per authorization request, binds to that browser's session and checks on the callback. It rejects authorization responses the client never asked for. It proves nothing about which authorization server answered.
solid answer
~50 s`state` is an opaque, unguessable value the client puts on the authorization request; the authorization server returns it unmodified on the authorization response. The client stores it **bound to the user agent** - in that browser's server-side session - and on the callback compares what came back against what that session is holding, then consumes it. What that check buys is rejection of an authorization response the client did not initiate from this browser: without it, an attacker who completes a flow on their own account can feed their `code` to a victim's callback and silently join the victim's local account to the attacker's account at the authorization server. What it does not do is say anything about which authorization server produced the response, or give the response any integrity protection. It is the default you send unless you have established the condition that lets you rely on the other CSRF protection RFC 9700 names.
code
http · 5 linesGET /authorize?response_type=code&client_id=s6BhdRkqt3&scope=journeys.read&state=af0ifjsldkj&redirect_uri=https%3A%2F%2Fclaims.example.rail%2Foauth%2Fcallback HTTP/1.1
Host: as.example.net
HTTP/1.1 302 Found
Location: https://claims.example.rail/oauth/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkjgo deeper
Recall the shape: the client invents an unguessable value, sends it, and checks that the same value comes back on the callback before doing anything with the response.
Explain the binding - the value lives against that browser's session and is consumed on use - and tell the injection story it prevents, where a victim's account gets linked to an attacker's.
Show the failure modes you would look for in a review: unbound values, one slot per session breaking concurrent tabs, and logs that record the mismatch together with the live code.
Decide the house rule: whether clients in your estate rely on the proof-key mechanism for this protection and must therefore confirm support, or always send the parameter and treat it as non-negotiable in review.
## What `state` is `state` is an authorization-request parameter defined by RFC 6749. The client generates it, puts it on the request to the authorization endpoint, and the authorization server is required to return it **unmodified** on the authorization response: ```http GET /authorize?response_type=code&client_id=s6BhdRkqt3&scope=journeys.read&state=af0ifjsldkj&redirect_uri=https%3A%2F%2Fclaims.example.rail%2Foauth%2Fcallback HTTP/1.1 Host: as.example.net ``` The authorization server treats it as opaque. It never interprets it, never validates it and has no opinion about it. Every bit of security value in `state` comes from what the **client** does with it. ## The attack it stops Take the rail operator's delay-repay claim service. A passenger links their ticket-retailer account so the service can read their journey history and fill in claims automatically. Without a `state` check, an attacker runs the flow themselves against the retailer, gets an authorization response for **their own** retailer account, and stops before redeeming it. They then cause the victim's browser to visit the claim service's redirection endpoint carrying that response - a link in an email, an image tag, any cross-site navigation. The claim service sees a callback with a code, redeems it, and attaches the **attacker's** retailer account to the **victim's** logged-in claim-service account. Now every journey the victim's account reads comes from the attacker's data, and anything the victim files, uploads or authorises against that link is visible to the attacker. This is cross-site request forgery aimed at the callback, and the general class of forged cross-site requests is a subject of its own; what is specific here is that the OAuth callback is a GET with a credential in it, so it is trivially forgeable unless the client insists it asked for this. ## Binding is the whole point A `state` value that is not tied to the user agent is decoration. The check has to be: *the value on this callback equals the value stored for this browser's session*. - Generate it from a cryptographically secure random source, per request, and make it long enough not to be guessed. - Store it **server-side against the session**, or in a cookie whose value the server compares - not merely echoed back out of the same URL. - Compare, then **consume** it, so the same authorization response cannot be replayed a second time. - Reject a callback with a missing value, an unknown value, or no stored value for that session. All three are the same answer: do not redeem the code. - Give the stored value a short expiry, so an abandoned flow does not leave a usable slot open for hours. | Check | What it proves | What it does not prove | |---|---|---| | `state` matches the session's stored value | This browser started this flow, and the response is the one it was waiting for | Anything about who issued the response | | `state` is present and well-formed | Nothing on its own | That it was ever bound to a user | | `state` is consumed after use | The response cannot be replayed at the callback | That the code was not also delivered elsewhere | The limit in the right-hand column matters. `state` is request binding and CSRF protection. It is **not** integrity protection over the response, and it is **not** a check of which authorization server answered - that is a separate mechanism owned by a different subject. A client that treats a matching `state` as "the response is genuine and from the right place" has drawn a conclusion the parameter does not support. ## When `state` is not the CSRF defence The common advice is "always send `state`", and as a default it is right. The specification is more precise. RFC 9700 section 2.1 says that a client which has **established** that the authorization server supports the proof-key mechanism MAY rely on that mechanism's CSRF protection instead of `state`, in which case `state` is free to carry application state - the page the user was on, the claim they were mid-way through. "Established" is doing real work in that sentence: assuming support you have not confirmed leaves you with neither protection. ## Failure modes worth recognising - A constant `state`, or one derived from the user identifier, so any value passes the comparison. - A value put in the URL and compared only against itself on return, which an attacker supplies on both sides. - A value stored in a shared cache without the session in the key, so two concurrent flows in two tabs validate each other's responses. - Logging the failure with the code still attached, which puts the credential in the log while investigating the attack.
- Is `state` always required for cross-site request forgery protection on the callback?No. RFC 9700 section 2.1 permits a client that has established the authorization server supports the proof-key mechanism to rely on that mechanism's protection instead, leaving `state` to carry application state. Sending `state` remains the safe default, because assuming support you have not confirmed leaves you with neither defence.
- Where must the value be stored for the check to mean anything?Bound to the user agent - in that browser's server-side session, or in a cookie whose value the server compares. A value taken from the callback URL and compared against itself, or held in a cache keyed by anything other than the session, is a check the attacker controls both sides of.
- What should the client do when `state` does not match?Stop. Do not redeem the code, do not create or link an account, and return a generic error to the browser. Record a security event with the session identifier and the mismatch, but keep the authorization code out of that log line - it is still a live credential.
- Two tabs start a link flow at once and the second callback fails. What is usually wrong?The client is storing one slot per session and overwriting it, so the first tab's stored value is gone by the time its callback arrives. Store pending values as a small keyed set with short expiries and look the callback's value up in it, rather than assuming one flow per browser at a time.
saying these in an interview costs you the question
- Calls `state` a signature or integrity check on the response
- Generates `state` but never binds it to the browser session
- Compares the returned value against the same URL it came from
- Reuses one constant value for every authorization request
- Says a matching value proves which authorization server answered
- Leaves the stored value usable after the callback has consumed it