OAuth without state parameter validation (CWE-352, CWE-601) is a CSRF attack on the OAuth flow itself. An attacker who tricks a victim into visiting a crafted URL can complete an OAuth authorization as the attacker, binding their identity to the victim's account — a classic account takeover vector. OWASP A01 (Broken Access Control) and NIST 800-53 IA-8 both require integrity checks on authentication callbacks. The state parameter is the mechanism: it must be a cryptographically random value generated by the application and stored server-side (not just echoed back) so the callback can verify the request originated from the expected user.
Medium because exploitation requires luring the victim to a crafted URL, but a successful attack results in account takeover by linking an attacker-controlled OAuth identity.
Generate a random state, store it in an httpOnly cookie, and validate it in the callback before exchanging the authorization code:
// Before redirecting to provider:
const state = crypto.randomUUID()
cookies().set('oauth_state', state, { httpOnly: true, secure: true, maxAge: 600 })
redirect(`https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&state=${state}`)
// In the callback route:
const returnedState = searchParams.get('state')
const storedState = cookies().get('oauth_state')?.value
if (!returnedState || returnedState !== storedState) {
return Response.json({ error: 'Invalid state' }, { status: 400 })
}
cookies().delete('oauth_state')
// Proceed with code exchange
Do not compare state to a static value — it must be unique per authorization request and session-bound.
ID: security-hardening.input-validation.oauth-state-validation
Severity: medium
What to look for: Enumerate every OAuth callback route. For each callback, examine OAuth flow implementation. Check the authorization redirect to see if a state parameter is generated and included. Then check the callback route to verify the returned state matches the expected value (stored in session or cookie). This prevents CSRF attacks on OAuth flows.
Pass criteria: OAuth authorization URLs include a cryptographically random state parameter stored in the session. The callback handler verifies the returned state matches before exchanging the authorization code — 100% of callbacks must validate state parameter before code exchange. Report: "X OAuth callback routes found, all Y validate the state parameter."
Fail criteria: No state parameter in OAuth flows. Or state is present but not validated on callback.
Skip (N/A) when: The application has no OAuth implementation (no third-party login buttons). Managed auth providers (NextAuth.js, Clerk) handle this automatically — verify their implementation handles state internally.
Detail on fail: Quote the actual callback code showing the missing state check. Example: "GitHub OAuth callback route does not validate state parameter — CSRF attack on OAuth possible" or "OAuth state generated but compared to a static value rather than session-stored random value"
Remediation: Implement state validation:
// When redirecting to OAuth provider:
const state = crypto.randomUUID()
// Store in session (server-side) or encrypted cookie
cookies().set('oauth_state', state, { httpOnly: true, secure: true, maxAge: 600 })
const authUrl = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&state=${state}&scope=user:email`
redirect(authUrl)
// In the callback handler:
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const code = searchParams.get('code')
const returnedState = searchParams.get('state')
const storedState = cookies().get('oauth_state')?.value
if (!returnedState || !storedState || returnedState !== storedState) {
return Response.json({ error: 'Invalid state parameter' }, { status: 400 })
}
// Clear state cookie, exchange code for token
cookies().delete('oauth_state')
// ... token exchange
}