Node.js Backend Fundamentals with Express · Data, Auth, Tests and Production

Authentication with Hashed Passwords and JWTs

Hash passwords with scrypt, issue short-lived JSON Web Tokens with jose, and protect routes with an auth middleware.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

POST /register, POST /login and a protected GET /me. Passwords are stored as salted scrypt hashes; login returns a signed JWT that expires after 15 minutes; a middleware verifies it on protected routes.

The idea

  • Never store passwords: store a slow, salted hash (scrypt, bcrypt or argon2) and compare with a timing-safe function.
  • A JWT is a signed token carrying claims (sub, exp). The server verifies the signature; it doesn't need a session store.
  • Keep access tokens short-lived; use refresh tokens or sessions for longer logins, and keep signing keys in configuration, not code.
  • The auth middleware reads Authorization: Bearer <token>, verifies it, and puts the user on res.locals.

The code

Register, log in and call a protected route
import express from "express";
import { scryptSync, randomBytes, timingSafeEqual } from "node:crypto";
import { SignJWT, jwtVerify } from "jose";

const secret = new TextEncoder().encode(process.env.JWT_SECRET ?? "dev-only-secret-change-me-32-bytes!!");
const users = new Map();
const hash = (password, salt = randomBytes(16)) => `${salt.toString("hex")}:${scryptSync(password, salt, 32).toString("hex")}`;
const verifyPassword = (password, stored) => {
  const [salt, key] = stored.split(":");
  return timingSafeEqual(Buffer.from(key, "hex"), scryptSync(password, Buffer.from(salt, "hex"), 32));
};
const auth = async (req, res, next) => {
  const token = req.get("authorization")?.replace(/^Bearer /, "");
  try {
    const { payload } = await jwtVerify(token ?? "", secret);
    res.locals.user = payload.sub;
    next();
  } catch {
    res.status(401).json({ error: "invalid or missing token" });
  }
};

const app = express();
app.use(express.json());
app.post("/register", (req, res) => {
  users.set(req.body.email, hash(req.body.password));
  res.status(201).json({ registered: req.body.email });
});
app.post("/login", async (req, res) => {
  const stored = users.get(req.body.email);
  if (!stored || !verifyPassword(req.body.password, stored)) return res.status(401).json({ error: "wrong email or password" });
  const token = await new SignJWT({}).setProtectedHeader({ alg: "HS256" }).setSubject(req.body.email)
    .setIssuedAt().setExpirationTime("15m").sign(secret);
  res.json({ token });
});
app.get("/me", auth, (req, res) => res.json({ email: res.locals.user }));

const server = app.listen(0);
const base = `http://127.0.0.1:${server.address().port}`;
const post = (path, body) => fetch(base + path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
await post("/register", { email: "[email protected]", password: "correct horse battery staple" });
console.log("stored:", users.get("[email protected]").slice(0, 12) + "... (salt:hash, never the password)");
console.log("wrong password:", (await post("/login", { email: "[email protected]", password: "guess" })).status);
const { token } = await (await post("/login", { email: "[email protected]", password: "correct horse battery staple" })).json();
console.log("token parts:", token.split(".").length);
const me = await fetch(`${base}/me`, { headers: { Authorization: `Bearer ${token}` } });
console.log("/me with token:", me.status, JSON.stringify(await me.json()));
console.log("/me without token:", (await fetch(`${base}/me`)).status);
server.close();

Output

stored: 8080f1ca5d72... (salt:hash, never the password)
wrong password: 401
token parts: 3
/me with token: 200 {"email":"[email protected]"}
/me without token: 401

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • Fast hashes (SHA-256) or unsalted hashes for passwords.
  • Long-lived JWTs with no way to revoke them.
  • Putting sensitive data in the JWT payload: it's signed, not encrypted, and anyone can read it.
  • Hard-coding the signing secret in source code.

Key points

  • Store salted, slow password hashes and compare in constant time.
  • Issue short-lived signed tokens; verify them in middleware.
  • Keep secrets in configuration and plan for refresh and revocation.

Further reading: jose · OWASP Password Storage Cheat Sheet · OWASP JWT Cheat Sheet

Practice questions

Why use scrypt, bcrypt or argon2 rather than SHA-256 for passwords?

Answer

They are deliberately slow and salted, making brute-force and rainbow-table attacks on stolen hashes far more expensive.

Is data in a JWT payload secret?

Answer

No. A signed JWT is base64url-encoded and readable by anyone; the signature only prevents tampering. Don't put sensitive data in it.

Why keep access tokens short-lived?

Answer

A stolen JWT is valid until it expires and is hard to revoke; short lifetimes limit the damage, with refresh tokens or sessions for long logins.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?