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
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}`);
});
Key points:
-
crypto.randomBytes(32)gives 256 bits of entropy. NotMath.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/callbackin dev andhttps://yourapp.com/auth/github/callbackin 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!);
});
What this does step by step:
-
Validate parameters first — reject anything missing
codeorstatebefore touching Redis -
Redis lookup — if
stateisn't in Redis (expired or never existed), block the request -
Delete immediately —
DELbefore 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 -
Parallel fetch —
/userand/user/emailsinPromise.all. Saves one round trip - Primary email filter — GitHub users can have multiple emails. Only the verified primary one is used
-
Upsert by githubId — not by email. Emails can change.
githubIdis stable - 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:
-
crypto.randomBytes(32)state — notMath.random() - Redis with TTL — not in-memory, not a database table
- Delete before exchange — one-time use
- Upsert by
githubId— stable key, handles email changes -
httpOnlycookie — 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)