DEV Community

myougaTheAxo
myougaTheAxo

Posted on

JWT Authentication with Claude Code: Refresh Token Rotation and Theft Detection

Short-lived access tokens = security. But frequent logouts = bad UX. Refresh tokens solve both. The catch: if you implement refresh tokens wrong, you create a bigger vulnerability than long-lived access tokens. Claude Code generates the complete secure implementation from CLAUDE.md rules.

CLAUDE.md Auth Security Rules

## Authentication Rules
- Access token: 15min TTL, HTTP Authorization header
- Refresh token: 30 days TTL, HttpOnly Cookie only
- Rotation: issue new refresh token on every use, invalidate old
- Store refresh tokens in DB (hashed with SHA-256, not plaintext)
- Theft detection: reused invalid token = revoke ALL user sessions
- Cookie settings: HttpOnly + Secure + SameSite=strict + Path=/auth/refresh
- Never expose refresh token in response body or logs
Enter fullscreen mode Exit fullscreen mode

Claude Code enforces every rule across the implementation.

Generating the Implementation

Prompt:

Implement JWT refresh token rotation with theft detection.
Follow CLAUDE.md authentication rules exactly.
Enter fullscreen mode Exit fullscreen mode

generateTokenPair

import crypto from 'crypto';
import jwt from 'jsonwebtoken';

interface TokenPair {
  accessToken: string;
  refreshToken: string; // raw — sent to client once, never stored
}

async function generateTokenPair(userId: string, tenantId: string): Promise<TokenPair> {
  // Unique ID for this refresh token (stored in JWT for fast lookup)
  const refreshTokenId = crypto.randomUUID();

  // Raw token: 64 random bytes = 512 bits of entropy
  const rawRefreshToken = crypto.randomBytes(64).toString('hex');

  // Hash before storing — if DB is compromised, tokens are useless
  const hashedToken = crypto.createHash('sha256').update(rawRefreshToken).digest('hex');

  // Store hashed token in DB
  await prisma.refreshToken.create({
    data: {
      id: refreshTokenId,
      userId,
      tenantId,
      tokenHash: hashedToken,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
    },
  });

  // Access token: short-lived, contains refreshTokenId for rotation
  const accessToken = jwt.sign(
    { userId, tenantId, refreshTokenId },
    process.env.JWT_SECRET!,
    { expiresIn: '15m' }
  );

  return { accessToken, refreshToken: rawRefreshToken };
}
Enter fullscreen mode Exit fullscreen mode

The raw token leaves the function exactly once — it goes into the HttpOnly cookie and is never logged or returned in response body.

rotateRefreshToken with Theft Detection

This is the critical function. Claude Code generates the full theft detection logic:

async function rotateRefreshToken(rawToken: string): Promise<TokenPair | null> {
  const hashedToken = crypto.createHash('sha256').update(rawToken).digest('hex');

  // Look up the hashed token in DB
  const storedToken = await prisma.refreshToken.findUnique({
    where: { tokenHash: hashedToken },
    include: { user: true },
  });

  // Token not found at all = never existed or already rotated
  if (!storedToken) {
    logger.warn('Refresh token not found — possible theft or replay', { tokenHash: hashedToken.slice(0, 8) });
    return null;
  }

  // Token expired
  if (storedToken.expiresAt < new Date()) {
    await prisma.refreshToken.delete({ where: { id: storedToken.id } });
    return null;
  }

  // Token already revoked = THEFT DETECTED
  // Someone is using a token that was already rotated.
  // The legitimate user has a new token. The attacker has the old one.
  // We can't tell which is which — revoke everything.
  if (storedToken.revokedAt) {
    logger.error('THEFT DETECTED: Reused revoked refresh token', {
      userId: storedToken.userId,
      tokenId: storedToken.id,
      revokedAt: storedToken.revokedAt,
    });

    // Nuclear option: revoke ALL active sessions for this user
    await prisma.refreshToken.updateMany({
      where: { userId: storedToken.userId, revokedAt: null },
      data: { revokedAt: new Date() },
    });

    // Force user to log in again — this alerts them to a possible breach
    return null;
  }

  // Valid token — rotate: revoke old, generate new
  await prisma.refreshToken.update({
    where: { id: storedToken.id },
    data: { revokedAt: new Date() }, // mark as used
  });

  return generateTokenPair(storedToken.userId, storedToken.tenantId);
}
Enter fullscreen mode Exit fullscreen mode

The theft detection works because rotation creates a one-time-use property: once a token is used, it's revoked. If it shows up again, someone replayed it.

/auth/refresh Route

app.post('/auth/refresh', async (req: Request, res: Response) => {
  const rawToken = req.cookies['refresh_token'];
  if (!rawToken) return res.status(401).json({ error: 'No refresh token' });

  const tokens = await rotateRefreshToken(rawToken);
  if (!tokens) {
    // Clear the cookie — don't let old tokens linger
    res.clearCookie('refresh_token', { path: '/auth/refresh' });
    return res.status(401).json({ error: 'Invalid or expired refresh token' });
  }

  // New refresh token in HttpOnly cookie
  res.cookie('refresh_token', tokens.refreshToken, {
    httpOnly: true,   // JavaScript cannot access this
    secure: true,     // HTTPS only
    sameSite: 'strict', // No cross-site requests
    path: '/auth/refresh', // Only sent to refresh endpoint
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
  });

  // Access token in response body (used in Authorization header)
  res.json({ accessToken: tokens.accessToken });
});
Enter fullscreen mode Exit fullscreen mode

The cookie path: '/auth/refresh' means the refresh token is only sent to the refresh endpoint — not to your API endpoints, not to any other route.

Security Properties Summary

Property Implementation Threat Mitigated
Short-lived access tokens 15min JWT Stolen access token window limited
HttpOnly cookie httpOnly: true XSS cannot steal refresh token
SameSite=strict sameSite: 'strict' CSRF cannot use refresh token
Hashed storage SHA-256 before DB write DB breach doesn't expose tokens
Rotation Revoke on use Replay attacks
Theft detection Revoke all on reuse Token theft triggers full logout

One CLAUDE.md file. Claude Code generates all six security properties consistently.


Security Pack (¥1,480) — Use /security-check to audit your auth implementation for token storage issues, missing cookie flags, and theft detection gaps.

Available at prompt-works.jp

Top comments (0)