How to add JWT authentication to a Node.js API: a practical guide
Step-by-step implementation of JWT authentication on a Node.js/Express API: password hashing, login endpoint, token verification middleware, refresh tokens, and the common security pitfalls.
On this page
- What JWT gives you (and what it does not)
- Prerequisites
- The data model
- Hashing passwords with bcrypt
- Signing a JWT
- The login endpoint
- Verifying tokens in middleware
- Refresh tokens
- Logging out
- Common pitfalls
- End-to-end flow
- FAQ
- How long should an access token live?
- Where should I store JWTs on the client?
- Should I use HS256 or RS256?
- What is the difference between an access token and a refresh token?
- Do I need a database for users?
- Is JWT the right choice for sessions?
- Verdict
JSON Web Tokens (JWTs) are the standard way to make a stateless authenticated API in Node.js. The client sends credentials once to a login endpoint, the server returns a signed token, and from then on the client proves who it is by sending that token in an Authorization header. This guide walks through the whole flow on an Express API: hashing passwords, signing tokens, verifying them in middleware, refreshing them, and avoiding the security mistakes that take most JWT setups down.
What JWT gives you (and what it does not)
A JWT is a compact, URL-safe string with three base64url-encoded parts — header.payload.signature — separated by dots. The server signs the header.payload with a secret (or private key) and the receiver verifies the signature. Because the signature covers the payload, a client cannot tamper with the claims without the server noticing.
JWT gives you:
- Stateless authentication. The server does not keep a session table; the token is the proof. A cluster of API nodes can verify any token as long as they share the signing key.
- Self-contained claims. The payload can carry the user id, role and expiry, so the API does not need a database round-trip on every request to know who the caller is.
- Cross-service trust. The same token can be verified by multiple services that share the public key (with RS256/ES256).
JWT does not give you:
- Revocation for free. A signed token is valid until it expires, even if the user logs out or is banned. You need a denylist, short expirations, or refresh-token rotation to mitigate that.
- Encrypted payload. Base64url is encoding, not encryption. Anyone who can read the token can read the payload. Never put passwords, tokens or PII in JWT claims unless you use a JWE.
- Password storage. JWT is for sessions. Storing user passwords is a separate job that needs a slow, salted hash (bcrypt, scrypt or Argon2).
For a deeper look at the structure and the claims, see the JWT explained guide; for the surrounding HTTP layer, see REST API with Node.js and Express.
Prerequisites
- Node.js 20+ installed.
- A working Express app. If you do not have one yet, the REST API with Node.js and Express guide walks you through the basics.
- A database or in-memory store for users (a
usersarray is enough for the demo; the same code works with Postgres, MongoDB, etc.). - The packages
jsonwebtoken,bcryptandexpress.
npm install express jsonwebtoken bcrypt
All three are open source. jsonwebtoken is the most widely used JWT library for Node.js, bcrypt is the reference password-hashing function (designed by Provos and Mazières in 1999 and still considered safe with a sufficient cost factor).
The data model
A user record needs at least an id, an email and a password hash. The plain password is never stored. A minimal shape looks like this:
// users store — in production this is a database table
const users = [];
let nextId = 1;
In a real app you would also store a unique index on email, a createdAt timestamp, and either a passwordUpdatedAt field (to invalidate tokens after a password change) or a tokenVersion counter (to bump every time the user logs out everywhere).
Hashing passwords with bcrypt
Never store passwords in plain text or with a fast hash like SHA-256. bcrypt is the standard choice: it is slow on purpose, salts automatically, and the cost factor can be raised over time to keep pace with hardware.
import bcrypt from "bcrypt";
const COST = 12; // 2^12 = 4096 rounds — balance between security and login latency
export async function hashPassword(plain) {
return bcrypt.hash(plain, COST);
}
export async function verifyPassword(plain, hash) {
return bcrypt.compare(plain, hash);
}
The COST value is the log2 of the number of internal rounds. 12 is the common production default: on a modern server it takes roughly 200–300 ms, which slows a brute-force attack on a stolen database to a crawl while still being acceptable for a real login. Drop to 10 for test environments, raise to 13 if your hardware can take it.
Two important properties of bcrypt, straight from the spec:
- Salting is automatic. Every call to
bcrypt.hashgenerates a fresh 16-byte salt and embeds it in the output. You do not store the salt separately. - The hash carries its own parameters. The output looks like
$2a$12$R9h/cIPz0gi.URNNX3kh2O.PST9/PgBkqquzi.Ss7KIUgO2t0jWMUW— the$2a$part identifies the algorithm,12the cost, and the next 22 characters the salt. Verification extracts all three from the stored hash.
bcrypt.compare is constant-time for a given hash, which avoids timing attacks on the comparison.
Signing a JWT
The jsonwebtoken package exposes jwt.sign(payload, secret, options):
import jwt from "jsonwebtoken";
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
export function signAccessToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email },
ACCESS_SECRET,
{ algorithm: "HS256", expiresIn: "15m" }
);
}
export function signRefreshToken(user) {
return jwt.sign(
{ sub: user.id, typ: "refresh" },
REFRESH_SECRET,
{ algorithm: "HS256", expiresIn: "7d" }
);
}
A few details that matter:
algorithm: "HS256"is pinned explicitly. Without it, some libraries honor thealgfield of an incoming token, which is the well-known “algorithm confusion” attack. Pin it.sub(subject) is the user id. The JWT spec reserves a small set of claim names —iss,sub,aud,exp,nbf,iat,jti— and using them keeps your tokens interoperable.expiresIncontrols lifetime. Short access tokens (15 minutes is a common default) limit the damage if one leaks. Refresh tokens (7 days) are the way the client gets a new access token without re-entering the password.- Two different secrets. Access and refresh tokens are signed with different keys so a leaked refresh token cannot be used to forge access tokens, and vice versa.
- Secrets come from the environment.
process.env.JWT_ACCESS_SECRETis loaded from your deployment environment, never hardcoded. A 32-byte random string is enough;openssl rand -hex 32gives you one.
The login endpoint
The POST /auth/login route takes an email and password, verifies the hash, and returns both tokens:
app.post("/auth/login", async (req, res) => {
const { email, password } = req.body ?? {};
if (typeof email !== "string" || typeof password !== "string") {
return res.status(400).json({ error: "email and password required" });
}
const user = users.find((u) => u.email === email);
if (!user || !(await verifyPassword(password, user.passwordHash))) {
// same response for unknown email and wrong password
return res.status(401).json({ error: "invalid credentials" });
}
return res.json({
accessToken: signAccessToken(user),
refreshToken: signRefreshToken(user),
});
});
Two security details worth pointing out:
- Same error for unknown email and wrong password. Telling the client “user not found” leaks which emails are registered; “invalid credentials” is the only safe response. This is the standard mitigation against user enumeration.
- Rate limiting belongs here. Even with a slow hash, an attacker can try millions of passwords per second if you let them. A simple per-IP and per-email rate limiter (e.g.
express-rate-limit) on the login and refresh routes is not optional in production.
The matching POST /auth/register endpoint is symmetrical: validate input, hash the password, save the user, return tokens. Same error-handling rules.
Verifying tokens in middleware
Every protected route runs through a middleware that reads the Authorization header, verifies the token, and attaches the user to the request:
function requireAuth(req, res, next) {
const header = req.get("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, { algorithms: ["HS256"] });
req.user = { id: payload.sub, email: payload.email };
return next();
} catch {
return res.status(401).json({ error: "invalid or expired token" });
}
}
Then mount it on the routes that need it:
app.get("/me", requireAuth, (req, res) => {
res.json({ id: req.user.id, email: req.user.email });
});
jwt.verify does three things for you, all of which you want:
- Checks the signature. A tampered or unsigned token fails.
- Checks
expandnbf. An expired token or one whosenbfis in the future is rejected. No clock skew by default. - Pins the algorithm. The
algorithms: ["HS256"]option disables the “alg=none” and “RS256 verified with the public key as an HMAC secret” attacks. Pin it the same way on the signing side.
The middleware never re-throws the underlying JWT error to the client — a generic invalid or expired token is enough; details like “signature mismatch” vs “expired at …” help an attacker tune their approach and are best left out.
Refresh tokens
When the access token expires, the client sends the refresh token to POST /auth/refresh and gets a new pair. The refresh token is rotated: the old one is invalidated and a new one is returned.
app.post("/auth/refresh", (req, res) => {
const { refreshToken } = req.body ?? {};
if (typeof refreshToken !== "string") {
return res.status(400).json({ error: "refreshToken required" });
}
try {
const payload = jwt.verify(refreshToken, REFRESH_SECRET, { algorithms: ["HS256"] });
if (payload.typ !== "refresh") {
return res.status(401).json({ error: "wrong token type" });
}
const user = users.find((u) => u.id === payload.sub);
if (!user) return res.status(401).json({ error: "user not found" });
return res.json({
accessToken: signAccessToken(user),
refreshToken: signRefreshToken(user),
});
} catch {
return res.status(401).json({ error: "invalid or expired refresh token" });
}
});
Why rotation matters: if a refresh token leaks, the legitimate client will use it next and the server will see the same jti twice. That is the signal to invalidate the user's whole token family. In a real system, store the jti of the last issued refresh token per user; if the same jti shows up again, revoke everything and force a re-login.
Logging out
Stateless JWT makes logout tricky. The token is still mathematically valid after the user leaves — there is nothing on the server to delete. Three options, in order of complexity:
- Client-side discard. The client deletes the tokens. Simple, but does not protect a leaked token.
- Short access tokens. With a 15-minute access token, a stolen token is useful for at most 15 minutes even without server-side action.
- Server-side denylist. Keep a Redis set of revoked
jtis and check it inrequireAuth. For most apps, option 2 alone is enough; add the denylist if you have a real reason to invalidate tokens immediately (password change, ban, suspected leak).
app.post("/auth/logout", requireAuth, (req, res) => {
// client should also delete the tokens
res.status(204).end();
});
Common pitfalls
- Storing the secret in source control. If
JWT_ACCESS_SECRETlives in the repo, rotate it the moment the repo leaks. Use environment variables and a secret manager. - Putting the token in
localStorage.localStorageis readable by any script on the page; a single XSS bug drains it. Store tokens in anHttpOnly; Secure; SameSite=Strictcookie when the client is a browser. For native apps or server-to-server callers, theAuthorizationheader is fine. - Trusting the
algfield. The classic JWT pitfall: a server that does not pin the algorithm can be tricked into accepting a token withalg: noneoralg: HS256where the public RSA key is the “secret”. Always passalgorithms: ["HS256"]tojwt.verify. - Long-lived access tokens. A token that never expires is a permanent backdoor. 15 minutes is a sensible default for human-driven APIs; service-to-service tokens can be longer but should still expire.
- No rate limiting on auth routes. Without a limiter, an attacker can grind the password field.
express-rate-limitkeyed on(ip, email)is the standard fix. - Sensitive data in the payload. JWT payload is readable by anyone who has the token. Put only the user id, role and similar non-sensitive claims in there.
- Forgetting HTTPS. Tokens travel in the
Authorizationheader on every request. Without TLS, anyone on the network can read them. Terminate TLS at the edge (Nginx, a managed load balancer, a CDN) and never run an authenticated API over plain HTTP.
End-to-end flow
Putting it all together, a client does this:
POST /auth/loginwith{ email, password }. Receives{ accessToken, refreshToken }.- Calls any protected route with
Authorization: Bearer <accessToken>. The server returns the user data. - After 15 minutes,
accessTokenis expired. The client callsPOST /auth/refreshwith{ refreshToken }and gets a new pair. - On logout, the client deletes both tokens and calls
POST /auth/logoutto tell the server.
That is the full loop. The server is stateless except for the user store and (optionally) a refresh-token version counter or denylist.
FAQ
How long should an access token live?
For human-driven APIs, 5 to 30 minutes is the common range. Shorter is safer (a leaked token is useful for less time) but worse for UX (the client has to refresh more often). 15 minutes is a balanced default. For service-to-service tokens, an hour to a day is common; rotate them with the same refresh flow.
Where should I store JWTs on the client?
For a browser app, the safest option is an HttpOnly; Secure; SameSite=Strict cookie set by the server. JavaScript on the page cannot read it, so an XSS bug cannot exfiltrate it. For a mobile or server-to-server client, store the token in the OS keychain or a secret manager, and send it as Authorization: Bearer … on every request. localStorage is convenient but exposes the token to any script that runs on the page.
Should I use HS256 or RS256?
HS256 uses a single shared secret: the same key signs and verifies. It is fast and simple, and it is the right choice when the same service issues and verifies the token. RS256 uses a private key to sign and a public key to verify; the public key can be shared with other services so they can verify tokens without being able to mint new ones. Pick RS256 when you have multiple services that need to verify tokens but only one service that issues them.
What is the difference between an access token and a refresh token?
The access token is the short-lived credential the client sends on every API call. The refresh token is a longer-lived credential whose only job is to mint new access tokens at the refresh endpoint. Keeping them separate means a leaked access token expires in minutes, and a leaked refresh token can be rotated and revoked. The signing secrets are different so a compromise of one does not give the attacker the other.
Do I need a database for users?
Yes — at least a place to store { id, email, passwordHash }. The demo in this guide uses an in-memory array so the example is self-contained, but in production the user store is a real database table. The same JWT code works with any store because the auth layer only needs the user record after the token is verified.
Is JWT the right choice for sessions?
JWT is a great fit for stateless APIs and for cross-service auth. It is the wrong choice when you need instant server-side revocation (no denylist), or when your sessions carry large amounts of state (the token is sent on every request, so the smaller the better). For traditional server-rendered web apps with Set-Cookie sessions, a server-side session table is often simpler.
Verdict
JWT authentication on a Node.js API is a 100-line job once the parts are clear: bcrypt for passwords, jsonwebtoken for signing, two routes (/auth/login and /auth/refresh) and one middleware (requireAuth). The hard part is not the code, it is the discipline — short access tokens, pinned algorithms, rate limiting, no secrets in the repo, and HTTPS in front of everything. Once those are in place, the API is stateless, horizontally scalable, and the same token works across every node behind the load balancer.
Sources
- IETF RFC 7519 — JSON Web Token (JWT) (rfc-editor.org/rfc/rfc7519)
- IETF RFC 7518 — JSON Web Algorithms (JWA) (rfc-editor.org/rfc/rfc7518)
- Wikipedia — JSON Web Token (en.wikipedia.org/wiki/JSON_Web_Token
- CC BY-SA)
- Wikipedia — bcrypt (en.wikipedia.org/wiki/Bcrypt
- CC BY-SA)
- MDN — SubtleCrypto (developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto
- CC-BY-SA 2.5+)
Share this guide
Related guides
- JWT explained: what a JSON Web Token is and how it works
What a JSON Web Token is, how the header.payload.signature structure works, which claims and algorithms exist, and how to validate JWTs safely.
- What is OAuth 2.0? How OAuth works and how to secure it
OAuth 2.0 is an open standard for delegated authorization defined in RFC 6749. It lets a user (the resource owner) grant a third-party application (the client) limited access to resources on another server without sharing their password. This guide explains the four roles, the main grant types, the recommended authorization-code flow with PKCE, and the security best practices from RFC 9700.
- What is authentication? Passwords, biometrics, SSO, and modern methods explained
Authentication is the process of verifying who you are. Learn how passwords, multi-factor authentication, biometrics, SSO, OAuth, and password managers work — and which methods offer the best protection for your application.