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.
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 onres.locals.
The code
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: 401Run 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.