The OAuth state parameter is a critical security feature that prevents Cross-Site Request Forgery (CSRF) attacks. It binds the OAuth flow to the user's session, ensuring the callback is legitimate.
Key Takeaways
- 1State parameter prevents CSRF attacks in OAuth flows
- 2Generate cryptographically random values (32+ bytes)
- 3Store state server-side or in encrypted cookie before redirect
- 4Validate state matches exactly on callback
- 5State can also carry application data (encoded)
"The client SHOULD utilize the 'state' request parameter to deliver this value to the authorization server when making an authorization request."— RFC 6749, Section 10.12
Why State Is Needed
This section explains the specific attack that state prevents. Understanding the attack flow helps you appreciate why every OAuth implementation must include state validation. Without it, your users could unknowingly link their accounts to an attacker's identity.
Without state validation, an attacker could execute a login CSRF attack. The attacker starts an OAuth flow with their own account, then tricks a victim into completing it. Here's the step-by-step breakdown:
| Attack Step | What Happens |
|---|---|
| 1. Attacker starts OAuth flow | Gets authorization URL with their account |
| 2. Attacker sends link to victim | Victim clicks the link |
| 3. Victim completes auth | Linked to attacker's account! |
| 4. Attacker has access | To victim's session/data |
The consequences of this attack are severe. The victim's activity in your app gets associated with the attacker's third-party account, potentially exposing private data or enabling impersonation.
State in the OAuth Flow
Now let's look at how state fits into the authorization flow. You generate a random value, store it, include it in the authorization URL, and verify it matches when the user returns. This creates a cryptographic binding between the request and the callback.
https://auth.example.com/authorize?client_id=abc&redirect_uri=https://myapp.com/callback&response_type=code&state=xY7kLm9pQr2sT4vW// 1. Generate state before redirect
import crypto from 'crypto';
function generateState() {
return crypto.randomBytes(32).toString('base64url');
}
// 2. Store state in session
app.get('/login', (req, res) => {
const state = generateState();
req.session.oauthState = state; // Store server-side
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('state', state);
res.redirect(authUrl.href);
});
// 3. Validate state on callback
app.get('/callback', (req, res) => {
const { code, state } = req.query;
// CRITICAL: Validate state matches
if (!state || state !== req.session.oauthState) {
return res.status(403).send('Invalid state parameter');
}
// Clear used state
delete req.session.oauthState;
// Continue with token exchange...
});The code above demonstrates the complete state lifecycle. Notice three critical steps: generating a cryptographically random value, storing it before the redirect, and verifying an exact match on callback. The delete req.session.oauthState line ensures the state can only be used once.
Generating Secure State
State values must be unpredictable. If an attacker can guess your state values, the protection becomes worthless. Here are implementations for different platforms, all using cryptographically secure random number generators.
// Node.js
import crypto from 'crypto';
const state = crypto.randomBytes(32).toString('base64url');
// Browser (Web Crypto API)
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const state = btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
// Python
import secrets
state = secrets.token_urlsafe(32)
// Go
import "crypto/rand"
import "encoding/base64"
b := make([]byte, 32)
rand.Read(b)
state := base64.RawURLEncoding.EncodeToString(b)Each example generates 32 bytes (256 bits) of randomness, then encodes it as URL-safe base64. This produces approximately 43 characters of unguessable state. The key requirement is using crypto.randomBytes or equivalent—never use Math.random() which is predictable.
Storing State
After generating state, you need to store it somewhere the callback handler can access. The right storage method depends on your architecture. Here's a comparison of common approaches:
| Method | Pros | Cons |
|---|---|---|
| Server session | Most secure, simple | Requires session storage |
| Encrypted cookie | Stateless server | Cookie size limits |
| Signed JWT | Can include metadata | Larger URLs |
| LocalStorage (SPA) | Simple for SPAs | Same-origin only |
For most server-rendered applications, session storage is the simplest and most secure choice. SPAs often use encrypted cookies or localStorage since they may not have persistent server sessions.
Encoding Application Data
Beyond CSRF protection, state can serve double duty by carrying application data through the OAuth redirect. This is useful when you need to remember where the user came from or what action triggered the login.
State can carry additional data (like return URL) when properly encoded:
// Encode data in state
function createState(returnTo) {
const data = {
csrf: crypto.randomBytes(16).toString('hex'),
returnTo: returnTo,
exp: Date.now() + 600000 // 10 min expiry
};
return Buffer.from(JSON.stringify(data)).toString('base64url');
}
// Decode on callback
function parseState(state) {
try {
const data = JSON.parse(Buffer.from(state, 'base64url').toString());
if (Date.now() > data.exp) throw new Error('State expired');
return data;
} catch {
throw new Error('Invalid state');
}
}This code combines a CSRF token, return URL, and expiration into a single state value. The exp field adds time-limiting, so stale authorization URLs become invalid. On callback, you can extract both the security token for validation and the returnTo URL for redirecting users where they wanted to go.
With the fundamentals covered, let's review the security requirements you should follow in every implementation.
Security Requirements
- • Use cryptographically secure random generation
- • Minimum 128 bits (16 bytes) of entropy
- • Validate state is exactly equal, not just present
- • Use state once and delete after validation
- • Set reasonable expiration (5-10 minutes)