Skip to main content
Backend & APIs
⚙️ Backend & APIsLesson 7 of 13

Authentication at the API Layer

Protect endpoints with tokens and sessions — who's calling, and are they allowed?

Authentication at the API Layer

Stage 3 · Backend & APIs · B.U.I.L.D. letter: L

Every unprotected endpoint is an open door — and your users have no idea you left it unlocked.


⚠️ The vibe trap

You hid the "Delete Account" button in the UI when the user isn't logged in. Feels secure. It is not. The button was never the door — the API route was. Anyone with a REST client, curl, or even a browser's DevTools can fire DELETE /users/42 directly against your server and bypass every pixel of your front end. The second trap is trusting a userId that arrives in the request body: a caller can write any number they want in there. Your server must derive identity from a cryptographically verified token, never from something the caller sent as plain data.


🔑 The Authorization Header and Bearer Tokens

When a user logs in, your server hands them a token — usually a JWT (JSON Web Token) or an opaque random string stored in your DB. Every subsequent request that needs authentication must include that token in an HTTP header:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

The word Bearer is a scheme name that tells your server what kind of credential follows. Your middleware reads this header, splits off the token string, and verifies it.

// utils/extractToken.js
function extractBearerToken(req) {
  const header = req.headers['authorization'] ?? '';
  if (!header.startsWith('Bearer ')) return null;
  return header.slice(7); // everything after "Bearer "
}

module.exports = { extractBearerToken };

Mental model: The Authorization header is the physical badge you hold up at the door. Bearer means "whoever holds this token is claiming this identity." Your job is to verify the badge is real — not just check that a badge exists.

Why it matters: Cookies are scoped to a browser and a domain. Bearer tokens travel with every client type — mobile apps, CLI tools, third-party integrations — making them the universal credential for APIs.

Common mistake: Reading req.body.token or req.query.token instead of the Authorization header. Body and query parameters are logged everywhere, show up in browser history, and get replayed in ways you don't control.


🛡️ Auth Middleware That Runs Before Protected Routes

Middleware is a function that sits between the incoming request and your route handler. It can inspect, reject, or augment the request before your business logic ever runs. Authentication belongs here — not scattered across every individual route.

// middleware/authenticate.js
const jwt = require('jsonwebtoken');
const { extractBearerToken } = require('../utils/extractToken');

function authenticate(req, res, next) {
  const token = extractBearerToken(req);

  if (!token) {
    return res.status(401).json({ error: 'No token provided. Please log in.' });
  }

  try {
    // jwt.verify throws if the token is expired, tampered with, or signed
    // with a different secret. Never skip this step.
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    req.user = { id: payload.sub, role: payload.role };
    next(); // token is valid — pass control to the next handler
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token.' });
  }
}

module.exports = { authenticate };

Apply it to a group of routes with Express's router:

// routes/posts.js
const express = require('express');
const router = express.Router();
const { authenticate } = require('../middleware/authenticate');
const { getPosts, createPost, deletePost } = require('../controllers/posts');

router.get('/', getPosts);                         // public — no auth needed
router.post('/', authenticate, createPost);        // must be logged in
router.delete('/:id', authenticate, deletePost);   // must be logged in + ownership check inside

Mental model: Think of middleware as a bouncer line. Public routes skip the line. Protected routes must clear the bouncer before they reach the venue. One bouncer, consistent rules.

Why it matters: If authentication logic lives in each route handler, you will eventually forget it on one. Middleware makes "this route requires auth" a one-word declaration.

Common mistake: Putting authenticate in app.use() globally and then having to work around it for public routes. Explicit per-route (or per-router) application is cleaner and easier to audit.


🔢 401 vs 403: Two Different Problems

These status codes are often confused. Getting them right helps API consumers understand exactly what went wrong and how to fix it.

CodeNameMeaningFix for the caller
401UnauthorizedThe server does not know who you areSend a valid token
403ForbiddenThe server knows who you are but you can't do thisLog in as someone with permission
// middleware/authorize.js
// A factory that returns a middleware checking for a required role.
function requireRole(role) {
  return function (req, res, next) {
    // authenticate() must have already run and set req.user
    if (!req.user) {
      return res.status(401).json({ error: 'Not authenticated.' });
    }
    if (req.user.role !== role) {
      return res.status(403).json({ error: `Requires role: ${role}` });
    }
    next();
  };
}

module.exports = { requireRole };

Usage in a route:

const { authenticate } = require('../middleware/authenticate');
const { requireRole } = require('../middleware/authorize');

// Only authenticated admins can reach this handler
router.delete('/admin/users/:id', authenticate, requireRole('admin'), deleteUser);

Mental model: 401 means "Show me your badge." 403 means "I see your badge, but you're not on the list for this room."

Why it matters: Returning 403 when the user isn't logged in at all leaks the fact that the resource exists. A 401 tells them to authenticate first without revealing anything about what's behind the door.

Common mistake: Returning 400 Bad Request for auth failures. 400 means malformed input. Auth failures are 401/403. Correct codes let clients handle errors programmatically.


🔒 Ownership Checks: Stopping IDOR Cold

IDOR stands for Insecure Direct Object Reference. It is one of the most common API vulnerabilities: a user who is correctly authenticated asks for a resource that belongs to someone else, using only that resource's ID.

GET /posts/99   — user A is logged in; post 99 belongs to user B

Your server authenticated user A. But did it check that post 99 is theirs to read or edit?

// controllers/posts.js
const { pool } = require('../db');

async function deletePost(req, res) {
  const postId = req.params.id;          // from the URL — safe, you control this
  const userId = req.user.id;            // from the verified token — NEVER req.body.userId

  const { rows } = await pool.query(
    'SELECT owner_id FROM posts WHERE id = $1',
    [postId]
  );

  if (rows.length === 0) {
    return res.status(404).json({ error: 'Post not found.' });
  }

  // Ownership check — the authenticated user must own this resource
  if (rows[0].owner_id !== userId) {
    return res.status(403).json({ error: 'You do not own this post.' });
  }

  await pool.query('DELETE FROM posts WHERE id = $1', [postId]);
  return res.status(204).send();
}

module.exports = { deletePost };

Mental model: Authentication answers "who are you?" Ownership checks answer "is this yours?" Both questions must be answered before any write operation on a user-owned resource. If you only authenticate, you've checked the badge but not the locker number.

Why it matters: An attacker who has a valid account can enumerate resource IDs (1, 2, 3…) and operate on other users' data. Authentication alone does not stop this. The ownership check is the second lock.

Common mistake: Trusting req.body.userId or req.query.userId as the identity for the ownership check. Those values came from the caller. Only req.user.id (derived from jwt.verify) is trustworthy.


🧩 Putting It Together: The Full Protected Route Flow

Here is the complete picture for a "edit my post" endpoint:

// routes/posts.js (complete protected route)
const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const { pool } = require('../db');

// Step 1: Auth middleware — who are you?
function authenticate(req, res, next) {
  const header = req.headers['authorization'] ?? '';
  if (!header.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token.' });
  }
  try {
    const payload = jwt.verify(header.slice(7), process.env.JWT_SECRET);
    req.user = { id: payload.sub, role: payload.role };
    next();
  } catch {
    return res.status(401).json({ error: 'Invalid token.' });
  }
}

// Step 2: Route handler — are you allowed to do this specific thing?
router.patch('/:id', authenticate, async (req, res) => {
  const postId = req.params.id;
  const userId = req.user.id;  // ← verified, not from body

  const { rows } = await pool.query(
    'SELECT owner_id FROM posts WHERE id = $1',
    [postId]
  );
  if (rows.length === 0) return res.status(404).json({ error: 'Not found.' });
  if (rows[0].owner_id !== userId) return res.status(403).json({ error: 'Forbidden.' });

  const { title, body } = req.body;
  await pool.query(
    'UPDATE posts SET title = $1, body = $2 WHERE id = $3',
    [title, body, postId]
  );
  return res.status(200).json({ message: 'Post updated.' });
});

module.exports = router;

Mental model: Every protected endpoint independently answers two questions, in order: (1) Who are you? — authenticate via the Authorization header. (2) Are you allowed? — check ownership or role before touching data. If either question fails, stop immediately.

Why it matters: Security is not a single gate at login time. Every individual operation on protected data must independently re-verify both identity and permission. Tokens can be stolen and re-used; the ownership check is your last line of defence at the data layer.

Common mistake: Assuming that because a user passed auth middleware, they are allowed to do whatever the route does. Middleware proves identity. It does not grant permission to every resource in your database.


🛠️ Your Mission

Take a protected API route in your project (or scaffold a fresh Express router with a PATCH /posts/:id endpoint) and add the following:

  1. Write an authenticate middleware in its own file (middleware/authenticate.js) that reads the Authorization header, verifies a JWT, and attaches { id, role } to req.user. Return 401 with a clear message if the token is missing or invalid.
  2. Apply that middleware to all write routes (POST, PATCH, PUT, DELETE) while leaving read routes public.
  3. Inside the handler for your update or delete route, fetch the resource from the database and compare its owner_id to req.user.id. Return 403 if they do not match.
  4. Verify manually: send a request with no token (expect 401), a valid token for the wrong user (expect 403), and a valid token for the owner (expect 200/204).

✅ You're done when…

  • authenticate middleware is extracted into its own file and re-used across all protected routes — no copy-pasted auth logic inside individual handlers (Security Audit Checklist: "Auth logic is centralized, not scattered").
  • Every write route (POST, PATCH, PUT, DELETE) on a user-owned resource performs an explicit ownership check comparing the DB owner_id to req.user.id — not to any value from req.body or req.query (Security Audit Checklist: "No IDOR — ownership verified at the DB layer on every mutation").
  • Your API returns 401 for missing/invalid tokens and 403 for authenticated-but-not-permitted requests — never confusing the two (Security Audit Checklist: "Correct 4xx semantics: 401 = not authenticated, 403 = not authorized").

➡️ Next: Pagination, Filtering & Sorting. Build It Right, Or Don't Build It At All. 🏛️

Always-on rigor toolkit

🏛️ Build It Right, Or Don't Build It At All.