Skip to content
Express.js ex libraries 5 min read

jsonwebtoken (JWT)

A JSON Web Token (JWT) is a compact, URL-safe, signed string that carries a JSON payload — typically a user’s identity and a few claims. Because the signature lets any service verify the token without a database lookup, JWTs are the workhorse of stateless authentication in Express APIs. The jsonwebtoken package is the de facto library for creating and validating them, and this page covers signing tokens, verifying them in middleware, choosing between shared secrets and key pairs, and dealing with expiry and tampering.

Installing and understanding the token

A JWT has three base64url-encoded parts joined by dots: header.payload.signature. The header names the algorithm, the payload holds your claims, and the signature is computed over the first two parts with a key. Anyone can read a JWT (it is encoded, not encrypted), but only the holder of the key can forge a valid signature — so never put secrets in the payload.

npm install jsonwebtoken

Signing a token

jwt.sign(payload, secret, options) returns a signed token. Keep the payload small — a user id and a role are usually enough — and always set an expiry with the expiresIn option so stolen tokens do not live forever. expiresIn accepts a number of seconds or a zeit/ms string like "15m" or "7d".

const express = require("express");
const jwt = require("jsonwebtoken");

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

const ACCESS_SECRET = process.env.ACCESS_SECRET; // long random string

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

  const token = jwt.sign(
    { sub: user.id, role: user.role }, // payload (claims)
    ACCESS_SECRET,
    { expiresIn: "15m", issuer: "devcraftly-api" }
  );

  res.json({ accessToken: token, tokenType: "Bearer" });
});

app.listen(3000, () => console.log("Listening on http://localhost:3000"));

The library automatically adds the registered claims iat (issued-at) and, because we set expiresIn, exp (expiry). The response looks like this:

Output:

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxODM0NTYwMCwiZXhwIjoxNzE4MzQ2NTAwLCJpc3MiOiJkZXZjcmFmdGx5LWFwaSJ9.q1Q2...signature",
  "tokenType": "Bearer"
}

Treat the signing secret like a database password. Load it from the environment, make it long and random (openssl rand -base64 48), and rotate it if it ever leaks. A short or hard-coded secret makes every token forgeable.

Verifying a token in middleware

The natural place to validate tokens is an Express middleware that runs before protected routes. It reads the Authorization: Bearer <token> header, calls jwt.verify, and attaches the decoded claims to req. jwt.verify throws if the signature is wrong, the token is malformed, or it has expired — so wrap it in try/catch and translate the error into a 401.

function requireAuth(req, res, next) {
  const header = req.headers.authorization || "";
  const [scheme, token] = header.split(" ");

  if (scheme !== "Bearer" || !token) {
    return res.status(401).json({ error: "Missing bearer token" });
  }

  try {
    const payload = jwt.verify(token, ACCESS_SECRET, {
      issuer: "devcraftly-api",
    });
    req.user = payload; // { sub, role, iat, exp, iss }
    next();
  } catch (err) {
    if (err.name === "TokenExpiredError") {
      return res.status(401).json({ error: "Token expired", expiredAt: err.expiredAt });
    }
    return res.status(401).json({ error: "Invalid token" });
  }
}

app.get("/me", requireAuth, async (req, res) => {
  const user = await loadUser(req.user.sub);
  res.json(user);
});

Passing issuer (and audience if you use it) as a verify option makes jwt.verify reject tokens minted for a different service, not just tokens with a bad signature.

Handling expired and invalid tokens

jwt.verify surfaces precise error types so you can respond appropriately. Inspecting err.name lets a client distinguish “your session ended, refresh it” from “this token is bogus.”

Error nameMeaningTypical response
TokenExpiredErrorexp is in the past; err.expiredAt is set401 — prompt a token refresh
JsonWebTokenErrorBad signature, malformed token, wrong issuer/audience401 — reject, do not retry
NotBeforeErrorToken’s nbf claim is still in the future401 — too early to use

A request with an expired token returns:

Output:

< HTTP/1.1 401 Unauthorized
< Content-Type: application/json
{"error":"Token expired","expiredAt":"2026-06-14T10:15:00.000Z"}

Secrets vs. key pairs

The signing key depends on the algorithm. The default HS256 is symmetric: one shared secret both signs and verifies, which is simple but means every service that verifies a token can also mint one. For systems where an auth server issues tokens that other services merely validate, use an asymmetric algorithm like RS256: sign with a private key, and distribute the public key for verification so no downstream service can forge tokens.

const fs = require("node:fs");

const privateKey = fs.readFileSync("./keys/private.pem");
const publicKey = fs.readFileSync("./keys/public.pem");

// Auth server: sign with the PRIVATE key
const token = jwt.sign({ sub: user.id }, privateKey, {
  algorithm: "RS256",
  expiresIn: "15m",
});

// Resource server: verify with the PUBLIC key only
const payload = jwt.verify(token, publicKey, { algorithms: ["RS256"] });

Generate an RSA key pair with OpenSSL:

openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem

Always pin the accepted algorithms on verify with { algorithms: [...] }. Omitting it has historically enabled the “alg=none” and RS256→HS256 confusion attacks, where an attacker downgrades the algorithm to bypass the signature check.

Symmetric (HS256)Asymmetric (RS256/ES256)
KeysOne shared secretPrivate (sign) + public (verify)
Best forSingle app signs and verifiesOne issuer, many verifiers
SharingSecret must stay private everywherePublic key can be distributed freely
CostFastSlightly slower; larger tokens

Best Practices

  • Keep payloads minimal and non-sensitive — JWTs are signed, not encrypted, so anyone can decode the claims.
  • Always set a short expiresIn on access tokens (5–15 minutes) and use a separate longer-lived refresh token to obtain new ones.
  • Load signing keys from environment variables or a secrets manager; never commit them to source control.
  • Pin accepted algorithms with { algorithms: ["RS256"] } on jwt.verify to block algorithm-confusion attacks.
  • Validate issuer and audience so a token issued for one service cannot be replayed against another.
  • Prefer RS256 key pairs when an auth server issues tokens that independent services verify, so verifiers cannot mint tokens.
  • Because JWTs are stateless, maintain a short deny-list (by jti or user id) to revoke tokens before they naturally expire after logout or a password change.
Last updated June 14, 2026
Was this helpful?