Skip to content
Laviyx Learn

Try

Node.js and Express course menu

Node.js Authentication: Hashing Passwords and JWT Login

Build login for an Express API: why passwords must be hashed, hashing with salt using crypto.scrypt, registration and login endpoints, issuing and verifying JSON Web Tokens, protecting routes with auth middleware, roles and permissions, and sessions vs tokens.

Advanced12 min readUpdated
On this page

Authentication answers "who are you?"; authorisation answers "what may you do?". This lesson builds both for an API: users register and log in with a password, receive a token, and send it with later requests to reach protected routes.

Never store plain passwords#

If a database of plain passwords leaks, every account is exposed, along with every other site where users reused the password. Store a hash instead: a one-way transformation that cannot be reversed. To check a login, hash the entered password the same way and compare.

Password hashes must be slow and salted:

  • Salt: random bytes stored with each hash, so two users with the same password get different hashes and precomputed tables are useless.
  • Slow (deliberately expensive) algorithms make guessing billions of passwords impractical. Use scrypt, bcrypt or Argon2, never plain SHA-256 or MD5.

Node's built-in crypto.scrypt is a suitable algorithm:

passwords.js (JavaScript)
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";

const scryptAsync = promisify(scrypt);

export async function hashPassword(password) {
  const salt = randomBytes(16).toString("hex");
  const hash = await scryptAsync(password, salt, 64);
  return `${salt}:${hash.toString("hex")}`;              // store salt and hash together
}

export async function verifyPassword(password, stored) {
  const [salt, hashHex] = stored.split(":");
  const hash = await scryptAsync(password, salt, 64);
  return timingSafeEqual(hash, Buffer.from(hashHex, "hex"));
}
try-passwords.js (JavaScript)
import { hashPassword, verifyPassword } from "./passwords.js";

const a = await hashPassword("monsoon-2026");
const b = await hashPassword("monsoon-2026");
console.log("Stored value length:", a.length);
console.log("Same password, same stored value?", a === b);
console.log("Correct password:", await verifyPassword("monsoon-2026", a));
console.log("Wrong password:", await verifyPassword("monsoon-2025", a));
Terminal
node try-passwords.js
Output
Stored value length: 161
Same password, same stored value? false
Correct password: true
Wrong password: false

The same password produced two different stored values because each has its own random salt, yet both verify correctly. timingSafeEqual compares in constant time, so attackers cannot learn anything from how long a comparison takes. The bcrypt and argon2 npm packages are popular alternatives.

JSON Web Tokens#

After a successful login, the server needs a way to recognise the user on later requests. A JSON Web Token (JWT) is a signed string containing claims such as the user's id and an expiry time. The server signs it with a secret; on each request it verifies the signature, so a tampered token is rejected.

Terminal
npm install jsonwebtoken

A JWT has three base64url parts separated by dots: header.payload.signature. The payload is encoded, not encrypted: anyone can read it, so never put secrets or personal data in it.

The auth API#

auth-api.js (JavaScript)
import express from "express";
import jwt from "jsonwebtoken";
import { hashPassword, verifyPassword } from "./passwords.js";

const JWT_SECRET = process.env.JWT_SECRET ?? "dev-only-secret-change-me";   // set a long random secret in production
const app = express();
app.use(express.json());

const users = [];                       // a database table in a real app

app.post("/api/register", async (req, res) => {
  const { email, password } = req.body;
  if (!email || !password || password.length < 8) {
    return res.status(400).json({ error: "email and a password of at least 8 characters are required" });
  }
  if (users.some((u) => u.email === email)) return res.status(409).json({ error: "Email already registered" });
  const user = { id: users.length + 1, email, passwordHash: await hashPassword(password), role: users.length === 0 ? "admin" : "customer" };
  users.push(user);
  res.status(201).json({ id: user.id, email: user.email, role: user.role });     // never send the hash
});

app.post("/api/login", async (req, res) => {
  const { email, password } = req.body;
  const user = users.find((u) => u.email === email);
  if (!user || !(await verifyPassword(password ?? "", user.passwordHash))) {
    return res.status(401).json({ error: "Invalid email or password" });     // same message either way
  }
  const token = jwt.sign({ sub: String(user.id), role: user.role }, JWT_SECRET, { expiresIn: "1h" });
  res.json({ token });
});

function requireAuth(req, res, next) {
  const header = req.get("authorization") ?? "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: "Login required" });
  try {
    req.user = jwt.verify(token, JWT_SECRET);
    next();
  } catch {
    res.status(401).json({ error: "Invalid or expired token" });
  }
}

function requireRole(role) {
  return (req, res, next) => (req.user.role === role ? next() : res.status(403).json({ error: "Not allowed" }));
}

app.get("/api/me", requireAuth, (req, res) => {
  const user = users.find((u) => u.id === Number(req.user.sub));
  res.json({ id: user.id, email: user.email, role: user.role });
});

app.get("/api/admin/users", requireAuth, requireRole("admin"), (req, res) => {
  res.json(users.map(({ id, email, role }) => ({ id, email, role })));
});

app.listen(3000);

Key decisions:

  • Never return the password hash, and give the same error for an unknown email and a wrong password, so attackers cannot discover which emails are registered.
  • The token stores only the user id (sub, "subject") and role, and expires after an hour.
  • requireAuth reads Authorization: Bearer <token>, verifies it, and attaches the claims to req.user.
  • requireRole("admin") adds authorisation on top: authenticated but not allowed gets 403, not 401.

Trying it#

Tokens and hashes change every run, so instead of raw curl output, this client script calls the API and prints what matters:

client.js (JavaScript)
const base = "http://localhost:3000/api";
const post = (path, body, token) =>
  fetch(base + path, {
    method: "POST",
    headers: { "Content-Type": "application/json", ...(token && { Authorization: `Bearer ${token}` }) },
    body: JSON.stringify(body),
  });
const get = (path, token) => fetch(base + path, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
const show = async (label, res) => console.log(label, res.status, JSON.stringify(await res.json()));

await show("register admin:", await post("/register", { email: "admin@shop.in", password: "correct-horse-battery" }));
await show("register user:", await post("/register", { email: "asha@shop.in", password: "monsoon-2026" }));
await show("duplicate:", await post("/register", { email: "asha@shop.in", password: "another-pass" }));
await show("wrong password:", await post("/login", { email: "asha@shop.in", password: "guess12345" }));

const { token } = await (await post("/login", { email: "asha@shop.in", password: "monsoon-2026" })).json();
console.log("token parts:", token.split(".").length, "| header:", token.split(".")[0]);
console.log("payload (readable by anyone):", JSON.parse(Buffer.from(token.split(".")[1], "base64url")).role);

await show("me without token:", await get("/me"));
await show("me with token:", await get("/me", token));
await show("admin route as customer:", await get("/admin/users", token));

const tampered = token.slice(0, -2) + (token.endsWith("A") ? "BB" : "AA");
await show("tampered token:", await get("/me", tampered));

const adminToken = (await (await post("/login", { email: "admin@shop.in", password: "correct-horse-battery" })).json()).token;
await show("admin route as admin:", await get("/admin/users", adminToken));
Terminal
node client.js
Output
register admin: 201 {"id":1,"email":"admin@shop.in","role":"admin"}
register user: 201 {"id":2,"email":"asha@shop.in","role":"customer"}
duplicate: 409 {"error":"Email already registered"}
wrong password: 401 {"error":"Invalid email or password"}
token parts: 3 | header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
payload (readable by anyone): customer
me without token: 401 {"error":"Login required"}
me with token: 200 {"id":2,"email":"asha@shop.in","role":"customer"}
admin route as customer: 403 {"error":"Not allowed"}
tampered token: 401 {"error":"Invalid or expired token"}
admin route as admin: 200 [{"id":1,"email":"admin@shop.in","role":"admin"},{"id":2,"email":"asha@shop.in","role":"customer"}]

Every rule works: duplicate emails get 409, wrong passwords 401, missing or tampered tokens 401, and a valid customer token is refused by the admin route with 403. The token's header part is the same for every token signed with HS256, and its payload is readable by anyone, which is why it holds only an id and a role.

Storing tokens on the client, and sessions#

  • Browser apps: an HttpOnly, Secure, SameSite cookie keeps the token out of JavaScript's reach (protecting it from XSS). Storing tokens in localStorage is common but exposes them to any injected script.
  • Mobile apps and other servers: send the token in the Authorization header, as above.
  • Sessions are the alternative to JWTs: the server stores session data (in memory, a database or Redis) and the browser holds only a random session id in a cookie. Sessions are easy to revoke instantly; JWTs need no server-side storage but cannot be cancelled before they expire unless you add a blocklist or short expiry with refresh tokens.

For many apps, a well-tested auth library or service (such as Auth.js, Passport, Clerk, Auth0 or your framework's built-in auth) is safer than writing everything yourself; understanding these pieces lets you use them correctly.

Common mistakes#

Practice#

Practice exercise

Using jsonwebtoken, sign a token for user 7 that expires in 2 seconds, verify it immediately, then wait 3 seconds and verify again. Print the result of each verification (or the error's name).

Show hint

jwt.sign({ sub: "7" }, secret, { expiresIn: "2s" }); verify inside try/catch and print err.name on failure. Wait with await new Promise((r) => setTimeout(r, 3000)).

Show solution
expiry.js (JavaScript)
import jwt from "jsonwebtoken";

const secret = "practice-secret";
const token = jwt.sign({ sub: "7" }, secret, { expiresIn: "2s" });

function check() {
  try {
    return `valid for user ${jwt.verify(token, secret).sub}`;
  } catch (err) {
    return err.name;
  }
}

console.log("Now:", check());
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("After 3 s:", check());
Terminal
node expiry.js
Output
Now: valid for user 7
After 3 s: TokenExpiredError

Key takeaway#

Next: Securing an Express API.

Finished this lesson?