A hand-rolled OAuth callback that processes the code parameter without comparing the state value to a stored cookie value is vulnerable to CSRF on the OAuth flow itself. CWE-346 (Origin Validation Error) and CWE-352 describe the attack: an attacker can initiate their own OAuth flow and trick a victim into completing it, linking the victim's account to the attacker's third-party identity. This enables account takeover without knowing the victim's credentials. Auth libraries (NextAuth, Clerk, Lucia) handle state internally — only hand-rolled callbacks are vulnerable.
Low because the attack requires an active victim session and social engineering to trigger the callback, but successful exploitation leads to account takeover via session fixation on the OAuth binding.
Use a hosted auth library (NextAuth, Clerk, Lucia) if possible — state validation is handled internally. For hand-rolled callbacks, generate a random state on authorization request, store it in a cookie, and compare it on callback.
// app/api/auth/callback/github/route.ts
export async function GET(req: Request) {
const url = new URL(req.url)
const code = url.searchParams.get('code')
const stateParam = url.searchParams.get('state')
const cookieStore = await cookies()
const cookieState = cookieStore.get('oauth_state')?.value
if (!stateParam || !cookieState || stateParam !== cookieState) {
return Response.json({ error: 'Invalid state' }, { status: 400 })
}
cookieStore.delete('oauth_state') // consume the state
// now safe to exchange code for token
}
ID: ai-slop-security-theater.unbound-auth.oauth-state-validated-when-handrolled
Severity: low
What to look for: Count all OAuth callback routes in the project. When the project has an OAuth callback route file (matching app/api/auth/callback/[provider]/route.{ts,js}, pages/api/auth/callback/[provider].{ts,js}, OR a route file containing the substring oauth AND callback) AND the file does NOT import from a hosted auth library (next-auth, @auth/core, @clerk/*, lucia, @supabase/auth-helpers-*, @auth0/nextjs-auth0, better-auth, @kinde-oss/*), check whether the handler body contains a comparison between a state value from the query string and a value retrieved from cookies or session storage.
Pass criteria: Hand-rolled OAuth callback handlers contain an explicit state comparison. Report: "X OAuth callbacks, Y are hand-rolled, all hand-rolled validate state."
Fail criteria: At least 1 hand-rolled OAuth callback does not validate the state parameter.
Skip (N/A) when: No OAuth callback routes detected, OR all OAuth callbacks use a hosted auth library (state validation is library-internal).
Detail on fail: "1 hand-rolled OAuth callback without state validation: app/api/auth/callback/github/route.ts processes the code parameter but never compares state to a stored value"
Remediation: OAuth without state validation is vulnerable to CSRF on the callback. Either use a hosted library (NextAuth, Clerk, Lucia) or validate state explicitly:
// Bad: hand-rolled callback without state check
export async function GET(req: Request) {
const code = new URL(req.url).searchParams.get('code')
// exchange code for token... no state check!
}
// Good: validate state from cookie
export async function GET(req: Request) {
const url = new URL(req.url)
const code = url.searchParams.get('code')
const stateParam = url.searchParams.get('state')
const cookieState = (await cookies()).get('oauth_state')?.value
if (!stateParam || stateParam !== cookieState) {
return Response.json({ error: 'Invalid state' }, { status: 400 })
}
// safe to exchange code now
}