Asia/Karachi
BlogOctober 4, 2026

JWT Authentication in Express with bcrypt

Register, log in and protect routes with hashed passwords and short-lived tokens
Fawaz Israr
JWT Authentication in Express with bcrypt
To add JWT authentication to an Express app with bcrypt, hash each password with bcrypt.hash when the user registers, check it with bcrypt.compare when they log in, and then sign a short-lived token with jsonwebtoken. Send the token in an httpOnly cookie and verify it in a middleware function that guards every protected route. I'm a final-year Computer Science student, and the custom gift store I built, My-Shop, uses this combination: JWT authentication with bcrypt password hashing. This article walks through the setup I would start from today. It uses Node 20 or newer, Express 4 or 5, and ES modules, so add "type": "module" to your package.json. Registration takes an email and a password, hashes the password, and stores only the hash. Login looks the user up by email, compares the submitted password with the stored hash, and on a match issues a token. Every later request carries that token, and a middleware function checks it before the route handler runs. Install what you need:
Bash
npm install express bcrypt jsonwebtoken cookie-parser express-rate-limit
The server never needs to remember who is logged in. Everything it needs is inside the signed token, and that is the main reason people pick JWTs for APIs. The cost is that you cannot easily cancel a token before it expires, which shapes most of the decisions below. Databases leak. When one does, a plain text password column hands attackers every account, and every other site where those users reused the password. A hash lets you verify a password without being able to read it back. Fast hashes such as MD5 or plain SHA-256 are the wrong tool, because a modern GPU can try billions of guesses per second against them. bcrypt is deliberately slow and has a cost factor that sets how much work each hash takes. It also adds a random salt to every hash, so two users with the same password get different results. The OWASP password storage cheat sheet recommends a cost factor of at least 10 for bcrypt. A cost of 12 is a common starting point. Time a hash on the real server before settling, because the right number depends on your hardware and how many logins you expect. One limit is worth knowing: bcrypt only uses the first 72 bytes of its input. Anything longer is ignored, so the code below rejects passwords over 72 bytes instead of silently truncating them. The node.bcrypt.js readme covers the details and the API. Here is the core of an auth.js file. The users module is a stand-in for whatever database layer you use.
Js
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { findUserByEmail, createUser } from "./users.js";

const COST = 12;
// Used when the email is unknown, so login takes about the same time either way
const DUMMY_HASH = await bcrypt.hash("not-a-real-password", COST);

const cookieOptions = {
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "lax",
  maxAge: 15 * 60 * 1000,
};

export async function register(req, res) {
  const email = String(req.body.email ?? "").trim().toLowerCase();
  const password = String(req.body.password ?? "");

  if (!email.includes("@") || password.length < 8 || Buffer.byteLength(password) > 72) {
    return res.status(400).json({ error: "Invalid email or password format" });
  }
  if (await findUserByEmail(email)) {
    return res.status(409).json({ error: "Could not create account" });
  }

  const passwordHash = await bcrypt.hash(password, COST);
  const user = await createUser({ email, passwordHash });
  res.status(201).json({ id: user.id, email: user.email });
}

export async function login(req, res) {
  const email = String(req.body.email ?? "").trim().toLowerCase();
  const password = String(req.body.password ?? "");

  const user = await findUserByEmail(email);
  const matches = await bcrypt.compare(password, user ? user.passwordHash : DUMMY_HASH);

  if (!user || !matches) {
    return res.status(401).json({ error: "Invalid email or password" });
  }

  const token = jwt.sign({ sub: String(user.id) }, process.env.JWT_SECRET, {
    expiresIn: "15m",
  });
  res.cookie("access_token", token, cookieOptions);
  res.json({ id: user.id, email: user.email });
}
Express 5 forwards errors from async handlers to your error middleware on its own. On Express 4, wrap them in try and catch, or add a small wrapper, so a rejected promise does not leave the request hanging. As little as possible. A JWT payload is only base64 encoded and signed, not encrypted, so anyone holding the token can read it. The signature stops people changing it, but it does nothing to hide it. A user id in the standard sub claim is usually enough. Skip emails, password hashes and anything private. If you put a role in the token, remember it stays valid until the token expires, so a demoted user keeps the old role for up to 15 minutes. For role checks that must be current, read the role from the database on each request. I cover that side in role-based access control in Next.js with Prisma. The signing secret belongs in an environment variable, never in the repository. Generate a long random one and load it with node --env-file=.env app.js (Node 20.6 and later):
Bash
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
Pass expiresIn every time you sign. A token with no expiry stays valid forever, and that is rarely what you want. Read the cookie, verify the signature and expiry, and attach the user id to the request. Pin the algorithm in the algorithms option so the verifier only accepts what you signed with.
Js
import jwt from "jsonwebtoken";

export function requireAuth(req, res, next) {
  const token = req.cookies.access_token;
  if (!token) return res.status(401).json({ error: "Not authenticated" });

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ["HS256"] });
    req.userId = payload.sub;
    next();
  } catch {
    res.status(401).json({ error: "Invalid or expired token" });
  }
}
Close-up of source code displayed on a dark monitor
Wire it together in your app file. The login route gets a rate limiter, and the /me route shows a protected endpoint:
Js
import express from "express";
import cookieParser from "cookie-parser";
import rateLimit from "express-rate-limit";
import { register, login, requireAuth } from "./auth.js";

const app = express();
app.use(express.json());
app.use(cookieParser());

const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 10 });

app.post("/auth/register", register);
app.post("/auth/login", loginLimiter, login);
app.get("/me", requireAuth, (req, res) => res.json({ id: req.userId }));

app.listen(3000);
If your app sits behind a reverse proxy or a hosting platform's load balancer, set app.set("trust proxy", 1) so the limiter sees the real client address instead of the proxy's. The same middleware pattern protects payment routes, for example the ones in Stripe Checkout in a MERN stack app. Both options have a weakness, and it helps to know which one you are accepting.
  • localStorage can be read by any JavaScript running on your page. One XSS bug, or one compromised npm package in your frontend, and the token can be copied out.
  • An httpOnly cookie cannot be read by scripts, so it cannot be copied that way. Browsers attach it automatically, though, which opens the door to CSRF, where another site triggers a request in the user's name.
For a browser app, the httpOnly cookie is the safer default. Add secure so it only travels over HTTPS and sameSite set to lax or strict so most cross-site requests do not carry it. If your frontend and API live on different sites, or you accept state-changing requests that SameSite does not cover, add CSRF tokens as well. MDN has a good overview of cookie attributes. An httpOnly cookie does not stop XSS itself. A script on the page can still send requests that carry the cookie, so you still have to prevent XSS. The cookie only stops the token from being stolen and reused elsewhere. A 15 minute access token limits the damage of a leak, but nobody wants to log in every 15 minutes. The usual answer is a second, longer-lived refresh token. At a high level: login returns a short access token and a random refresh token. The server stores only a hash of the refresh token in the database and sends the real one in an httpOnly cookie limited to the refresh route. When the access token expires, the client calls that route, the server checks the stored hash, and it issues a new access token along with a new refresh token (rotation). Logging out deletes the stored record, which is what makes revocation possible. You can skip refresh tokens for a small project and accept a longer expiry, but then you have no way to cancel a stolen token. Run through this before you ship:
  1. Passwords are hashed with bcrypt at cost 10 or higher, never with a fast hash, and over-long input is rejected.
  2. JWT_SECRET is long, random and loaded from the environment, not a word like "secret" in the source.
  3. Tokens always carry expiresIn, and jwt.verify pins the algorithm.
  4. The payload holds an id and nothing private.
  5. The token cookie is httpOnly, secure in production and has a sameSite setting.
  6. Login has a rate limit and one generic error message for unknown emails and wrong passwords, with similar response times.
  7. Protected routes use the verification middleware, and role or ownership checks happen on the server.
Cover photo from Unsplash.
Share this post: