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
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.
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 };
}
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);
}
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 });
});
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)