# OAuth state parameter validated on callback

- **Pattern:** `ab-002400` (`security-hardening.input-validation.oauth-state-validation`)
- **Severity:** medium
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002400
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002400)

## Why it matters

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.

## Severity rationale

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.

## Remediation

Generate a random `state`, store it in an `httpOnly` cookie, and validate it in the callback before exchanging the authorization code:

```ts
// 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.

## Detection

- **ID:** `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:

  ```ts
  // 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
  }
  ```

## External references

- cwe CWE-352
- cwe CWE-601
- owasp A01
- nist IA-8

Taxons: access-control

HTML version: https://auditbuffet.com/patterns/ab-002400
