mdashikjs/blog
All posts
JWT Authentication Done Right: Access Tokens and Refresh Tokens
Security

JWT Authentication Done Right: Access Tokens and Refresh Tokens

Security5 min

JWT Authentication Done Right: Access Tokens and Refresh Tokens

Most JWT tutorials show you how to sign a token and call it a day. Production auth needs short-lived access tokens, refresh token rotation, and a clear revocation strategy. Here is the full picture.

JWTAuthenticationSecurityNode.js
Share:

The two-token pattern

  • Access token: short-lived (15 minutes), sent with every request, stored in memory
  • Refresh token: long-lived (7 days), used only to get new access tokens, stored in an httpOnly cookie

Why two tokens? If an access token leaks, the damage window is 15 minutes. The refresh token is harder to steal because it is httpOnly (no JavaScript access) and only sent to one endpoint.

Token generation

function generateTokens(userId: string) {
  const accessToken = jwt.sign(
    { sub: userId, type: 'access' },
    ACCESS_SECRET,
    { expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { sub: userId, type: 'refresh', jti: crypto.randomUUID() },
    REFRESH_SECRET,
    { expiresIn: '7d' }
  );

  return { accessToken, refreshToken };
}

The jti (JWT ID) on the refresh token is critical. It lets you track and revoke individual tokens.

Refresh token rotation

Every time a refresh token is used, issue a new one and invalidate the old one. If an attacker steals a refresh token and uses it, the legitimate user's next refresh attempt will fail (because the token was already rotated). This signals a compromise.

async function refresh(token: string) {
  const payload = jwt.verify(token, REFRESH_SECRET);
  const isValid = await redis.get(`refresh:${payload.jti}`);

  if (!isValid) {
    // Token was already used — possible theft. Revoke all tokens for this user.
    await revokeAllTokens(payload.sub);
    throw new UnauthorizedException('Token reuse detected');
  }

  // Invalidate the old token
  await redis.del(`refresh:${payload.jti}`);

  // Issue new tokens
  return generateTokens(payload.sub);
}

Revocation strategy

Access tokens are stateless — you cannot revoke them without checking a blacklist on every request, which defeats the purpose. Instead, keep them short-lived. For immediate revocation (user changes password, admin bans account), maintain a small Redis set of revoked user IDs. Check it on critical endpoints only.

Where to store tokens

  • Access token: in-memory variable (not localStorage — XSS can read it)
  • Refresh token: httpOnly, Secure, SameSite=Strict cookie

Common mistakes I see

  1. Storing JWTs in localStorage (XSS vulnerability)
  2. Using a single long-lived token (no damage containment)
  3. No refresh token rotation (stolen tokens work forever)
  4. Putting sensitive data in the JWT payload (it is base64, not encrypted)
MA

Written by Md Ashik

Senior Software Engineer building reliable backends. I write about the practical tradeoffs behind shipping software that holds up in production.