Skip to content
Node.js nd security 5 min read

Authentication with JWT

A JSON Web Token (JWT) is a compact, signed credential that lets a server prove a client’s identity without keeping any session state in memory or a database. The client logs in once, receives a token, and sends it on every subsequent request; the server verifies the signature and trusts the claims inside. Because the token is self-contained and the signature is cheap to check, JWTs scale horizontally with no shared session store — which is exactly why they dominate modern API authentication. The trade-off is that a valid token cannot be easily revoked, so getting expiry and storage right matters.

Anatomy of a token

A JWT is three Base64URL-encoded segments joined by dots: header.payload.signature. The header names the signing algorithm (e.g. HS256). The payload holds the claims — JSON key/value pairs such as the subject (sub), issued-at (iat), and expiry (exp). The signature is a keyed hash of the first two segments, and it is the only part that can’t be forged without the secret.

The payload is encoded, not encrypted. Anyone can decode and read it — never put passwords, card numbers, or other secrets in a JWT. The signature guarantees integrity, not confidentiality.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9   ← header   {"alg":"HS256","typ":"JWT"}
.eyJzdWIiOiI0MiIsImlhdCI6MTcxOH0       ← payload  {"sub":"42","iat":1718...}
.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1   ← signature HMAC-SHA256(header.payload, secret)

Signing a token

The de-facto library is jsonwebtoken. jwt.sign(payload, secret, options) builds and signs a token. Keep the payload small — just enough to identify the user and their permissions — and always set an expiry. The secret should be a long, random value loaded from the environment, never hard-coded.

npm install jsonwebtoken
import jwt from "jsonwebtoken";

const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET; // 32+ random bytes

function issueAccessToken(user) {
  return jwt.sign(
    { sub: user.id, role: user.role },
    ACCESS_SECRET,
    { expiresIn: "15m", issuer: "devcraftly-api" }
  );
}

const token = issueAccessToken({ id: 42, role: "editor" });
console.log(token.slice(0, 40) + "...");

Output:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ...

jsonwebtoken automatically adds iat and, because we passed expiresIn, an exp claim 15 minutes in the future.

Verifying a token

On each protected request, the client sends the token in the Authorization: Bearer <token> header. jwt.verify re-computes the signature and checks exp; it throws if the token is tampered with or expired. Wrap it so an invalid token becomes a clean 401.

import jwt from "jsonwebtoken";

export function authenticate(req, res, next) {
  const header = req.headers.authorization ?? "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: "Missing token" });

  try {
    const claims = jwt.verify(token, process.env.JWT_ACCESS_SECRET, {
      issuer: "devcraftly-api",
    });
    req.user = { id: claims.sub, role: claims.role };
    next();
  } catch (err) {
    const expired = err.name === "TokenExpiredError";
    return res.status(401).json({ error: expired ? "Token expired" : "Invalid token" });
  }
}

Output (expired token):

HTTP/1.1 401 Unauthorized
{"error":"Token expired"}

Always pass the same issuer/audience options to verify that you used in sign. Verifying only the signature without these checks lets a token minted for another service be replayed against yours.

Access tokens vs refresh tokens

A single long-lived token is dangerous: if it leaks it stays valid for its whole lifetime, and there’s no built-in way to revoke it. The standard fix is two tokens. A short-lived access token (minutes) authorizes API calls. A long-lived refresh token (days/weeks) does nothing but obtain new access tokens from a dedicated endpoint. Because refresh tokens are sent rarely and can be stored server-side, you can revoke them — log a user out by deleting their refresh-token record.

Access tokenRefresh token
LifetimeShort (5–15 min)Long (days–weeks)
Sent withEvery API requestOnly the /refresh endpoint
Stored whereIn memory (client)httpOnly cookie / secure store
RevocableNo (just expires)Yes (server-side allow-list)
PurposeAuthorize requestsMint new access tokens
function issueRefreshToken(user) {
  return jwt.sign({ sub: user.id }, process.env.JWT_REFRESH_SECRET, {
    expiresIn: "7d",
  });
}

// POST /auth/refresh — exchange a valid refresh token for a fresh access token
export async function refresh(req, res) {
  const { refreshToken } = req.cookies;
  try {
    const { sub } = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
    if (!(await isRefreshTokenActive(sub, refreshToken))) {
      return res.status(401).json({ error: "Revoked" });
    }
    const user = await findUserById(sub);
    return res.json({ accessToken: issueAccessToken(user) });
  } catch {
    return res.status(401).json({ error: "Invalid refresh token" });
  }
}

Use separate secrets for the two token types so a leaked access secret can’t forge refresh tokens, and store a hash of each issued refresh token (isRefreshTokenActive above) so you can invalidate it on logout or rotation.

Storing tokens securely

Where the client keeps tokens determines which attacks apply. localStorage is readable by any script on the page, so a single XSS flaw exfiltrates the token — avoid it for refresh tokens. The safest pattern is to put the refresh token in an httpOnly, Secure, SameSite cookie (invisible to JavaScript, immune to XSS theft) and hold the short-lived access token only in memory, re-fetching it via /refresh after a reload.

res.cookie("refreshToken", issueRefreshToken(user), {
  httpOnly: true,       // not exposed to document.cookie / JS
  secure: true,         // HTTPS only
  sameSite: "strict",   // mitigates CSRF
  path: "/auth/refresh",
  maxAge: 7 * 24 * 60 * 60 * 1000,
});

Cookies bring CSRF risk, so pair SameSite=Strict (or Lax) with a CSRF token or a custom header check on state-changing routes.

Best Practices

  • Use strong, random per-type secrets (32+ bytes) loaded from the environment — never commit them, and rotate them periodically.
  • Always set a short expiresIn on access tokens; pair them with revocable refresh tokens rather than issuing one long-lived token.
  • Verify issuer/audience (and the algorithm) on every verify call so tokens from other services can’t be replayed.
  • Keep payloads minimal and secret-free — the body is encoded, not encrypted, and is fully readable by the client.
  • Store refresh tokens in httpOnly, Secure, SameSite cookies; keep access tokens in memory, not localStorage.
  • Track issued refresh tokens server-side so logout, password change, and token rotation can revoke them immediately.
  • Serve tokens only over HTTPS, and reject the none algorithm explicitly if you accept tokens from external issuers.
Last updated June 14, 2026
Was this helpful?