Skip to main content

Authentication API

Difficulty: Intermediate · Builds on: routing, middleware, JWT, express-validator

You are tasked with building a standalone authentication service. Users register with an email and password, log in to receive a JWT, and use that token to access a protected profile route. An admin-only route demonstrates role-based access control. This is the auth core you would later bolt onto any API — get it right once here, and you can reuse the pattern everywhere.

To keep the focus purely on authentication mechanics, the reference solution stores users in memory. Swapping the store for Mongoose is one of the stretch goals.

What You'll Practice

  • Hashing passwords with bcrypt and choosing a sensible cost factor
  • Signing and verifying JWTs with jsonwebtoken, including expiry
  • Writing an auth middleware and composing it with a role-check middleware
  • Validating request bodies with express-validator
  • Returning consistent status codes and error shapes

Requirements

  • Registration hashes the password before storing — plain text passwords must never exist at rest
  • Registering an email that already exists returns 400
  • Login returns a signed JWT that expires (1 hour in the reference solution)
  • Login failures return one generic message whether the email is unknown or the password is wrong
  • The profile route requires a valid token and never returns the password hash
  • The admin route requires a valid token and the admin role
  • Clients can never choose their own role — the role field in a register body must be ignored
  • All input is validated before it reaches the controller

API Contract

MethodEndpointAuthRequestSuccess Response
POST/auth/registerPublicBody: name, email, password201 — user object without password
POST/auth/loginPublicBody: email, password200token plus user object
GET/auth/mePrivateHeader: Authorization: Bearer TOKEN200 — current user's profile
GET/admin/usersPrivate adminHeader: Authorization: Bearer TOKEN200 — array of all users

Edge Cases to Handle

ScenarioExpected result
Register with an email that already exists400 — "An account with this email already exists"
Register with a password shorter than 8 chars400 — validation message
Login with an unknown email401 — "Invalid email or password"
Login with a wrong password401 — the exact same message as unknown email
Protected route with no Authorization header401 — "Access denied. No token provided."
Protected route with an expired or tampered token401 — "Invalid or expired token."
Admin route with a valid non-admin token403 — permission message
Register body containing a role fieldField is ignored; user is created with the user role

Build Guide

Step 1 — Project setup

Nothing new here: npm install express@4 bcrypt jsonwebtoken express-validator dotenv, "type": "module" in package.json (the reference middleware uses import/export), an express.json() app with /auth and /admin routers, a 404 catch-all, and a central error handler — the same skeleton as the error handling doc. Put PORT, JWT_SECRET, JWT_EXPIRES_IN=1h, and the seeded admin's credentials in .env: ADMIN_EMAIL=rizwan@example.com and ADMIN_PASSWORD=SuperSecret123! (the values the curl section below logs in with). The secret must be long and random — generate one with node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" instead of typing one.

Step 2 — The user store

An in-memory Map keyed by lowercased email, exposing findByEmail, findById, createUser, toPublic, listUsers, and seedAdmin. Two design decisions do the security work:

  • The store owns hashing. createUser calls bcrypt.hash(password, 12) itself, so no controller can accidentally save a plain password. A cost of 12 is a good 2026 default — see the Security Notes for how to tune it.
  • toPublic strips the hash (rest-destructure the password_hash field away) and is the only shape any endpoint ever returns.

createUser defaults role to user, and no controller ever passes a role from a request body — that is what makes the "client picks its own role" attack impossible. seedAdmin runs once at startup and registers the single admin account from ADMIN_EMAIL and ADMIN_PASSWORD in .env.

Step 3 — Register

POST /auth/register is yours to write. The sequence:

  1. Validate the shape with express-validator rules: name 2–60 characters, a valid normalized email, password 8–72 characters. The max of 72 is not arbitrary — bcrypt only uses the first 72 bytes, so anything longer would silently truncate. A tiny validate middleware turns validationResult into your 400 error shape; the validation docs cover the library.
  2. Destructure only name, email, password from the body — extra fields like role never reach the store.
  3. Check for a duplicate email with findByEmail and return 400 if it exists.
  4. Create the user — the store hashes the password on the way in.
  5. Return 201 with toPublic(user) — never the hash.

Step 4 — Login

POST /auth/login follows one rule above all: the response never reveals which half failed.

  1. Validate that email and password are present.
  2. Look the user up with findByEmail.
  3. Run bcrypt.compare(password, user.password_hash) — a single boolean covering "user exists AND password matches".
  4. If either check fails, return the same 401 with "Invalid email or password". Distinct messages would let an attacker enumerate which emails have accounts.
  5. On success, sign a JWT containing only id and role, with expiresIn from the environment, and return it alongside toPublic(user). Keep the payload small — a JWT is signed, not encrypted, so anyone holding the token can read it. Signing and verifying are covered in the JWT doc.

Step 5 — Auth and role middlewares

This pair is the genuinely new pattern, so here is the reference:

middlewares/auth.js
import jwt from "jsonwebtoken";

export const auth = (req, res, next) => {
const header = req.headers.authorization || "";
const token = header.startsWith("Bearer ") ? header.slice(7) : null;

if (!token) {
return res.status(401).json({ status: 401, response: "Unauthorized", message: "Access denied. No token provided." });
}

try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
return res.status(401).json({ status: 401, response: "Unauthorized", message: "Invalid or expired token." });
}
};

export const requireRole =
(...roles) =>
(req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ status: 403, response: "Forbidden", message: "You do not have permission to access this resource." });
}
next();
};

auth reads the Bearer header, verifies the token, and attaches the decoded payload to req.user. requireRole is a middleware factory composed after auth, so req.user is guaranteed to exist by the time it runs.

Step 6 — Routes

Wiring is the routing you already know:

  • POST /auth/register and POST /auth/login run their validation rules, then validate, then the controller.
  • GET /auth/me runs auth, then a handler that loads findById(req.user.id)404 if the user has since disappeared — and returns toPublic(user).
  • GET /admin/users composes auth, requireRole("admin") and returns listUsers(). That composition is the whole point: authentication and authorization stay two separate, reusable pieces.

Finally, app.js loads dotenv/config, mounts the two routers, awaits seedAdmin(), and listens.

Test It with curl

Register Hafsa
curl -s -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"name":"Hafsa Ashiq","email":"hafsa@example.com","password":"correct-horse-9"}'
Login and grab the token
TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"hafsa@example.com","password":"correct-horse-9"}' \
| node -pe "JSON.parse(require('fs').readFileSync(0)).data.token")
Fetch the protected profile
curl -s http://localhost:3000/auth/me -H "Authorization: Bearer $TOKEN"
Hafsa is not an admin — expect 403
curl -s http://localhost:3000/admin/users -H "Authorization: Bearer $TOKEN"
Login as the seeded admin, then list users — expect 200
ADMIN_TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"rizwan@example.com","password":"SuperSecret123!"}' \
| node -pe "JSON.parse(require('fs').readFileSync(0)).data.token")

curl -s http://localhost:3000/admin/users -H "Authorization: Bearer $ADMIN_TOKEN"

Also verify the failure paths: wrong password returns 401 with the same message as an unknown email, no header returns 401, and a register body with "role": "admin" still creates a plain user.

Security Notes

  • Never store plain passwords. Only the bcrypt hash is stored, and toPublic guarantees the hash never leaves the server.
  • Cost factor. 12 salt rounds is a good 2026 default — high enough to hurt offline cracking, low enough that login stays fast. Benchmark on your hardware and raise it when logins take well under 100 ms.
  • Generic login errors. Returning "email not found" versus "wrong password" lets an attacker enumerate which emails have accounts. One message for both closes that hole.
  • Token expiry. A stolen token without an expiry is a permanent key. Keep access tokens short-lived and add refresh tokens when sessions need to outlive them.
  • Keep the payload small. Sign only what authorization needs (id, role). A JWT payload is readable by anyone who holds the token — it is signed, not encrypted.

Stretch Goals

  1. Mongoose user model — replace the in-memory store with MongoDB, using a unique index on email and handling the duplicate-key error
  2. Refresh tokens — a 15-minute access token plus a POST /auth/refresh endpoint backed by a stored, revocable refresh token
  3. Login rate limiting — lock down /auth/login to a handful of attempts per IP per minute (Problem 5 shows you how to build the limiter)
  4. Password change endpointPATCH /auth/password that requires the current password before accepting the new one