ZUA.
Home/Blog/JWT Best Practices
Backend / SecurityJune 19, 2026 · 11 min read

JWT Auth Best Practices in Node.js — What Most Tutorials Get Wrong

Most JWT tutorials get you to a working login endpoint in 10 minutes. They don't cover what happens when a token is stolen, how to properly log users out, or why storing tokens in localStorage is a bad idea. This article covers what production auth actually looks like.

JWTNode.jsTypeScriptSecurityAuthOAuth

What Most Tutorials Skip

The typical JWT tutorial shows you this flow:

  • User logs in → server signs a JWT → client stores it → client sends it on every request
  • Token never expires (or expires in 7 days)
  • No refresh tokens, no revocation, no rotation
  • Token stored in localStorage

That works for a demo. In production it means a stolen token gives an attacker permanent access, you can't force-logout a user, and XSS anywhere in your app leaks every user's credentials. Here's how to do it properly.

The Correct Token Architecture

Production JWT auth uses two tokens, not one:

Access Token
  - Short-lived: 15 minutes
  - Stored in memory (JS variable), NOT localStorage
  - Sent as Authorization: Bearer <token> header
  - Stateless — verified by signature alone, no DB lookup

Refresh Token
  - Long-lived: 7–30 days
  - Stored in an HttpOnly, Secure, SameSite=Strict cookie
  - Stored server-side in DB (hashed) for revocation
  - Used only to obtain a new access token

The access token is short enough that if stolen, the window of exposure is small. The refresh token lives in an HttpOnly cookie — JavaScript cannot read it, so XSS attacks can't steal it.

Step 1 — Signing Tokens Correctly

Always use asymmetric signing (RS256) for anything beyond a single-service app. It lets other services verify tokens without sharing a secret:

npm install jsonwebtoken
npm install -D @types/jsonwebtoken
// auth/tokens.ts
import jwt from "jsonwebtoken";
import crypto from "crypto";

const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;

export interface TokenPayload {
  sub: string;       // user ID
  role: string;
  iat?: number;
  exp?: number;
}

export function signAccessToken(payload: Omit<TokenPayload, "iat" | "exp">): string {
  return jwt.sign(payload, ACCESS_SECRET, {
    expiresIn: "15m",
    issuer: "your-app",
    audience: "your-app-client",
  });
}

export function signRefreshToken(payload: Omit<TokenPayload, "iat" | "exp">): string {
  return jwt.sign(payload, REFRESH_SECRET, {
    expiresIn: "7d",
    issuer: "your-app",
  });
}

export function verifyAccessToken(token: string): TokenPayload {
  return jwt.verify(token, ACCESS_SECRET, {
    issuer: "your-app",
    audience: "your-app-client",
  }) as TokenPayload;
}

export function verifyRefreshToken(token: string): TokenPayload {
  return jwt.verify(token, REFRESH_SECRET, {
    issuer: "your-app",
  }) as TokenPayload;
}
NEVER DO THIS

Do not put sensitive data in the JWT payload — it is base64 encoded, not encrypted. Anyone can decode it. Only put userId, role, and sessionId.

Step 2 — The Login Endpoint

On login, issue both tokens. The refresh token goes into a cookie, the access token goes in the response body:

// auth/login.ts
import { Router, Request, Response } from "express";
import bcrypt from "bcrypt";
import { signAccessToken, signRefreshToken } from "./tokens";
import { saveRefreshToken } from "./refreshTokenStore";
import { findUserByEmail } from "../users/userRepository";

const router = Router();

router.post("/login", async (req: Request, res: Response) => {
  const { email, password } = req.body;

  const user = await findUserByEmail(email);
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    return res.status(401).json({ error: "Invalid credentials" });
  }

  const payload = { sub: user.id, role: user.role };
  const accessToken = signAccessToken(payload);
  const refreshToken = signRefreshToken(payload);

  // store hashed refresh token in DB for revocation
  await saveRefreshToken(user.id, refreshToken);

  // refresh token in HttpOnly cookie — JS cannot read this
  res.cookie("refreshToken", refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "strict",
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    path: "/auth/refresh",             // only sent to the refresh endpoint
  });

  // access token in response body — client stores in memory
  return res.json({ accessToken });
});

export default router;
TIP

Setting path: "/auth/refresh" on the cookie means it is only sent by the browser when hitting that specific endpoint — not on every request. Reduces the attack surface significantly.

Step 3 — Refresh Token Rotation

Every time the client uses a refresh token to get a new access token, issue a new refresh token and invalidate the old one. This is called rotation — if a refresh token is stolen and used, you detect it:

// auth/refresh.ts
import { Router, Request, Response } from "express";
import { verifyRefreshToken, signAccessToken, signRefreshToken } from "./tokens";
import { rotateRefreshToken, revokeRefreshToken } from "./refreshTokenStore";

router.post("/auth/refresh", async (req: Request, res: Response) => {
  const token = req.cookies?.refreshToken;
  if (!token) return res.status(401).json({ error: "No refresh token" });

  let payload;
  try {
    payload = verifyRefreshToken(token);
  } catch {
    return res.status(401).json({ error: "Invalid refresh token" });
  }

  // rotate: validate old token and issue new one atomically
  const newRefreshToken = await rotateRefreshToken(payload.sub, token);

  if (!newRefreshToken) {
    // token was already used — possible theft, revoke everything
    await revokeRefreshToken(payload.sub);
    return res.status(401).json({ error: "Token reuse detected" });
  }

  const accessToken = signAccessToken({ sub: payload.sub, role: payload.role });

  res.cookie("refreshToken", newRefreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "strict",
    maxAge: 7 * 24 * 60 * 60 * 1000,
    path: "/auth/refresh",
  });

  return res.json({ accessToken });
});

The rotateRefreshToken function hashes the incoming token, checks it exists in the DB, deletes it, and inserts a new one — all in a single transaction. If the same refresh token arrives twice, someone is reusing a stolen token — revoke everything for that user immediately.

Step 4 — The Auth Middleware

Verify the access token on every protected route. Never hit the database here — the whole point of JWTs is stateless verification:

// middleware/authenticate.ts
import { Request, Response, NextFunction } from "express";
import { verifyAccessToken, TokenPayload } from "../auth/tokens";

declare global {
  namespace Express {
    interface Request {
      user?: TokenPayload;
    }
  }
}

export function authenticate(req: Request, res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith("Bearer ")) {
    return res.status(401).json({ error: "Missing token" });
  }

  const token = authHeader.slice(7);

  try {
    req.user = verifyAccessToken(token);
    next();
  } catch (err) {
    if (err instanceof Error && err.name === "TokenExpiredError") {
      return res.status(401).json({ error: "Token expired", code: "TOKEN_EXPIRED" });
    }
    return res.status(401).json({ error: "Invalid token" });
  }
}

// role-based guard — compose on top of authenticate
export function requireRole(...roles: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user || !roles.includes(req.user.role)) {
      return res.status(403).json({ error: "Forbidden" });
    }
    next();
  };
}

Using a specific error code (TOKEN_EXPIRED) lets the frontend distinguish between an expired token (silently refresh and retry) and an invalid token (force logout).

Step 5 — Logout and Revocation

Logout must invalidate the refresh token in the DB and clear the cookie. Access tokens can't be revoked (they're stateless) — that's why keeping them short-lived at 15 minutes matters:

// auth/logout.ts
router.post("/auth/logout", authenticate, async (req: Request, res: Response) => {
  const token = req.cookies?.refreshToken;

  if (token) {
    // delete from DB so it can't be used again
    await revokeRefreshToken(req.user!.sub, token);
  }

  res.clearCookie("refreshToken", { path: "/auth/refresh" });
  return res.json({ success: true });
});
NOTE

For admin-forced logout (banned user, compromised account), store a tokenVersion integer on the user record and embed it in the JWT payload. Increment it to instantly invalidate all existing access tokens for that user — check it in the middleware.

Common Mistakes to Avoid

  • Storing JWTs in localStorage — readable by any JS on the page, XSS steals all tokens
  • Long-lived access tokens (hours or days) — stolen token = prolonged access, no way to revoke
  • Signing with HS256 and sharing the secret across services — any service can forge tokens
  • Not validating issuer and audience claims — tokens from other apps accepted as valid
  • Putting passwords, emails, or PII in the payload — JWT is encoded, not encrypted
  • No refresh token rotation — stolen refresh token gives permanent access until expiry
  • Skipping HTTPS in production — tokens transmitted in plaintext over HTTP

The Refresh Token Store

A minimal PostgreSQL schema for storing refresh tokens with automatic cleanup:

-- migrations/refresh_tokens.sql
CREATE TABLE refresh_tokens (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash  TEXT NOT NULL UNIQUE,   -- bcrypt hash of the token
  expires_at  TIMESTAMPTZ NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);

-- run periodically to clean up expired tokens
DELETE FROM refresh_tokens WHERE expires_at < NOW();

Store a bcrypt hash of the refresh token, never the raw token. If your database is breached, the hashes are useless without the originals.

Wrapping Up

The pattern boils down to three rules: keep access tokens short-lived and in memory, keep refresh tokens in HttpOnly cookies and rotate them on every use, and store only hashed refresh tokens server-side so you can revoke them.

It's more setup than the 10-minute tutorial version, but it's the difference between auth that works in a demo and auth that holds up when someone actually tries to break it.

Need a secure auth system built for your product?

I build and ship production systems — happy to discuss your requirements.

Book a Call