
An API without authentication lets anyone who finds the URL read and delete its data. Here I write about how I add JWT authentication to a Node.js and Express API, step by step: hashing passwords with bcrypt, signing short-lived tokens, verifying them in middleware and checking roles.
The code runs on Node 10 LTS and Express 4.16, with jsonwebtoken 8 and bcrypt 3. The examples assume a users table and a small AppError class, an Error with an HTTP status. Every failure below calls next() with one. A central handler then turns it into a JSON response, as I show in my guide to error handling middleware in Node.js.
What a JWT is, and what it is not
A JSON Web Token, defined in RFC 7519, has three Base64URL parts separated by dots: a header, a payload and a signature. The server signs the header and payload with a secret. Later, it can check that signature without a database lookup. That is the whole appeal of JWT authentication: the token carries its own proof.
Two facts shape every JWT authentication decision that follows. First, the payload is encoded, not encrypted. Anyone holding the token can read it, so never put a password, an email you would not publish or any secret inside. Second, a signed token stays valid until it expires. You cannot “log out” a JWT on the server without extra machinery. Hence, access tokens must be short-lived.
Configuration: refuse to start with a weak secret
An HS256 token is only as strong as its secret. A short or guessable secret can be brute-forced offline from a single captured token. So my config module, which validates environment variables at startup, rejects anything shorter than 32 characters:
JWT_SECRET: Joi.string().min(32).required(),
Generate a real one with Node’s crypto module:
node -e "console.log(require('crypto').randomBytes(48).toString('base64'))"
Step 1: Store passwords as bcrypt hashes
JWT authentication starts with a login, and a login needs stored passwords. Never store them as plain text, and never use a fast hash such as SHA-256. Fast hashes let an attacker with a leaked table try billions of guesses per second. Instead, bcrypt is slow on purpose, and its cost factor makes it slower as hardware improves.
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE,
password_hash text NOT NULL,
role text NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
created_at timestamptz NOT NULL DEFAULT now()
);
const bcrypt = require('bcrypt');
const COST = 12;
function hashPassword(plain) {
return bcrypt.hash(plain, COST); // salt is generated and stored inside the hash
}
function checkPassword(plain, hash) {
return bcrypt.compare(plain, hash);
}
A cost of 12 takes a few hundred milliseconds on a typical server. Measure it on your own hardware and pick the highest value your login traffic can afford. The result starts with $2b$12$, followed by the salt and the hash, so you store one string per user. The OWASP password storage cheat sheet explains the reasoning.
One practical note: the bcrypt package compiles native code during install. If your build image lacks Python and a compiler, bcryptjs offers the same API in pure JavaScript, at roughly a third of the speed.
Step 2: Register and log in
// Computed once at startup, used when the email is unknown.
const DUMMY_HASH = bcrypt.hashSync('not-a-real-password', COST);
async function register({ email, password }) {
const passwordHash = await hashPassword(password);
return users.create({ email: email.toLowerCase(), passwordHash });
}
async function login({ email, password }) {
const user = await users.findByEmail(email.toLowerCase());
// Compare even when the user does not exist, so timing does not reveal it.
const ok = await checkPassword(password, user ? user.passwordHash : DUMMY_HASH);
if (!user || !ok) {
throw new AppError(401, 'Unauthorized', 'Invalid email or password');
}
return issueAccessToken(user);
}
The login returns the same message for a wrong email and a wrong password. A separate “user not found” message would let anyone test which emails have accounts. For the same reason, the code runs a bcrypt comparison against a dummy hash when the user is missing. Otherwise, the fast “no user” path would reveal the answer through response time.
The registration route validates input with Joi before the handler runs. I ask for an email and a password of at least 10 characters. I also add rate limiting on the login route, because a slow hash does not stop an attacker who can try forever.
Step 3: Sign and verify tokens
const jwt = require('jsonwebtoken');
const config = require('./config');
const JWT_OPTIONS = { issuer: 'taskapi', audience: 'taskapi-clients' };
function issueAccessToken(user) {
return jwt.sign({ sub: user.id, role: user.role }, config.jwtSecret, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: JWT_OPTIONS.issuer,
audience: JWT_OPTIONS.audience,
});
}
function verifyAccessToken(token) {
return jwt.verify(token, config.jwtSecret, {
algorithms: ['HS256'],
issuer: JWT_OPTIONS.issuer,
audience: JWT_OPTIONS.audience,
});
}
The payload holds only what the API needs on every request: the user id in sub and the role. Decoded, a token looks like this:
{"sub":"u1","role":"user","iat":1543984200,"exp":1543985100,"aud":"taskapi-clients","iss":"taskapi"}
The algorithms option on verify is not optional in my code. It pins the accepted algorithm. Without it, older libraries could be tricked into accepting a token with "alg": "none" or into confusing an RSA public key with an HMAC secret. With it, jsonwebtoken rejects the forged token. Here is real output from those checks:
JsonWebTokenError invalid signature (signed with a different secret)
TokenExpiredError jwt expired (exp in the past)
JsonWebTokenError jwt audience invalid. expected: ... (token for another service)
JsonWebTokenError jwt signature is required (forged "alg": "none" token)
The issuer and audience checks matter as soon as you have two services sharing a secret. They stop a token issued for one from working at the other.
Step 4: The JWT authentication middleware
function authenticate(req, res, next) {
const header = req.get('authorization') || '';
const parts = header.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
res.set('WWW-Authenticate', 'Bearer');
return next(new AppError(401, 'Unauthorized', 'Missing bearer token'));
}
try {
const claims = verifyAccessToken(parts[1]);
req.user = { id: claims.sub, role: claims.role };
next();
} catch (err) {
res.set('WWW-Authenticate', 'Bearer error="invalid_token"');
const detail = err.name === 'TokenExpiredError' ? 'Token expired' : 'Invalid token';
next(new AppError(401, 'Unauthorized', detail));
}
}
app.use('/tasks', authenticate, tasksRouter);
The JWT authentication middleware reads the token from the Authorization header in the Bearer format. It never reads tokens from query strings, because URLs end up in logs and browser history.
On success, it attaches a small req.user object. On failure, it passes a 401 to the central error handler, together with a WWW-Authenticate header as the HTTP spec expects. The client learns whether the token expired, which tells it to refresh. It does not learn anything else about why verification failed.
Step 5: Authorization is a separate question
Authentication answers “who are you?”. Authorization answers “may you do this?”. I keep them in separate middleware, so each route states its rule in one place:
function requireRole(role) {
return (req, res, next) => {
if (!req.user || req.user.role !== role) {
return next(new AppError(403, 'Forbidden', 'Insufficient permissions'));
}
next();
};
}
router.delete('/:id', requireRole('admin'), validate({ params: idParams }), controller.remove);
Note the status codes. A missing or bad token is a 401, which means “authenticate first”. A valid token without permission is a 403, which means “you are known, and the answer is no”. Clients handle the two cases differently, so mixing them up confuses every front end.
Roles cover coarse rules. For “users may only edit their own tasks”, the check belongs in the service, where it can compare task.ownerId with req.user.id.
Why JWT authentication needs short-lived tokens
In JWT authentication, a token cannot be revoked by default. If a token leaks, it works until exp. A 15-minute lifetime limits that damage. Of course, nobody wants to log in every 15 minutes, which is where refresh tokens come in.
My usual JWT authentication design pairs the short token with a long-lived refresh token. The refresh token is a random opaque string, not a JWT. The server stores only its hash, together with the user and an expiry date. When the access token expires, the client sends the refresh token to /auth/refresh. The server then checks it, deletes it and issues a new pair. That rotation means a stolen refresh token works at most once. Logout becomes simple too: delete the stored refresh token.
For an instant ban, keep a short deny list of token ids (jti) until those tokens expire.
HS256 versus RS256 for JWT authentication
HS256 uses one shared secret to sign and verify. It is simple and fast, and it fits when one service both issues and checks tokens. However, every service that verifies tokens can also create them.
RS256 uses a private key to sign and a public key to verify. Only the auth service holds the private key, while every other service gets the public key. Choose RS256 once several services verify tokens, or when a third party must check them. With jsonwebtoken, the switch is the key you pass and the algorithm list.
Where the browser should keep the token
For a single-page app, the choice is usually localStorage or a cookie. Any script on the page can read localStorage, so a single XSS bug leaks the token. An httpOnly, Secure, SameSite cookie keeps the token away from scripts, but it needs CSRF protection. I prefer the cookie for refresh tokens and keep the access token in memory. Mobile apps should use the platform keychain instead.
Troubleshooting JWT authentication
| Symptom | Cause | Fix |
|---|---|---|
invalid signature after a deploy |
The secret differs between instances or changed | Load one secret from the same source for every instance |
jwt expired right after login |
The server clock is wrong | Sync time with NTP; allow a small clockTolerance |
jwt malformed |
The header still contains the word Bearer, or quotes |
Split the header and pass only the token |
bcrypt fails to install |
No build tools in the image | Install Python and a compiler, or use bcryptjs |
| Every request returns 403 | The role is missing from the token payload | Include role when signing |
When not to use JWTs
For a classic server-rendered web app on one domain, a server-side session in a cookie is simpler and easier to revoke. JWT authentication shines when the API serves mobile apps, several front ends or several back-end services. Also, if you need instant revocation of every token, you will end up checking a store on each request anyway. At that point, an opaque session token is the honest choice.
Common JWT authentication mistakes
- Verifying without an algorithm list. Always pass
algorithms: ['HS256']or your chosen algorithm. - Putting secrets in the payload. Anyone can decode it.
- Long-lived access tokens. A leaked token then works for days.
- Different messages for unknown users and wrong passwords. That reveals which accounts exist.
- Using
jwt.decodeinstead ofjwt.verify. Decode does not check the signature at all.
FAQ
How does JWT authentication work in Express?
The user logs in with a password, and the server returns a signed token. The client sends it in the Authorization header as a Bearer token. Middleware verifies the signature and expiry on every request and attaches the user to req.
How long should a JWT access token last?
Keep it short, usually 5 to 15 minutes. Pair it with a server-stored refresh token that can be rotated and revoked, so users stay logged in without long-lived bearer tokens.
Is bcrypt still a good choice for passwords?
Yes. bcrypt is slow on purpose and has a tunable cost factor. Pick a cost that takes a few hundred milliseconds on your servers and raise it as hardware gets faster.
What is the difference between 401 and 403?
A 401 means the request has no valid credentials, so the client should log in or refresh. A 403 means the credentials are valid but do not allow this action.
Verify everything and trust nothing in the token beyond what you signed. Keep access tokens short, pin the algorithm, and treat JWT authentication and authorization as two separate checks.
Last updated on 31 October 2026