DEV Community

myougaTheAxo
myougaTheAxo

Posted on

OAuth Integration with Claude Code: GitHub Login, State Validation, and Security

Skipping state validation in OAuth means a CSRF attacker can hijack user sessions. Hardcoding callback URLs means your auth breaks the moment you switch from localhost to staging to production. These aren't edge cases — they're the bugs that show up in code review on day one.

Claude Code generates secure OAuth flows when you put the right constraints in CLAUDE.md. Here's how.


The CLAUDE.md Rules That Drive Secure OAuth

## Authentication Rules

- **State parameter required (CSRF prevention)**: Every OAuth initiation generates a unique `state` value
- **State generation**: `crypto.randomBytes(32).toString('hex')` — never predictable, never reused
- **State storage**: Redis with 10-minute TTL. Use `state:<value>` as the key
- **State validation**: On callback, verify `state` matches Redis entry. Reject immediately if missing or mismatched
- **Replay prevention**: Delete `state` key from Redis immediately after first use
- **PKCE recommended**: For public clients, add `code_verifier`/`code_challenge` (S256)
- **Callback URL**: Always from environment variable (`GITHUB_CALLBACK_URL`), never hardcoded
- **Token storage**: Encrypt OAuth tokens before persisting to database
- **Session cleanup**: Delete tokens on disconnect or session expiry
Enter fullscreen mode Exit fullscreen mode

One section in CLAUDE.md. Every OAuth flow Claude Code generates respects these rules.


GET /auth/github: Initiate the Flow

// GET /auth/github
app.get('/auth/github', async (req, res) => {
  const state = crypto.randomBytes(32).toString('hex');
  const stateKey = `state:${state}`;

  await redis.set(stateKey, '1', 'EX', 600); // 10-minute TTL

  const params = new URLSearchParams({
    client_id: process.env.GITHUB_CLIENT_ID!,
    redirect_uri: process.env.GITHUB_CALLBACK_URL!,
    scope: 'read:user user:email',
    state,
  });

  res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});
Enter fullscreen mode Exit fullscreen mode

Key points:

  • crypto.randomBytes(32) gives 256 bits of entropy. Not Math.random(). Not a timestamp.
  • Redis TTL of 600 seconds (10 minutes). If the user takes longer than that to complete login, the state is gone and the flow restarts cleanly.
  • Callback URL from environment variable, so GITHUB_CALLBACK_URL=http://localhost:3000/auth/github/callback in dev and https://yourapp.com/auth/github/callback in production.

GET /auth/github/callback: Validate Everything

// GET /auth/github/callback
app.get('/auth/github/callback', async (req, res) => {
  const { code, state } = req.query;

  // 1. Validate both parameters are present
  if (!code || !state) {
    return res.status(400).send('Missing code or state');
  }

  // 2. Verify state against Redis
  const stateKey = `state:${state}`;
  const stored = await redis.get(stateKey);
  if (!stored) {
    return res.status(403).send('Invalid or expired state');
  }

  // 3. Delete immediately — prevents replay attacks
  await redis.del(stateKey);

  // 4. Exchange code for access token
  const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
    method: 'POST',
    headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
    body: JSON.stringify({
      client_id: process.env.GITHUB_CLIENT_ID,
      client_secret: process.env.GITHUB_CLIENT_SECRET,
      code,
      redirect_uri: process.env.GITHUB_CALLBACK_URL,
    }),
  });
  const { access_token } = await tokenRes.json();

  // 5. Fetch user data in parallel
  const headers = {
    Authorization: `token ${access_token}`,
    Accept: 'application/vnd.github.v3+json',
  };
  const [userRes, emailsRes] = await Promise.all([
    fetch('https://api.github.com/user', { headers }),
    fetch('https://api.github.com/user/emails', { headers }),
  ]);
  const [githubUser, emails] = await Promise.all([userRes.json(), emailsRes.json()]);

  // 6. Find primary email
  const primaryEmail = emails.find((e: any) => e.primary && e.verified)?.email;
  if (!primaryEmail) {
    return res.status(400).send('No verified primary email on GitHub account');
  }

  // 7. Upsert user — githubId as the stable key
  const user = await prisma.user.upsert({
    where: { githubId: String(githubUser.id) },
    update: {
      name: githubUser.name || githubUser.login,
      avatarUrl: githubUser.avatar_url,
    },
    create: {
      githubId: String(githubUser.id),
      email: primaryEmail,
      name: githubUser.name || githubUser.login,
      avatarUrl: githubUser.avatar_url,
    },
  });

  // 8. Issue session tokens
  const { accessToken, refreshToken } = generateTokenPair(user.id);

  res.cookie('session', accessToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 15 * 60 * 1000, // 15 minutes
  });

  res.redirect(process.env.FRONTEND_URL!);
});
Enter fullscreen mode Exit fullscreen mode

What this does step by step:

  1. Validate parameters first — reject anything missing code or state before touching Redis
  2. Redis lookup — if state isn't in Redis (expired or never existed), block the request
  3. Delete immediatelyDEL before the token exchange. This is the replay prevention. If an attacker somehow gets the callback URL and tries to replay it, the state is already gone
  4. Parallel fetch/user and /user/emails in Promise.all. Saves one round trip
  5. Primary email filter — GitHub users can have multiple emails. Only the verified primary one is used
  6. Upsert by githubId — not by email. Emails can change. githubId is stable
  7. httpOnly cookie — the access token never touches JavaScript. XSS can't steal it

Why Each Piece Matters

Rule Without It With It
crypto.randomBytes(32) Predictable state → CSRF takeover 256-bit entropy, unguessable
Redis TTL State lives forever → replay window grows 10-minute expiry, auto-cleanup
DEL before token exchange Same callback can be replayed One-time use enforced
httpOnly cookie XSS steals token from localStorage Cookie inaccessible to JS
Env var for callback URL Breaks on environment change One config change deploys everywhere

Summary

The pattern: CLAUDE.md defines the security contract → Claude Code generates code that follows it → you get consistent, secure OAuth without remembering every rule every time.

The five pieces that matter:

  1. crypto.randomBytes(32) state — not Math.random()
  2. Redis with TTL — not in-memory, not a database table
  3. Delete before exchange — one-time use
  4. Upsert by githubId — stable key, handles email changes
  5. httpOnly cookie — JS can't touch it

If you want a pre-built prompt that generates this pattern (plus JWT rotation, session invalidation, and rate limiting) in one Claude Code run, it's in the Security Pack on prompt-works.jp — search /security-check. The pack covers OAuth, input validation, OWASP Top 10 checks, and more.


What's the OAuth mistake you've seen most in code review? State validation, callback URL hardcoding, or something else?

Top comments (0)