Refresh tokens that can be reused indefinitely negate the security benefit of short-lived access tokens entirely. CWE-384 (Session Fixation) captures the structural problem: a compromised token remains valid forever as a fixed key to the account. OWASP API2 (Broken Authentication) specifically calls out refresh token replay as an authentication weakness. A single leaked refresh token — from a log, a compromised client, or a man-in-the-middle — gives an attacker persistent access that survives every access token expiry. Rotation with detection forces the attacker to race the legitimate user, and a race loss invalidates the entire session.
High because a stolen refresh token grants indefinite re-authentication capability, making it functionally equivalent to a never-expiring credential.
Treat each refresh token as single-use: when it's consumed to issue a new access token, delete the old one from the database and persist the replacement. Detect replay attacks by flagging token reuse.
// src/app/api/auth/refresh/route.ts
export async function POST(req: Request) {
const { refreshToken } = await req.json()
const stored = await db.refreshToken.findUnique({ where: { token: refreshToken } })
if (!stored) {
// Token not found — could be a replay attack; invalidate all sessions for safety
return new Response('Unauthorized', { status: 401 })
}
// Delete old token before issuing new one (rotation)
await db.refreshToken.delete({ where: { id: stored.id } })
const newRefreshToken = crypto.randomUUID()
await db.refreshToken.create({
data: { token: newRefreshToken, userId: stored.userId, expiresAt: addDays(new Date(), 30) }
})
return Response.json({
accessToken: signAccessToken(stored.userId),
refreshToken: newRefreshToken
})
}
ID: api-security.auth.refresh-token-rotation
Severity: high
What to look for: Enumerate every relevant item. Look for refresh token handling logic. If your API issues access tokens and refresh tokens, check that when a refresh token is used to issue a new access token, the old refresh token is invalidated and a new one is issued.
Pass criteria: At least 1 of the following conditions is met. Refresh token endpoint invalidates the old refresh token after issuing a new access token. Replayed refresh tokens are rejected.
Fail criteria: The same refresh token can be used multiple times without invalidation, or no refresh token rotation is implemented.
Skip (N/A) when: The API uses short-lived access tokens without refresh tokens, or relies on server-side session management with automatic timeout.
Cross-reference: For user-facing accessibility and compliance, the Accessibility Basics audit covers foundational requirements.
Detail on fail: "Refresh tokens are not rotated — the same refresh token can be used indefinitely to issue new access tokens" or "Refresh token endpoint does not invalidate old tokens after rotation"
Remediation: Implement refresh token rotation by invalidating the old token when a new one is issued:
export default async (req, res) => {
const { refreshToken } = req.body
const session = await db.refreshToken.findUnique({ where: { token: refreshToken } })
if (!session) {
return res.status(401).json({ error: 'Invalid refresh token' })
}
// Delete old token
await db.refreshToken.delete({ where: { id: session.id } })
// Issue new tokens
const newAccessToken = jwt.sign({ userId: session.userId }, process.env.JWT_SECRET, { expiresIn: '1h' })
const newRefreshToken = crypto.randomUUID()
await db.refreshToken.create({ data: { token: newRefreshToken, userId: session.userId } })
res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken })
}