Authentication with JWT

JSON Web Tokens (JWT) are a compact, self-contained way to represent a user’s identity and claims as a signed string that a server can verify without a database lookup on every request. In Node.js, JWTs are the backbone of stateless API authentication: a client logs in once, receives a token, and then sends that token on every subsequent request instead of re-sending a password or relying on a server-side session store. This lesson covers how a JWT is structured and signed, how to issue and verify tokens with the popular jsonwebtoken package, how to build a real login flow with Express and bcrypt, and the security mistakes that turn a JWT-based system into a liability.

Overview: How JWT Authentication Works

A JWT is three base64url-encoded segments joined by dots: header.payload.signature. The header names the signing algorithm (typically HS256 for a shared secret, or RS256/ES256 for a public/private key pair) and the token type. The payload holds claims — registered ones like sub (subject/user id), iat (issued at), exp (expiration), plus whatever custom data your app needs, such as a role or tenant id. The signature is computed over header.payload using the secret or private key, and it is what lets a server trust the token without storing anything about it.

Part Contents Encoding
Header Signing algorithm (e.g. HS256) and token type (JWT) Base64url JSON
Payload Claims such as sub, iat, exp, and custom data (user id, role, etc.) Base64url JSON — readable by anyone, not encrypted
Signature HMAC or RSA/ECDSA signature over header.payload Base64url bytes

That middle point is critical: base64url is an encoding, not encryption. Anyone who has the token can decode the payload and read it (paste one into a JWT debugger and see for yourself). The signature only proves the payload hasn’t been tampered with and that it was issued by someone holding the secret — it does not hide the contents. Never put a password, an API key, or other secret data in a JWT payload.

The typical flow: a client posts credentials to a login route; the server verifies them and calls jwt.sign() to mint a token with a short expiration; the client stores the token and sends it as Authorization: Bearer <token> on every request; an authentication middleware calls jwt.verify() before the route handler runs, rejecting anything with a bad signature or an expired exp claim. Because verification only needs the secret (or public key) and some CPU cycles for the HMAC/RSA math, the server needs no database round trip and no session store — that’s what “stateless” means here. For HS256 this crypto work is a handful of microseconds and runs synchronously on the main thread without meaningfully blocking the event loop; password hashing with bcrypt, by contrast, is deliberately slow and Node offloads it to the libuv thread pool so it doesn’t stall other requests.

Syntax

npm install jsonwebtoken bcrypt express
jwt.sign(payload, secretOrPrivateKey, options);
jwt.verify(token, secretOrPublicKey, options);
jwt.decode(token);
Option Used with Purpose
expiresIn sign() Token lifetime, e.g. "15m", "7d", or a number of seconds
algorithm sign() Signing algorithm to use; default is HS256
algorithms verify() Allow-list of accepted algorithms — always set this explicitly
issuer, audience, subject both Standard claims (iss, aud, sub) set on sign and checked on verify
ignoreExpiration verify() Skip the exp check — almost never what you want

jwt.sign() returns the token string. jwt.verify() recomputes the signature, checks the claims, returns the decoded payload on success, and throws (JsonWebTokenError or TokenExpiredError) on any failure. jwt.decode() just base64url-decodes the payload without checking the signature at all — it is for inspecting a token you already trust, never for authorization decisions.

Examples

Example 1: Signing and Verifying a Token

The smallest possible use of jsonwebtoken: sign a payload, then verify it back.

import jwt from "jsonwebtoken";

const secret = process.env.JWT_SECRET ?? "dev-only-secret-change-me";

const token = jwt.sign(
  { sub: "user_123", role: "admin" },
  secret,
  { expiresIn: "15m" }
);

console.log("Token:", token);

try {
  const payload = jwt.verify(token, secret);
  console.log("Payload:", payload);
} catch (err) {
  console.error("Invalid token:", err.message);
}

Output:

Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIs...
Payload: { sub: 'user_123', role: 'admin', iat: 1753900800, exp: 1753901700 }

The exact token string differs on every run because iat is the current Unix timestamp, but the shape is always the same: a header, the payload you passed in plus iat/exp, and a signature. If you change even one character of the token before verifying, jwt.verify() throws instead of returning a payload — that’s the whole security guarantee.

Example 2: A Login Endpoint and Protected Route with Express

A realistic setup: a /login route that checks a hashed password and issues a token, and a /profile route that only responds if a valid Bearer token is present.

import express from "express";
import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";

const app = express();
app.use(express.json());

const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
  throw new Error("JWT_SECRET environment variable is required");
}

// In a real app this comes from your database, not an array
const users = [
  {
    id: "user_1",
    username: "ada",
    passwordHash: "$2b$12$KIXQ4Qe0F6y0y0y0y0y0yOeYQ4Qe0F6y0y0y0y0y0y0y0y0y0y0y",
  },
];

app.post("/login", async (req, res) => {
  const { username, password } = req.body;
  const user = users.find((u) => u.username === username);
  if (!user) {
    return res.status(401).json({ error: "Invalid credentials" });
  }

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

  const token = jwt.sign(
    { sub: user.id, username: user.username },
    JWT_SECRET,
    { expiresIn: "15m", algorithm: "HS256" }
  );

  res.json({ token });
});

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

  const token = authHeader.slice("Bearer ".length);

  try {
    req.user = jwt.verify(token, JWT_SECRET, { algorithms: ["HS256"] });
    next();
  } catch (err) {
    res.status(401).json({ error: "Invalid or expired token" });
  }
}

app.get("/profile", requireAuth, (req, res) => {
  res.json({ message: `Hello, ${req.user.username}`, sub: req.user.sub });
});

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: "Internal server error" });
});

const port = process.env.PORT ?? 3000;
app.listen(port, () => console.log(`Listening on port ${port}`));

Test it with curl once the server is running:

curl -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"username":"ada","password":"correct horse battery staple"}'
# {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}

curl http://localhost:3000/profile \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
# {"message":"Hello, ada","sub":"user_1"}

Notice two things beyond the happy path: bcrypt.compare() is awaited, since native bcrypt work runs off the main thread via the libuv thread pool and returns a promise; and requireAuth is a normal Express middleware, run before the route handler, that either calls next() or ends the response — it never falls through silently.

Example 3: Access and Refresh Tokens

Because a JWT can’t be revoked before it expires (the server never “looks it up”), the standard fix is to keep the access token short-lived and pair it with a longer-lived refresh token that can mint new access tokens.

function generateTokens(user) {
  const accessToken = jwt.sign(
    { sub: user.id, username: user.username },
    JWT_SECRET,
    { expiresIn: "15m" }
  );

  const refreshToken = jwt.sign(
    { sub: user.id, type: "refresh" },
    JWT_REFRESH_SECRET,
    { expiresIn: "7d" }
  );

  return { accessToken, refreshToken };
}

app.post("/refresh", (req, res) => {
  const { refreshToken } = req.body;

  try {
    const payload = jwt.verify(refreshToken, JWT_REFRESH_SECRET);
    if (payload.type !== "refresh") {
      return res.status(401).json({ error: "Invalid token type" });
    }

    const { accessToken } = generateTokens({
      id: payload.sub,
      username: payload.username,
    });
    res.json({ accessToken });
  } catch (err) {
    res.status(401).json({ error: "Invalid or expired refresh token" });
  }
});

The refresh token uses a different secret and carries a type claim so a stolen access token can’t be replayed against /refresh. In production you’d also store issued refresh tokens (or a hash of them) so a compromised one can be revoked, and rotate the refresh token on every use.

How It Works Step by Step

  1. The client POSTs credentials to /login.
  2. bcrypt.compare() checks the password against the stored hash; this runs on the libuv thread pool, so the event loop stays free for other requests while it works.
  3. On success, jwt.sign() base64url-encodes a header and payload (adding iat and exp) and computes an HMAC signature over them — all synchronously, and fast enough not to matter for HS256.
  4. The client stores the token and attaches it as Authorization: Bearer <token> on later requests.
  5. Before any protected route handler runs, requireAuth extracts the header, calls jwt.verify(), which recomputes the signature and checks exp/nbf — throwing immediately if anything doesn’t match.
  6. On success the decoded payload is attached to req.user and next() hands control to the route handler; on failure the middleware responds with 401 and the route handler never runs.

Common Mistakes

Mistake 1: Tokens that never expire

Omitting expiresIn produces a token that is valid forever. If it leaks, it’s a permanent credential.

const token = jwt.sign({ sub: user.id }, JWT_SECRET);
// no expiresIn — this token is valid until the secret is rotated

Always set a short expiration for access tokens and use a refresh token (Example 3) for longer sessions:

const token = jwt.sign({ sub: user.id }, JWT_SECRET, { expiresIn: "15m" });

Mistake 2: Trusting jwt.decode() instead of jwt.verify()

jwt.decode() reads the payload without checking the signature at all, so anyone can craft a token with role: "admin" and have it accepted.

const claims = jwt.decode(token);
if (claims.role === "admin") {
  grantAdminAccess();
}

Use jwt.verify() for any decision that matters, and always restrict algorithms explicitly so an attacker can’t switch the algorithm to bypass the signature check:

try {
  const claims = jwt.verify(token, JWT_SECRET, { algorithms: ["HS256"] });
  if (claims.role === "admin") {
    grantAdminAccess();
  }
} catch {
  denyAccess();
}

Best Practices

  • Use a long, random secret (32+ bytes) from an environment variable or secrets manager — never commit it to source control.
  • Always pass an explicit algorithms allow-list to verify() to prevent algorithm-confusion attacks.
  • Keep access tokens short-lived (minutes) and use a refresh token, stored server-side or rotated, for longer sessions — JWTs can’t be revoked before exp on their own.
  • Never put passwords, API keys, or other secrets in the payload; it is base64url-encoded, not encrypted, and readable by anyone who has the token.
  • Prefer an httpOnly, secure, sameSite cookie over localStorage for storing tokens in browser apps, to reduce the impact of XSS.
  • Set issuer/audience and check them on verify if tokens are shared across multiple services.
  • Hash passwords with bcrypt (or argon2) using a real cost factor — never store or compare plaintext passwords.

Practice Exercises

  1. Extend the Example 2 server with a /refresh endpoint (see Example 3) fully wired up, and confirm that an expired access token can be exchanged for a new one without re-entering a password.
  2. Add a role claim to the login token and modify requireAuth (or add a second middleware) so a new /admin route returns 403 for any user whose role isn’t "admin".
  3. Change jwt.verify() in requireAuth to omit the algorithms option, then research and write down, in a comment, exactly what attack that option was protecting against.

Summary

  • A JWT is header.payload.signature, base64url-encoded and signed, but not encrypted — never put secrets in the payload.
  • jwt.sign() issues a token, jwt.verify() checks its signature and expiration and throws on failure, and jwt.decode() reads a payload without any verification.
  • A typical flow is: verify credentials, sign a short-lived access token, send it as a Bearer header, verify it in middleware before every protected route.
  • Because JWTs can’t be revoked before they expire, pair short access tokens with a longer-lived, revocable refresh token.
  • Always restrict algorithms on verify, use a strong secret from the environment, and hash passwords with bcrypt before ever comparing them.