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.
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.
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.
You now implement JWT authentication securely. Next, learn password hashing, the step that protects credentials before any token is even issued.