karthik.dev
Back to blogBackend Engineering

JWT Authentication Done Right: Stateless Auth Without the Footguns

2026-03-01 · 7 min read

JWT authentication is everywhere and misimplemented everywhere. After building auth systems for CodeMyFYP Academy, NEXORA, and the AI Interview Prep Platform — all with different role requirements — I've converged on a pattern that's both secure and practical.

Access token + refresh token: why you need both

A common mistake: issuing a single long-lived JWT (7 days, 30 days) for authentication. The problem: JWTs can't be invalidated server-side without a blocklist — meaning a stolen token is valid until expiry.

The solution: short-lived access tokens (15 minutes) paired with long-lived refresh tokens (7 days). The refresh token lives in an HttpOnly cookie (inaccessible to JavaScript); the access token lives in memory (or short-lived sessionStorage).

Browser                    Server
  |──── POST /auth/login ───>|
  |<── Access token (15min)  |
  |<── Refresh token cookie  | (HttpOnly, Secure, SameSite=Strict)
  |                          |
  |─ GET /api/data (AT) ────>| AT valid → serve data
  |                          |
  | (15 min later)           |
  |─ POST /auth/refresh ────>| RT in cookie → validate → new AT
  |<── New access token      |

If the access token is stolen, it's valid for at most 15 minutes. If the refresh token is stolen — harder, since it's in an HttpOnly cookie — rotation (see below) limits the damage window.

Refresh token rotation

Every time a refresh token is used, invalidate it and issue a new one. Store refresh tokens in your database with a revoked flag:

async function refreshAccessToken(refreshToken: string) {
  const stored = await db.refreshTokens.findByToken(hash(refreshToken));
  
  if (!stored || stored.revoked || stored.expiresAt < new Date()) {
    throw new UnauthorizedError("Invalid refresh token");
  }
  
  // Invalidate the used token
  await db.refreshTokens.revoke(stored.id);
  
  // Issue new tokens
  const newRefreshToken = crypto.randomBytes(32).toString("hex");
  await db.refreshTokens.create({
    userId: stored.userId,
    tokenHash: hash(newRefreshToken),
    expiresAt: addDays(new Date(), 7),
  });
  
  const accessToken = signJWT({ userId: stored.userId, role: stored.role }, "15m");
  return { accessToken, refreshToken: newRefreshToken };
}

Bonus: reuse detection. If a refresh token that's already been revoked is used again, it likely means the token was stolen and both copies are being used. Revoke all tokens for that user immediately.

What to put in the JWT payload

Keep it minimal. Only include what every authorized endpoint needs to avoid a database call:

type JWTPayload = {
  sub: string;         // user ID
  role: UserRole;      // for RBAC enforcement without a DB call
  tenantId: string;    // for multi-tenant scoping
  iat: number;         // issued at (added by library)
  exp: number;         // expiry (added by library)
};

Never put sensitive data (passwords, SSNs, payment info) in JWTs. They're signed, not encrypted — Base64-decoding the payload reveals its contents to anyone.

Algorithm: always RS256 (or ES256), never HS256 in distributed systems

HS256 uses a symmetric shared secret — any service that can verify tokens can also create them. In a distributed system with multiple services, this is a liability.

RS256 uses an asymmetric key pair: sign with the private key, verify with the public key. Services can verify tokens without being able to create them.

For a single-service backend, HS256 with a strong secret is fine. For microservices, use RS256.

RBAC in middleware, not in route handlers

Don't scatter permission checks through your route handlers. Centralize them:

// middleware/authorize.ts
export function authorize(...roles: UserRole[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    const { role } = req.user!;  // populated by auth middleware
    if (!roles.includes(role)) {
      return res.status(403).json({ error: "Forbidden" });
    }
    next();
  };
}

// router
router.get("/admin/users",
  authenticate,
  authorize("admin"),
  getUsers
);

This makes permission requirements visible at the route definition level and keeps route handlers focused on their actual logic.