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
bcryptand 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
adminrole - 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
| Method | Endpoint | Auth | Request | Success Response |
|---|---|---|---|---|
| POST | /auth/register | Public | Body: name, email, password | 201 — user object without password |
| POST | /auth/login | Public | Body: email, password | 200 — token plus user object |
| GET | /auth/me | Private | Header: Authorization: Bearer TOKEN | 200 — current user's profile |
| GET | /admin/users | Private admin | Header: Authorization: Bearer TOKEN | 200 — array of all users |
Edge Cases to Handle
| Scenario | Expected result |
|---|---|
| Register with an email that already exists | 400 — "An account with this email already exists" |
| Register with a password shorter than 8 chars | 400 — validation message |
| Login with an unknown email | 401 — "Invalid email or password" |
| Login with a wrong password | 401 — the exact same message as unknown email |
Protected route with no Authorization header | 401 — "Access denied. No token provided." |
| Protected route with an expired or tampered token | 401 — "Invalid or expired token." |
| Admin route with a valid non-admin token | 403 — permission message |
Register body containing a role field | Field 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.
createUsercallsbcrypt.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. toPublicstrips the hash (rest-destructure thepassword_hashfield 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:
- Validate the shape with
express-validatorrules: 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 tinyvalidatemiddleware turnsvalidationResultinto your400error shape; the validation docs cover the library. - Destructure only
name,email,passwordfrom the body — extra fields likerolenever reach the store. - Check for a duplicate email with
findByEmailand return400if it exists. - Create the user — the store hashes the password on the way in.
- Return
201withtoPublic(user)— never the hash.
Step 4 — Login
POST /auth/login follows one rule above all: the response never reveals which half failed.
- Validate that
emailandpasswordare present. - Look the user up with
findByEmail. - Run
bcrypt.compare(password, user.password_hash)— a single boolean covering "user exists AND password matches". - If either check fails, return the same
401with "Invalid email or password". Distinct messages would let an attacker enumerate which emails have accounts. - On success, sign a JWT containing only
idandrole, withexpiresInfrom the environment, and return it alongsidetoPublic(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:
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/registerandPOST /auth/loginrun their validation rules, thenvalidate, then the controller.GET /auth/merunsauth, then a handler that loadsfindById(req.user.id)—404if the user has since disappeared — and returnstoPublic(user).GET /admin/userscomposesauth, requireRole("admin")and returnslistUsers(). 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
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"}'
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")
curl -s http://localhost:3000/auth/me -H "Authorization: Bearer $TOKEN"
curl -s http://localhost:3000/admin/users -H "Authorization: Bearer $TOKEN"
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
toPublicguarantees 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
- Mongoose user model — replace the in-memory store with MongoDB, using a unique index on
emailand handling the duplicate-key error - Refresh tokens — a 15-minute access token plus a
POST /auth/refreshendpoint backed by a stored, revocable refresh token - Login rate limiting — lock down
/auth/loginto a handful of attempts per IP per minute (Problem 5 shows you how to build the limiter) - Password change endpoint —
PATCH /auth/passwordthat requires the current password before accepting the new one