An OAuth callback handler. Exchanges the authorization code for tokens and logs the user in. The author left out the `state` parameter check that distinguishes a legitimate callback from a CSRF-style attack on the OAuth flow.
The vulnerable diff
// auth/oauth.ts
app.get("/auth/callback", async (req, res) => {
// BUG: state parameter not verified — OAuth CSRF wide open
const tokens = await provider.exchangeCode(req.query.code as string);
const profile = await provider.getProfile(tokens.access_token);
await loginUser(req, profile);
res.redirect("/dashboard");
});What is wrong
The `state` parameter is OAuth 2.0's CSRF defense. The client generates a random value at the start of the flow, persists it bound to the user's session, and includes it in the authorization URL. The provider echoes it back on the callback. The client verifies the echoed `state` matches the one stored in the session. If it does not, the callback is rejected. Without the check, the attacker can complete the OAuth flow on their own account, then trick the victim into hitting the callback URL — the victim's session ends up logged in as the attacker. RFC 6749 mandates the parameter for this reason; CWE-352 covers the broader class.
The attack
Attack path:
1. Attacker starts OAuth login flow on their browser. Captures
the authorization code from the redirect to /auth/callback.
2. Attacker sends victim a link:
https://app.example.com/auth/callback?code=<attacker's code>
3. Victim clicks. Server exchanges the code, gets the attacker's
tokens and profile, binds the session to attacker.
4. Victim now sees the app logged in as attacker. Any data the
victim enters (notes, files, payment info) lands in attacker's
account.Variants include 'account linking' attacks where the victim is already logged in to the legitimate account and the callback connects the attacker's third-party identity to the victim's account.
Mesrai's review comment
mesraipilot · Bot · reviewed 2 min ago
[mesrai] [code-review] [Security] [CWE-352] [high]
OAuth callback does not verify the `state` parameter. OAuth CSRF —
attacker initiates the flow, gets a code, sends the callback URL to
the victim. Victim's session ends up bound to attacker's account.
Two-part fix:
1. On authorization start, generate `state`, store in session:
const state = crypto.randomBytes(32).toString("base64url");
req.session.oauthState = state;
res.redirect(provider.authorizeUrl({ state, ... }));
2. On callback, verify and then clear:
if (req.query.state !== req.session.oauthState) {
return res.status(400).json({ error: "state mismatch" });
}
delete req.session.oauthState;
const tokens = await provider.exchangeCode(req.query.code);
For PKCE flows (recommended for SPAs), also generate and verify a
code_verifier — same shape, stored alongside state.
Reference: RFC 6749 § 10.12 (CSRF), OAuth 2.0 Security BCPThe fix
// auth/oauth.ts — fixed
import crypto from "crypto";
app.get("/auth/start", (req, res) => {
const state = crypto.randomBytes(32).toString("base64url");
req.session.oauthState = state;
res.redirect(provider.authorizeUrl({ state }));
});
app.get("/auth/callback", async (req, res) => {
if (req.query.state !== req.session.oauthState) {
return res.status(400).json({ error: "state mismatch" });
}
delete req.session.oauthState;
const tokens = await provider.exchangeCode(req.query.code as string);
const profile = await provider.getProfile(tokens.access_token);
await loginUser(req, profile);
res.redirect("/dashboard");
});Two functions: `/auth/start` generates and stores the state, `/auth/callback` verifies and consumes it. The 'consumes' is important — `delete req.session.oauthState` so the state cannot be replayed if the callback fires a second time. For SPA flows, also implement PKCE: generate a `code_verifier` at start, hash it for the `code_challenge`, verify on callback.
Why human review missed it
OAuth `state` is mandated by the spec but enforced by the implementer — there is no automatic check. Many tutorials skip it for brevity, then the brevity ends up in production. Mesrai's rule pack catches every OAuth callback handler that does not read `req.query.state` or its equivalent before exchanging the code.
Related rules + further reading
Mesrai rule pack: security/oauth-state-required — flags OAuth callback handlers without state verification.
RFC 6749 § 10.12 — Cross-Site Request Forgery on OAuth.
CWE-352 — applied to OAuth callbacks specifically.
Takeaway
Generate `state` at start, verify at callback, delete after use. Three lines that close OAuth CSRF for the entire flow.