WEB API's Lesson 32 – Mini Project - Auth API | Dataplexa
Web APIs · Lesson 32

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.

Prerequisite
This lesson builds directly on the 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.

What you add
POST /auth/register, POST /auth/login, and an auth middleware that guards every /api/members route.
New packages
bcryptjs for password hashing. jsonwebtoken for signing and verifying tokens.
Skills applied
Authentication Basics (L17), JWT Authentication (L21), API Keys (L19), and Error Handling (L16).
Time estimate
40–60 minutes. Shorter if Lesson 31 is already running. Longer if you add role-based access at the end.

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.

Auth flow — request by request
POST /auth/register
POST /auth/login
Receive JWT
Send JWT in Authorization header
Middleware verifies → route runs
Why bcrypt for passwords?

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

1
Add bcryptjs and jsonwebtoken to the project

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 jsonwebtoken
added 8 packages, and audited 74 packages in 3s found 0 vulnerabilities # package.json dependencies now includes: { "dependencies": { "bcryptjs": "^2.4.3", "express": "^4.18.2", "jsonwebtoken": "^9.0.2" } }
What just happened?

npm 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

2
Add imports, the JWT secret, and the users array

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 = [];
# No visible output — these are module-level declarations. # Restart node index.js after saving to confirm no syntax errors: $ node index.js APIForge Members API running on http://localhost:3000
What just happened?

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

3
Build the registration endpoint

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 }
  });
});
# POST /auth/register body: { "email": "dev@apiforge.dev", "password": "S3cur3Pass!" } HTTP/1.1 201 Created Content-Type: application/json { "success": true, "message": "Account created", "data": { "id": 1, "email": "dev@apiforge.dev" } } # POST /auth/register again with the same email HTTP/1.1 409 Conflict Content-Type: application/json { "success": false, "error": "An account with that email already exists" }
What just happened?

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

4
Build the login endpoint — verify credentials and issue a JWT

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
  });
});
# POST /auth/login body: { "email": "dev@apiforge.dev", "password": "S3cur3Pass!" } HTTP/1.1 200 OK Content-Type: application/json { "success": true, "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImVtYWlsIjoiZGV2QGFwaWZvcmdlLmRldiIsImlhdCI6MTcxNTAwMDAwMCwiZXhwIjoxNzE1MDA3MjAwfQ.Xv8kN2qPzRtLmYdCwHsJeA4bFnTgUoIpKcVrQyZlMxE", "expiresIn": "2h" } # POST /auth/login body: { "email": "dev@apiforge.dev", "password": "wrongpassword" } HTTP/1.1 401 Unauthorized Content-Type: application/json { "success": false, "error": "Invalid credentials" }
What just happened?

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

5
Write the auth middleware and apply it to every /api/members route

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 */ });
# GET /api/members — no Authorization header HTTP/1.1 401 Unauthorized Content-Type: application/json { "success": false, "error": "Authorization header missing or malformed" } # GET /api/members — with valid token Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... HTTP/1.1 200 OK Content-Type: application/json { "success": true, "count": 5, "data": [ ... ] } # GET /api/members — with an expired token HTTP/1.1 401 Unauthorized Content-Type: application/json { "success": false, "error": "Token has expired — please log in again" }
What just happened?

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.

Without auth

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.

With auth

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
Extension challenges

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?

Up Next
Mini Project: API Gateway
The APIForge DevOps team builds a lightweight gateway that routes requests, enforces rate limits, and aggregates responses from multiple services.