Skip to content

JWT Authentication in Node.js

JSON Web Tokens (JWTs) are the most common way to implement stateless, token-based authentication in Node.js APIs. This lesson covers signing, verifying, and protecting routes with JWTs.

What Is a JWT?

A JWT is a compact, signed token made of three base64url-encoded parts separated by dots: a header (describing the signing algorithm), a payload (your claims, like userId), and a signature (proving the token wasn't tampered with). Anyone can decode and read a JWT's payload, but only someone with the correct secret (or private key) can produce a valid signature.

Because the server can verify a JWT's signature without a database lookup, JWTs are naturally stateless, ideal for APIs where you don't want to maintain shared session storage across multiple server instances.

import jwt from 'jsonwebtoken';

const token = jwt.sign(
  { userId: user.id },
  process.env.JWT_SECRET,
  { expiresIn: '1h' }
);

// Later, on a protected request:
const payload = jwt.verify(token, process.env.JWT_SECRET);
console.log(payload.userId);

jwt.verify() throws if the signature is invalid or the token has expired, always wrap it in a try/catch when verifying tokens from client requests.

Signing and Verifying JWTs

jwt.sign(payload, secret, { expiresIn: '1h' })
jwt.verify(token, secret)   // throws on invalid/expired tokens
jwt.decode(token)            // reads payload WITHOUT verifying (unsafe alone)
  • jwt.sign() produces the token string, embedding whatever claims you pass as the payload.
  • jwt.verify() checks the signature and expiration, throwing if either check fails.
  • jwt.decode() reads the payload without verifying it, never trust decoded data without also verifying.
  • expiresIn limits how long a token remains valid, shorter lifetimes reduce the impact of a leaked token.

JWT Cheatsheet

Core jsonwebtoken methods and conventions.

Task Code
Sign a token jwt.sign(payload, secret, { expiresIn: '1h' })
Verify a token jwt.verify(token, secret)
Send in a request Authorization: Bearer <token> header
Read the header req.headers.authorization?.split(' ')[1]
Handle expiration Catch TokenExpiredError specifically
Refresh tokens A separate, longer-lived token used to obtain new access tokens

Protecting Routes With Middleware

A single reusable middleware function can verify the JWT from the Authorization header, attach the decoded user info to req, and reject the request with 401 if verification fails, keeping this logic out of every individual route handler.

function requireAuth(req, res, next) {
  const authHeader = req.headers.authorization;
  const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;

  if (!token) {
    return res.status(401).json({ error: 'Missing token' });
  }

  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch (error) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

app.get('/profile', requireAuth, (req, res) => {
  res.json({ userId: req.user.userId });
});

Access Tokens and Refresh Tokens

Short-lived access tokens (minutes to a couple hours) limit the damage if one leaks, but forcing a full re-login that often is bad for user experience. The common solution is a longer-lived refresh token, stored more securely, used only to obtain new access tokens without re-entering credentials.

  • Access token: short-lived, sent with every API request.
  • Refresh token: longer-lived, stored securely (often in an HTTP-only cookie), used only to mint new access tokens.
  • Revoking a refresh token (on logout or suspected compromise) effectively ends the session going forward.

Common Mistakes

  • Storing JWTs in localStorage where they're vulnerable to being read by malicious JavaScript (XSS).
  • Using a weak or hardcoded JWT_SECRET instead of a long, random, environment-variable-stored secret.
  • Trusting jwt.decode() output without also calling jwt.verify(), decoding alone doesn't check the signature.
  • Issuing tokens with no expiration, removing any built-in limit on how long a leaked token stays valid.

Key Takeaways

  • A JWT is a signed, self-contained token made of a header, payload, and signature.
  • jwt.verify() must be used (not just jwt.decode()) to trust a token's contents.
  • Middleware can centralize JWT verification and attach the decoded user to req.
  • Short-lived access tokens combined with a longer-lived refresh token balance security and user experience.

Pro Tip

Store your JWT_SECRET as a long, randomly generated string (crypto.randomBytes(64).toString('hex') works well) in an environment variable, never commit it to source control, and rotate it if you ever suspect it has leaked.