JWT Refresh Tokens: The Architecture I Settled On
After trying several approaches, here's how I handle auth tokens in Aegis2FA and why.
I went through three different token architectures before landing on one that actually made sense.
The Problem
Short-lived tokens are more secure (less time for attackers to use stolen tokens) but annoying for users (constant re-login). Long-lived tokens are convenient but risky.
The solution most people land on: two tokens.
What I Use Now
Access token: 15 minutes, sent in Authorization header, stored in memory (not localStorage).
Refresh token: 7 days, HttpOnly cookie, stored in database.
The access token is stateless. The server just validates the signature and expiry. No database lookup.
The refresh token is stateful. It's in the database, so I can revoke it. When a user logs out or I detect something suspicious, I delete their refresh tokens and they're immediately locked out.
The Key Insight
Rotate refresh tokens on every use.
Old token → verify → delete from database → issue new tokens
This means if an attacker steals a refresh token and tries to use it after you've already used it, the token won't exist in the database. I can detect something is wrong and revoke everything.
async function refresh(oldToken: string) {
const payload = jwt.verify(oldToken, REFRESH_SECRET);
// Check database
const stored = await db.refreshToken.findUnique({
where: { id: payload.tokenId }
});
if (!stored) {
// Token was already used or revoked
// Possible theft - revoke all tokens for this user
await db.refreshToken.deleteMany({ where: { userId: payload.sub } });
throw new Error('Invalid token');
}
// Delete old, create new
await db.refreshToken.delete({ where: { id: payload.tokenId } });
const newToken = createRefreshToken(payload.sub);
await db.refreshToken.create({ ... });
return {
accessToken: createAccessToken(payload.sub),
refreshToken: newToken
};
}What Didn't Work
Storing access tokens in localStorage: XSS can read them. Just don't.
Long-lived access tokens: If you need to revoke access immediately (user reports account compromise), you can't. The token is valid until it expires.
Refresh tokens without rotation: An attacker with a stolen refresh token can use it indefinitely. With rotation, they get one use before I notice.
The Frontend Part
The annoying part is the frontend needs to handle token expiry gracefully. I use an Axios interceptor that catches 401s, refreshes the token, and retries the original request.
Users never see a login screen unless their refresh token actually expires (7 days of inactivity).
Things I Still Think About
Is 15 minutes too short for access tokens? Maybe. Some apps use 1 hour. But 15 minutes means if a token leaks, the window for abuse is small.
Is 7 days too long for refresh tokens? For a learning project, it's fine. For a banking app, I'd probably go shorter.
The code is in Aegis2FA. The auth module is probably the most polished part of the whole project.