Mesrai
Back to blog
// essaySecurity

OAuth State Parameter Missing: How Mesrai Prevents CSRF on Callback

Real PR catch: OAuth callback without `state` verification. The session-binding attack and the generate-store-verify fix. CWE-352.

Mesrai TeamJuly 26, 20268 min read

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

tsauth/oauth.ts
// 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:

textexploit
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

textreview-comment.txt
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 BCP

The fix

tsauth/oauth.ts (fixed)
// 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.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.