0 / 91
Week 8 · Day 52 of 91

Authorization and ownership

Backend Architecture, Authentication, and Security

Objective

Enforce permissions on the server for every protected action.

Authentication identifies the operator; authorization checks which controls that operator is permitted to activate.

  • roles and permissions
  • resource ownership
  • deny by default

Why this matters

Yesterday your API learned who is calling. Today it learns what that caller may do — and this is the step most often skipped, because when the frontend hides the delete button the app looks secure. It is not. Anyone can send the request the button would have sent.

By the end of today, every protected action on your server checks permission before it acts, you have a written permission matrix, and you have evidence for both the allowed and the denied case. Broken access control is consistently the single most common serious flaw in real web applications, and it is entirely preventable by the code you write in the next hour.

Authorization is a separate decision from authentication

Authentication established identity: this request comes from Ana, user 7. Authorization asks a different question on every single protected request: may user 7 do this particular thing to this particular resource?

They fail differently, and HTTP has two status codes for exactly this:

Status Meaning When
401 Unauthorized "I don't know who you are." No session cookie, or an expired one.
403 Forbidden "I know who you are, and no." Valid session, insufficient permission.

(401 is historically misnamed — it means unauthenticated. Learn the pair; the names will not be fixed.)

The badge and the key switch, wired separately

Authentication identifies the operator: the badge reader confirms this is Ana from maintenance. Authorization checks which controls that operator may activate: the badge opens the panel, but the high-voltage disconnect is behind a supervisor key switch. Two interlocks in series, each proving a different thing. A design where the badge reader closes every contactor is one lost badge away from disaster — and hiding a switch behind a cover plate, with the contactor still live, is the hardware version of hiding a button in the UI.

Roles and permissions

A role is a named group of users: admin, technician. A permission is a named action: equipment:delete, maintenance:edit. Roles exist so you can say "admins may delete equipment" once instead of listing users.

Write the mapping down before you write code. This is a permission matrix, and it is the real deliverable of the day — the code is a transcription of it.

Action admin technician anonymous
List equipment yes yes no
Create equipment yes yes no
Delete equipment yes no no
Create maintenance record yes yes no
Edit own maintenance record yes yes (own only) no
Edit anyone's maintenance record yes no no

Two things the matrix makes visible that prose hides. Every row has an answer for every role — no blanks, no "probably fine". And one cell says own only, which is a different kind of rule entirely.

Ownership: when the role is not enough

Some rules do not depend on who you are in general but on your relationship to one specific row. A technician may edit the maintenance record they wrote, and not their colleague's.

This is resource ownership, and it cannot be decided by a middleware that only sees the role. It requires loading the resource and comparing:

// in the service, where rules live
const record = await repo.findById(recordId);
if (!record) throw new AppError('not_found', 'maintenance record not found');
if (actor.role !== 'admin' && record.created_by !== actor.id) {
  throw new AppError('forbidden', 'you may only edit your own records');
}

Notice the order: load, then check, then act. A common bug is checking the ID in the URL against the session, without loading the row — which proves nothing about the row's actual owner.

The most exploited bug in web applications

An endpoint like GET /maintenance/41 that returns the record because 41 exists, without asking whether this caller may see 41, is a broken-access-control bug. Change the number in the URL, read someone else's data. It is trivially discoverable and it appears in production systems constantly. The fix is one comparison — and remembering to write it every time.

Deny by default

The safe default is no. Access is granted only when a rule explicitly says so; anything not covered is refused.

The alternative — allow unless a rule forbids — fails the moment someone adds a route and forgets the check, and the failure is silent. Denied by default fails loudly instead: the new endpoint returns 403 until you decide, deliberately, who may use it. Loud failures get fixed.

In practice: attach the authentication check to the whole protected router, not endpoint by endpoint, and make "which role may do this" an explicit argument on each route.

router.use(requireAuth);                                  // everything below needs a session
router.delete('/equipment/:id', requireRole('admin'), h); // and this needs the role

If someone adds a route to that router and forgets requireRole, it is still gated by requireAuth — degraded, not open. That is what defence in depth buys you.

The default position of a valve

Ask what happens when the control signal is lost. A fail-closed valve shuts; a fail-open valve dumps the tank. Missing permission information is a lost signal — decide now which way your system moves when it arrives.

Walkthrough: session lookup, role gate, ownership check

First, turn the cookie from Day 51 into a known user. This middleware runs before every protected route:

// src/middleware/auth.ts
import type { Request, Response, NextFunction } from 'express';
import * as sessions from '../repositories/session.repository.js';

export async function requireAuth(req: Request, res: Response, next: NextFunction) {
  const sid = req.cookies?.sid;
  if (!sid) return res.status(401).json({ error: 'authentication required' });

  const session = await sessions.findValid(sid);   // expires_at > now(), joined to users
  if (!session) return res.status(401).json({ error: 'authentication required' });

  req.actor = { id: session.user_id, role: session.role };
  next();
}

findValid must check expiry in SQL — WHERE id = $1 AND expires_at > now() — so an old cookie cannot be replayed forever. Reading cookies needs cookie-parser:

npm install cookie-parser
npm install --save-dev @types/cookie-parser
import cookieParser from 'cookie-parser';
app.use(cookieParser());

TypeScript will not know about `req.actor` until you tell it

Adding your own property to Express's Request needs one declaration file:

// src/types/express.d.ts
declare global {
  namespace Express {
    interface Request {
      actor?: { id: number; role: string };
    }
  }
}
export {};

The same pattern covers any property your middleware attaches later.

Then the role gate, which is a small factory returning a middleware:

export function requireRole(...allowed: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.actor) return res.status(401).json({ error: 'authentication required' });
    if (!allowed.includes(req.actor.role)) {
      return res.status(403).json({ error: 'insufficient permissions' });
    }
    next();
  };
}

Wire it up, deny-by-default style:

const router = Router();
router.use(requireAuth);

router.get('/equipment', listEquipment);
router.post('/equipment', createEquipment);
router.delete('/equipment/:id', requireRole('admin'), deleteEquipment);
router.patch('/maintenance/:id', updateMaintenance);  // ownership checked in the service

The ownership rule stays in the service, because it is a business rule and it needs the row:

// src/services/maintenance.service.ts
export async function updateRecord(
  actor: { id: number; role: string },
  recordId: number,
  changes: { notes?: string },
) {
  const record = await repo.findById(recordId);
  if (!record) throw new AppError('not_found', 'maintenance record not found');
  if (actor.role !== 'admin' && record.created_by !== actor.id) {
    throw new AppError('forbidden', 'you may only edit your own records');
  }
  return repo.update(recordId, changes);
}

Add forbidden: 403 to the route's status map from Day 50, and the service still knows no HTTP.

Now prove it. Log in as a technician, keeping the cookie in a jar:

curl -c tech.txt -X POST http://localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"correct horse battery staple"}'

curl -i -b tech.txt -X DELETE http://localhost:3000/equipment/1
HTTP/1.1 403 Forbidden
{"error":"insufficient permissions"}

And with no cookie at all:

curl -i -X DELETE http://localhost:3000/equipment/1
HTTP/1.1 401 Unauthorized
{"error":"authentication required"}

Checkpoint

You have three distinct outcomes for the same URL: 401 anonymous, 403 technician, 200 (or 204) admin. If any two of those are the same, the check is not doing what you think.

Your turn

  1. Write your permission matrix in docs/permissions.md — every action as a row, every role as a column, no blank cells. Do this before any code.
  2. Add a role to your seed users so you have one admin and one technician.
  3. Implement requireAuth, checking session expiry in SQL, and attach it with router.use to the whole protected router.
  4. Implement requireRole and apply it only where the matrix says a role is required.
  5. Implement the ownership check for editing maintenance records, in the service, loading the row first.
  6. Build a permission test script: for each matrix row, one curl as admin, one as technician, one with no cookie. Record the status code you got beside the one the matrix predicted.
  7. Fix every disagreement. A cell that returns 200 where the matrix says no is a live bug.
  8. Deliberately add a new empty route to the protected router without a role gate, confirm it returns 401 anonymously rather than being wide open, then remove it. That is deny-by-default working.

Pair mode — start here, at step 1

"Help me write a permission matrix before implementing authorization checks." Give the AI your resources, actions, and roles, and let it propose rows — its value here is catching the case you forgot, such as "who may read another user's records". You decide every cell. When it later proposes code, inspect the diff, run your step 6 script, and be able to explain each changed line before you keep it. A permission check you cannot narrate is a permission check you cannot trust.

Common pitfalls

  • Hiding the button and calling it done. The browser is under the user's control, as Day 1 established. UI hiding is courtesy; the server check is the security.
  • Checking the role but not the owner. requireRole('technician') lets every technician edit every record. Role gates and ownership checks answer different questions; most endpoints need both.
  • Trusting a user ID from the request body. {"userId": 1} in the body is an attacker's claim, not a fact. The actor comes from the session, and only from the session.
  • Using 403 for "not logged in". It hides the fix from an honest client. No credentials is 401; wrong credentials for this action is 403.
  • Leaking existence through status codes. Returning 403 for a record that does not exist tells the caller it exists. For sensitive resources, prefer 404 for both.

Verify it yourself

Open today's reference, the OWASP Top 10, and find the entry on broken access control.

  1. OWASP lists common access-control failures. Find one your matrix does not currently cover, and add the row.
  2. Find what OWASP says about enforcing access control server-side and about denying by default. Does it agree with the two claims this lesson made? Quote the sentence in docs/permissions.md.

An access-control rule that exists only in your head is not a rule. Written down, checked by a script, cited to a source — that is one you can defend six months from now.

The hour

  1. 0–5 min Recall

    Without notes, state yesterday’s main idea and one unresolved question.

  2. 5–20 min Learn

    Read only the listed concept notes and official reference sections needed today.

  3. 20–48 min Build

    Add admin and technician roles. Restrict equipment deletion and maintenance editing appropriately.

  4. 48–55 min Explain and verify

    Run the result, inspect evidence, and explain the data/control flow in your own words.

  5. 55–60 min Quiz and commit

    Complete the quiz, record one lesson, and commit the verified change when applicable.

What to hand in

Deliverable

Server-side permission tests for allowed and denied cases.

Working with AI today

AI as pair programmer

Define one small task, review the plan, inspect the diff, run checks, and explain every changed section.

Help me write a permission matrix before implementing authorization checks.

References

End-of-day quiz

Q1 Where must authorization be enforced?
Q2 Which result best proves today’s work is complete?
Q3 Before accepting an AI-generated code change, what should you do?

Explain-back gate

Pass the quiz above to unlock completion.

Quiz + explain-back checks required.