JWT Authentication in Node.js and Express: Step by Step

JWT authentication for Express, step by step: bcrypt passwords, short-lived tokens, verify middleware, roles, refresh tokens and token storage.

Executive Summary: Here I add JWT authentication to an Express API step by step. Passwords are stored as bcrypt hashes, and the login issues a short-lived token signed with jsonwebtoken 8. Middleware then verifies every request with a pinned algorithm, and a role check handles authorization. I also cover refresh tokens, HS256 versus RS256 and token storage.
JWT authentication in paper-cut style: red, purple and blue token segments pass through a shield gate to a server rack

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.decode instead of jwt.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

Authentication Express JWT Node.js Security

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *