Web APIs
Mini Project: Build an Auth API
You build a working authentication layer — registration, login, and JWT-protected routes — on top of the Members API from Lesson 31.
Every production API eventually asks the same question: who is making this request, and are they allowed to? An open API that returns data to anyone who calls it is fine for a public dataset. The moment that data belongs to a user, a team, or a paying customer — you need auth.
This project extends the APIForge Members API with a full authentication layer. You will build a registration endpoint that stores hashed passwords, a login endpoint that issues a signed JSON Web Token, and a middleware function that blocks unauthenticated requests before they reach any protected route. When you finish, only callers with a valid token can read or modify member data.
index.js from Lesson 31. If you do not have that file, follow Steps 1–5 of Lesson 31 first, then return here. The auth layer slots in on top — you do not rewrite the CRUD routes, you protect them.Project Brief
The APIForge Security team reviewed the Members API and flagged it as unauthenticated — any caller with the URL can read, create, and delete records. Product has approved two sprints to add auth before the API goes beyond the internal network.
The chosen approach is JWT Bearer authentication. A user registers once, logs in to receive a token, then includes that token in every subsequent request. The server validates the token on each call without touching any session store or database.
bcryptjs for password hashing. jsonwebtoken for signing and verifying tokens.How JWT Auth Works — Before You Write Any Code
A JSON Web Token is a compact string divided into three base64-encoded sections separated by dots. The first section is the header — it names the signing algorithm. The second is the payload — it carries claims, which are just key-value pairs like a user id or email. The third is the signature — a cryptographic hash of the first two sections made using a secret key only the server knows.
When the server receives a token on a subsequent request, it re-computes the signature using the same secret. If the recomputed signature matches the one on the token, the token is valid and untampered. No database lookup needed — the token is self-verifying.
Storing passwords as plain text is catastrophic — if your user table leaks, every password is immediately readable. A fast hash like MD5 or SHA-256 is barely better: attackers can precompute billions of hashes per second on consumer GPUs.
bcrypt is deliberately slow and adds a random salt to each hash. That salt means two users with identical passwords produce completely different hash strings. And the cost factor — a tunable number — lets you make hashing slower as hardware gets faster, keeping brute-force attacks impractical year after year.
Step 1 — Install New Dependencies
bcryptjs is a pure JavaScript implementation of the bcrypt algorithm — no native binaries, so it installs cleanly on every platform. jsonwebtoken is the standard Node.js library for creating and verifying JWTs.
You also need a JWT secret — a long, random string that acts as the signing key. In a real deployment this lives in an environment variable, never hardcoded. For this prototype you will define it as a constant at the top of the file.
# WHAT: Install auth packages into the APIForge Members API project
npm install bcryptjs jsonwebtokennpm added both packages to node_modules and recorded them in package.json. Anyone who clones this project later just runs npm install and gets the exact same versions.
Try this: open node_modules/jsonwebtoken/README.md and skim the sign() and verify() method signatures — those are the only two functions this project uses.
Step 2 — User Store and Auth Setup at the Top of index.js
Add these lines to the very top of index.js, right after the existing const express = require('express') line. The users array is a second in-memory store — separate from members — that holds registered account credentials.
The JWT secret here is a placeholder. In any real project, load it from process.env.JWT_SECRET and set it in a .env file that is never committed to source control. A leaked secret means every token ever signed with it is now forgeable.
// WHAT: Add auth dependencies and user store to the top of index.js
// Add these lines directly after: const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const JWT_SECRET = 'apiforge-dev-secret-change-in-production';
const JWT_EXPIRES_IN = '2h'; // Token lifetime — 2 hours
// In-memory user store — separate from the members data
let users = [];Three new variables now sit at module scope — visible to every function in the file. JWT_SECRET is used during both signing (login) and verification (middleware). JWT_EXPIRES_IN controls how long a token stays valid before the server rejects it — two hours is a reasonable default for an internal tool.
Try this: intentionally misspell require('bcryptjs') as require('bcrypt') and observe the MODULE_NOT_FOUND crash. Then fix it — understanding that error message saves time later.
Step 3 — POST /auth/register
Registration accepts an email and password. It checks the email is not already taken — duplicate accounts cause confusion and potential security issues. Then it hashes the password with bcrypt before storing anything. The plain-text password is never saved anywhere.
The salt rounds value of 10 is passed to bcrypt.hash(). This tells bcrypt to run the hashing function 210 = 1,024 times internally, making each hash operation take around 100ms on typical hardware. That delay is imperceptible to a human but makes automated brute-force guessing prohibitively slow.
// WHAT: Registration endpoint — hash password and store user account
// Add this before app.listen() in index.js
app.post('/auth/register', async (req, res) => {
const { email, password } = req.body;
// Validate input
if (!email || !password) {
return res.status(400).json({
success: false,
error: 'email and password are required'
});
}
// Check for duplicate email
const existing = users.find(u => u.email === email);
if (existing) {
return res.status(409).json({
success: false,
error: 'An account with that email already exists'
});
}
// Hash the password — never store plain text
const passwordHash = await bcrypt.hash(password, 10);
const newUser = {
id: users.length + 1,
email,
passwordHash
};
users.push(newUser);
res.status(201).json({
success: true,
message: 'Account created',
data: { id: newUser.id, email: newUser.email }
});
});The response on success deliberately omits passwordHash. Even though the hash is not the plain-text password, there is no reason to expose it. The less credential data that crosses the wire, the smaller the attack surface.
The duplicate check uses 409 Conflict — the correct status for a request that cannot be completed because the resource already exists in its current state. A 400 would be technically wrong here: the request is well-formed, it just conflicts with existing data.
Try this: console.log the passwordHash value after it is generated and compare two hashes of the same password — they will be different strings because bcrypt generates a fresh salt each time.
Step 4 — POST /auth/login
Login looks up the user by email, then calls bcrypt.compare() to check the submitted password against the stored hash. If both checks pass, jwt.sign() creates a signed token containing the user's id and email.
One important design decision: the error message for both a wrong email and a wrong password is identical — "Invalid credentials". This is intentional. Separate messages like "no account found" vs "wrong password" let attackers enumerate valid email addresses. A uniform message gives away nothing.
// WHAT: Login endpoint — verify credentials and return a signed JWT
// Add this after the register route in index.js
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({
success: false,
error: 'email and password are required'
});
}
// Find user by email
const user = users.find(u => u.email === email);
if (!user) {
return res.status(401).json({
success: false,
error: 'Invalid credentials'
});
}
// Compare submitted password against stored hash
const passwordMatch = await bcrypt.compare(password, user.passwordHash);
if (!passwordMatch) {
return res.status(401).json({
success: false,
error: 'Invalid credentials'
});
}
// Sign a JWT with user id and email as the payload
const token = jwt.sign(
{ userId: user.id, email: user.email },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
res.status(200).json({
success: true,
token,
expiresIn: JWT_EXPIRES_IN
});
});That long dot-separated string is a real JWT. Paste it into jwt.io and you will see the decoded payload — userId, email, iat (issued at), and exp (expiry timestamp) — all in plain text. This is why you never put passwords or secrets inside a JWT payload: the payload is encoded, not encrypted.
Try this: copy the token from a successful login response, decode the middle section with atob() in your browser console, and read the payload. Then try logging in with a non-existent email and confirm the error message is identical to a wrong-password attempt.
Step 5 — Auth Middleware and Protected Routes
Middleware in Express is just a function with three parameters: req, res, and next. Call next() and the request continues to the route handler. Return a response without calling next() and the request stops there.
The convention for JWTs is the Authorization: Bearer <token> header. The middleware reads that header, strips the "Bearer " prefix, and calls jwt.verify(). If verification passes, the decoded payload is attached to req.user so downstream handlers can read the caller's identity without re-parsing the token themselves.
// WHAT: Auth middleware function + apply it to all /api/members routes
// Add the middleware function before the GET /api/members route definition
function authenticate(req, res, next) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
error: 'Authorization header missing or malformed'
});
}
const token = authHeader.split(' ')[1]; // Strip "Bearer " prefix
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded; // Attach decoded payload for route handlers
next();
} catch (err) {
return res.status(401).json({
success: false,
error: err.name === 'TokenExpiredError'
? 'Token has expired — please log in again'
: 'Invalid token'
});
}
}
// Apply middleware to all /api/members routes
// Replace your existing route definitions with these protected versions:
app.get('/api/members', authenticate, (req, res) => { /* same handler as before */ });
app.get('/api/members/:id', authenticate, (req, res) => { /* same handler as before */ });
app.post('/api/members', authenticate, (req, res) => { /* same handler as before */ });
app.put('/api/members/:id', authenticate, (req, res) => { /* same handler as before */ });
app.delete('/api/members/:id', authenticate, (req, res) => { /* same handler as before */ });Passing authenticate as the second argument to each route definition means Express calls it before the route handler. The route handler only runs if next() was called — otherwise the 401 short-circuits the entire request.
jwt.verify() throws if the token is expired, malformed, or signed with a different secret. The try/catch handles all three cases and returns a helpful message for the expiry case — that tells the client exactly what to do next (log in again) rather than leaving them guessing.
Try this: test the full flow end to end: register → login → copy the token → call GET /api/members with the Authorization: Bearer <token> header set. Then try calling without the header and confirm the 401 fires.
Before and After: Open vs Protected
Five middleware calls is all it took to transform an open API into one that enforces identity. The CRUD logic itself did not change — only what is allowed to reach it did.
Any caller with the URL can list all members, add fake records, and delete real ones. A curious developer who finds the endpoint URL in a browser network tab has full read-write access.
There is no audit trail. You cannot tell which caller made a change, when, or why.
Only registered users with a valid token can call any member endpoint. The token payload includes userId and email — you could log every mutation with the caller's identity attached.
Tokens expire. A stolen token stops working in two hours without any revocation logic needed.
Final Route Map
| Method | Path | Auth required? | Purpose |
|---|---|---|---|
POST |
/auth/register |
No | Create a new user account |
POST |
/auth/login |
No | Verify credentials, receive JWT |
GET |
/api/members |
Yes — Bearer token | List all members |
GET |
/api/members/:id |
Yes — Bearer token | Fetch one member |
POST |
/api/members |
Yes — Bearer token | Create a member |
PUT |
/api/members/:id |
Yes — Bearer token | Replace a member record |
DELETE |
/api/members/:id |
Yes — Bearer token | Delete a member |
Try adding a GET /auth/me route that reads req.user — set by the middleware — and returns the current user's profile. This pattern is how GitHub's /user endpoint works.
Try adding a role field to users (values: "admin" or "viewer"), include it in the JWT payload, and block POST/PUT/DELETE for "viewer" accounts with a 403 Forbidden. That is the jump from authentication — who are you? — to authorization — what are you allowed to do?
Try moving JWT_SECRET to a .env file and reading it via process.env.JWT_SECRET using the dotenv package. Then add .env to .gitignore. That single habit prevents a category of security incidents that hit real companies every year.
Quiz
1. An APIForge developer wants to include a user's hashed password in the JWT payload so the login endpoint doesn't need to query the user store on each request. Why is this a bad idea?
2. A user calls POST /auth/register with an email that already exists in the APIForge user store. Which HTTP status code and reason best describe the correct response?
3. The authenticate middleware function in the APIForge API accepts req, res, and next as parameters. What are the two possible outcomes when this middleware runs?