All posts

JWT Refresh Tokens: The Architecture I Settled On

After trying several approaches, here's how I handle auth tokens in Aegis2FA and why.

3 min read
authenticationjwtsecuritybackend

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.

typescript
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.