The State Parameter
The attack
Without the state parameter, an attacker can force you to log in as them. Here is how.
The attacker starts an OAuth flow with your app, gets a GitHub authorization code, but does not use it. Instead, they craft a link:
https://yourapp.com/auth/github/callback?code=ATTACKERS_CODE They trick you into clicking this link (in an email, a forum post, an embedded image). Your server receives the code, exchanges it for a token, and logs you in as the attacker’s GitHub account.
Why is this bad? If your app stores sensitive data (credit card info, private documents, API keys), the attacker now has access to it. You are logged in as them, and you might enter your data into their account.
This is a cross-site request forgery (CSRF) attack against the OAuth callback.
How state prevents it
The state parameter ties the authorization request to the callback. Your server generates a random string, sends it to GitHub in the authorization URL, and GitHub sends it back in the callback. Your server checks that the returned state matches what it sent.
The attacker cannot forge the state because:
- They do not know what random string your server generated for your session
- The state is different for every authorization attempt
- Your server rejects callbacks where the state does not match
Implementing state storage
We need a place to store state values between the redirect (Step 1) and the callback (Step 3). A simple in-memory Map works:
Create src/oauth-state.ts:
// src/oauth-state.ts
const pending = new Map<string, number>();
export function createState(): string {
const state = crypto.randomUUID();
pending.set(state, Date.now());
return state;
}
export function verifyState(state: string): boolean {
const created = pending.get(state);
if (!created) return false;
// State values expire after 10 minutes
const maxAge = 10 * 60 * 1000;
if (Date.now() - created > maxAge) {
pending.delete(state);
return false;
}
// Single-use: delete after verification
pending.delete(state);
return true;
} createState generates a random UUID and stores it with a timestamp. verifyState checks that the state exists, is not older than 10 minutes, and has not been used before (single-use). After verification, the state is deleted so it cannot be reused.
Update the authorization redirect
Update src/routes/github.ts to use the state helper:
// src/routes/github.ts
import { route, group } from "@hectoday/http";
import { env } from "../env.js";
import { createState } from "../oauth-state.js";
export const githubRoutes = group([
route.get("/auth/github", {
resolve: (c) => {
const state = createState();
const params = new URLSearchParams({
client_id: env.githubClientId,
redirect_uri: `${env.baseUrl}/auth/github/callback`,
scope: "read:user user:email",
state,
});
const url = `https://github.com/login/oauth/authorize?${params}`;
return Response.redirect(url, 302);
},
}),
]); The TODO from the previous lesson is now resolved. createState() generates and stores the state in one call.
Verify state in the callback (preview)
In the callback handler (which we build in the next lesson), we will verify the state like this:
const state = url.searchParams.get("state");
if (!state || !verifyState(state)) {
return Response.json({ error: "Invalid state" }, { status: 403 });
} If the state is missing, expired, or already used, the callback fails. The attacker’s crafted link will not have a valid state, so the attack fails.
Why not store state in a cookie?
Some implementations store the state in a cookie and compare it to the URL parameter in the callback. This works but has a subtlety: the cookie must be SameSite=Lax or SameSite=None to survive the redirect from GitHub back to your app. Since we already have a server-side Map for sessions, using it for state is simpler and avoids cookie configuration issues.
Exercises
Exercise 1: Manually craft a callback URL with a fake state value: http://localhost:3000/auth/github/callback?code=fake&state=fake. After we build the callback handler in the next lesson, visit this URL and verify you get a “Invalid state” error.
Exercise 2: Look at the verifyState function. What happens if you call it twice with the same state value? The first call returns true and deletes the state. The second call returns false because the state is gone. This is the single-use property: it prevents replay attacks where an attacker resubmits a captured callback URL.
What does the state parameter protect against?
Why is state deleted after verification (single-use)?