Access + Refresh Architecture
Access tokens expire in 15 minutes. Refresh tokens live 7 days but rotate on each use.
Token Generation
export function generateTokens(userId: string) {
const accessToken = jwt.sign(
{ sub: userId, type: 'access' },
process.env.JWT_ACCESS_SECRET!,
{ expiresIn: '15m' }
)
const refreshToken = jwt.sign(
{ sub: userId, type: 'refresh', jti: randomBytes(16).toString('hex') },
process.env.JWT_REFRESH_SECRET!,
{ expiresIn: '7d' }
)
return { accessToken, refreshToken }
}
Rotation with Theft Detection
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.cookies
try {
const payload = verifyRefreshToken(refreshToken)
// Token already used? = theft detected
const stored = await db.refreshToken.findFirst({ where: { jti: payload.jti } })
if (!stored) {
await db.refreshToken.deleteMany({ where: { userId: payload.sub } })
return res.status(401).json({ error: 'Token reuse detected' })
}
await db.refreshToken.delete({ where: { jti: payload.jti } })
const tokens = generateTokens(payload.sub)
const newPayload = verifyRefreshToken(tokens.refreshToken)
await db.refreshToken.create({
data: { jti: newPayload.jti, userId: payload.sub, expiresAt: new Date(Date.now() + 7 * 86400_000) }
})
res.cookie('refreshToken', tokens.refreshToken, { httpOnly: true, secure: true, sameSite: 'strict' })
return res.json({ accessToken: tokens.accessToken })
} catch {
return res.status(401).json({ error: 'Invalid token' })
}
})
Storage Best Practices
| Location | Access Token | Refresh Token |
|---|---|---|
| Memory | Best | Lost on page reload |
| httpOnly Cookie | Good | Best |
| localStorage | XSS risk | Never |
-> Parse JWT tokens with the JWT Parser.