What does Laravel Socialite keep in the session between redirect() and user(), why does the callback throw InvalidStateException, and when is stateless() appropriate?
answer
- the session keys state and code_verifier
- pulled once, compared with hash_equals
- different host means different cookie
- a refreshed callback fails the second time
- stateless() skips the check, not PKCE's session
basics
~20 sredirect() stores a random state (and, with enablePKCE(), a code_verifier) in the session; user() pulls the state and throws InvalidStateException when it is missing or differs. stateless() skips that check for sessionless APIs and gives up its protection.
solid answer
~50 s`redirect()` saves a random 40-character `state` in the session and adds it to the provider URL; with `enablePKCE()` it also saves a 96-character `code_verifier` and sends its S256 challenge. On the callback, `user()` **pulls** `state` from the session and compares it with the query's `state` using `hash_equals`; an empty or different value throws `Laravel\Socialite\Two\InvalidStateException`. In practice that means the session did not survive the round trip: the callback host differs from the one that started the login, the session cookie is `SameSite=strict` so the cross-site return omits it, the session expired, or the user refreshed the callback after the state was already pulled. `stateless()` turns the check off, which suits a token API with no cookie session but removes the login-CSRF defence the state gives. PKCE in Socialite still keeps `code_verifier` in the session, so `stateless()->enablePKCE()` fails without one.
go deeper
Recall that redirect() stores a state in the session and user() throws InvalidStateException when the returned state does not match.
Explain the pull-and-compare mechanics, the session keys used, and the common causes: host mismatch, cookie policy, expiry and refresh.
Diagnose production cases from cookies and hosts, handle replays gracefully, and explain why stateless() trades away login-CSRF protection and does not free PKCE from the session.
Set the rule for when a product may run social login statelessly, and what compensating control the client flow must provide.
## What travels in the session Socialite's OAuth 2 providers keep two values in the Laravel **session** between the outbound redirect and the callback: | Session key | Written by | When | Read by | |---|---|---|---| | `state` | `redirect()` | unless `stateless()` was called | `user()`, via `session()->pull('state')` | | `code_verifier` | `redirect()` | only after `enablePKCE()` | the token request inside `user()` | The `state` is `Str::random(40)`. The `code_verifier` is `Str::random(96)`; Socialite sends its SHA-256 challenge with `code_challenge_method=S256` on the way out and the verifier itself when it exchanges the code. PKCE is **off by default** in Socialite; `enablePKCE()` switches it on per call. ## How the state check works On a developer-community site, a member clicks "Sign in with Google". On the way back, `user()`: 1. Returns the cached user if this provider instance already resolved one. 2. Unless stateless, **pulls** `state` from the session (reading and deleting it). 3. Throws `InvalidStateException` if the pulled value is empty or not `hash_equals` to `$request->input('state')`. 4. Otherwise exchanges the code and fetches the profile. `InvalidStateException` extends `InvalidArgumentException`, so unhandled it renders as a 500 page. ## Why the state goes missing in real apps - **Two hosts.** The login starts on `www.example.test` but the registered callback is `example.test`, or local development mixes `localhost` and `127.0.0.1`. Cookies are per host, so the callback arrives with a fresh, empty session. - **Cookie policy.** The skeleton's `same_site` is `lax`, which sends the cookie on a top-level GET back from the provider. Setting `SESSION_SAME_SITE=strict` drops it on that cross-site return. - **Expired or rotated session.** A member who idles on the consent screen past the session lifetime returns to a new session. - **Replay.** Because the state is pulled, refreshing the callback URL, or any duplicated request to it, finds no state the second time. - **No session at all.** Routes in `routes/api.php` have no session middleware; there the first `session()` call throws a `RuntimeException` rather than `InvalidStateException`. The fix is almost always to make the session survive: one canonical host, a `redirect` in `config/services.php` that matches it, and the default `lax` policy. Catching the exception and sending the member back to the login page with a "please try again" message handles the replay case gracefully. ## `stateless()` `stateless()` sets a flag so `redirect()` stores no state and `user()` skips the comparison. The docs present it for **stateless APIs that do not use cookie-based sessions**, for example a mobile client that drives the flow itself. What you give up: - The state ties the callback to the browser that started the login. Without it, an attacker can feed their own authorization code into a victim's browser and log the victim into the attacker's account (login CSRF). - It is **not** a fix for `InvalidStateException` on a normal web app; it hides the symptom while removing the protection. ## A trap with PKCE `enablePKCE()` writes `code_verifier` to the session **even when** `stateless()` is set, and `user()` pulls it from the session for the token request. In a truly sessionless API, the combination therefore throws the same "Session store not set on request." error. PKCE with Socialite needs a session. ## Handling the exception well Even with a healthy session, some members will hit the replay case. A small handler keeps it from becoming a 500 page: ```php use Laravel\Socialite\Two\InvalidStateException; try { $googleUser = Socialite::driver('google')->user(); } catch (InvalidStateException) { return redirect('/login')->withErrors(['oauth' => 'Sign-in expired, please try again.']); } ``` Log these events with the request host and whether a session cookie arrived; a sudden rise usually points at a host or cookie-configuration change rather than at attackers. ## Summary - The session carries `state`, and with PKCE `code_verifier`. - `InvalidStateException` nearly always means the session did not make the round trip. - `stateless()` is for sessionless token flows, not a cure for lost sessions.
- Why does refreshing the Socialite callback page throw InvalidStateException even after a successful login?`user()` reads the state with `session()->pull('state')`, which deletes it. The first request consumes it; a refresh arrives with the same `state` in the query but nothing in the session, so the empty-state branch throws. Catch the exception and redirect to the login page, or redirect away from the callback as soon as sign-in succeeds.
- Can you combine stateless() and enablePKCE() in Socialite for a sessionless API?Not as written. `redirect()` still stores the `code_verifier` in the session when PKCE is on, and the token request pulls it back from the session. Without a session store the call fails, so PKCE through Socialite needs cookie sessions, or a flow where the client performs PKCE itself and hands your API the resulting token.
saying these in an interview costs you the question
- InvalidStateException means the provider rejected your client secret
- Calling stateless() is the standard fix for InvalidStateException on web apps
- Socialite enables PKCE by default for every OAuth 2 provider
- The state stays in the session so the callback can be safely refreshed
- stateless() also stops PKCE from needing a session