A refresh token that never rotates is a long-lived credential equivalent: steal it once, impersonate the user indefinitely. Token refresh without rotation means CWE-613 (Insufficient Session Expiration) applies in practice even when session expiry is configured correctly — the refresh token keeps re-issuing access tokens forever. NIST 800-63B §7.1 (Session Bindings) requires that bound authenticators be invalidated when the session ends, and RFC 9700 (OAuth 2.0 Security Best Current Practice, January 2025) requires refresh tokens for public clients to be sender-constrained or use rotation with reuse detection. OWASP A07 (Identification and Authentication Failures) lists perpetual credentials as a named failure mode.
High because a static refresh token captured once provides perpetual account access that bypasses access token expiry entirely.
Invalidate the old refresh token in the same database transaction that issues the new one — a non-atomic rotation creates a race condition where both tokens are briefly valid:
async function rotateRefreshToken(oldToken: string) {
const newToken = crypto.randomUUID()
await db.$transaction([
db.session.update({
where: { refreshToken: oldToken },
data: { refreshToken: newToken, rotatedAt: new Date() }
})
])
return newToken
}
Supabase Auth rotates automatically when using @supabase/ssr. NextAuth with database sessions rotates automatically. For custom implementations, any non-atomic rotation is a security gap.
ID: saas-authentication.session-management.refresh-token-rotation
Severity: high
What to look for: Examine auth library configuration for refresh token handling. In NextAuth: check for JWT callback that rotates tokens. In Supabase: refresh token rotation is automatic — verify the SSR helper is used correctly. In Clerk: built-in rotation. In custom JWT implementations: look for token rotation logic in the auth refresh endpoint. Confirm that old refresh tokens are invalidated after use. Count all instances found and enumerate each.
Pass criteria: Refresh tokens are rotated on use (each refresh produces a new refresh token, and the old one is immediately invalidated). Libraries that implement rotation by default (Supabase Auth, Clerk) count as passing when configured correctly. At least 1 implementation must be confirmed.
Fail criteria: Refresh tokens are long-lived and reusable indefinitely without rotation. Custom JWT refresh endpoint issues a new access token without invalidating or rotating the refresh token.
Skip (N/A) when: No refresh token mechanism detected — the project uses short-lived sessions without a separate refresh token scheme, or uses a managed auth provider that handles this transparently with no configurable option.
Detail on fail: "Custom JWT refresh endpoint at /api/auth/refresh issues new access token but does not invalidate or rotate the refresh token" or "No refresh token rotation configured in auth library — refresh tokens are permanent".
Remediation: Refresh token rotation means a compromised refresh token can only be used once before it becomes invalid. Implement rotation by invalidating the old token in the same transaction as issuing the new one:
// Example: custom refresh endpoint
async function refreshTokens(oldRefreshToken: string) {
const session = await db.sessions.findUnique({
where: { refreshToken: oldRefreshToken }
})
if (!session || session.isRevoked) throw new Error('Invalid token')
// Rotate: invalidate old, issue new in one operation
const newRefreshToken = crypto.randomUUID()
await db.sessions.update({
where: { id: session.id },
data: {
refreshToken: newRefreshToken,
rotatedAt: new Date()
}
})
return { accessToken: signJwt(session.userId), refreshToken: newRefreshToken }
}
Supabase handles this automatically. NextAuth with database sessions handles it automatically. For custom implementations, the atomic invalidate-and-issue pattern is critical.