Skip to content
Episode 86 · One number in a URL

Authentication vs authorization: RBAC, ABAC, least privilege and IDOR

Thursday, 10:15: a security researcher emails StayHub. Logged in as a receptionist at one hotel, she changed /bookings/5512 to /bookings/5513 and saw another hotel's guest, passport number included.

"Who are you?" and "Are you allowed?" sound similar. Mixing them up is one of the most common security bugs on the web.

30 min intermediateAuthentication (who you are) vs authorization (what you may do)Authorization models: RBAC, ABAC and relationship-basedObject-level authorization and IDOR bugsLeast privilege for users, services and tokensEnforcing authorization on every request (roles + ownership)Never trust the client
  1. Problem

    A hotel SaaS serves 400 hotels. Receptionists, managers, chain admins, guests and partner integrations all call the same API, and each may see and do different things.

  2. Why the simple system fails

    Checking only that a user is logged in, hiding buttons in the UI, or sprinkling `if (user.role === 'admin')` across handlers leaks one hotel's guests to another the first time someone changes an id in a URL.

  3. Engineering concept

    Separate identity (authentication) from permission (authorization). Every request answers: may *this* subject perform *this* action on *this* resource, in *this* context? Grant the least privilege needed, and enforce on the server, close to the data.

  4. Technology

    RBAC (roles), ABAC (attributes), ReBAC (relationships), scoped queries and row-level security, policy engines (OPA, Cedar, OpenFGA), scoped service credentials and OAuth scopes.

  5. Trade-offs

    Simple roles are easy to audit but explode into role sprawl; attribute and relationship models are expressive but harder to reason about and test. Central policy engines add consistency and a network hop.

01

The problem

2 min
Thursday, 10:15. An email to security@stayhub from Inès, a security researcher.
  1. Omar · On-call SRE
    Inès logged into the extranet as a receptionist at Hotel Amsterdam Central. She changed /bookings/5512 to /bookings/5513 and got another hotel's guest. Passport number included.
  2. Karim · CTO
    But our login has two-factor. She couldn't have broken in.
  3. You · Engineer at StayHub
    She didn't break in. She was logged in.
02

The big idea

4 min
Logged in is not allowed
The big idea

Authentication asks who you are, once per session; authorization asks whether you may do this, to this, on every request.

Think of a hotel key card. The front desk checks your passport and makes the card: authentication. Each door's lock then decides whether your card opens this door. Our extranet's locks only checked "is this a valid card?"

03

How it works

5 min
A request, checked properly
  1. 1
    Authenticate

    : session cookie → user 981, receptionist at Hotel Amsterdam Central. No valid session: 401.

  2. 2
    Coarse check at the edge

    The gateway confirms she's staff and may call /extranet at all. It sees a URL and a token, so it can't know who owns booking 5513.

  3. 3
    Tenant from the session

    The tenant (her hotel) comes from her session, never from the URL or the body. Anything in the request is attacker-controlled.

  4. 4
    Scope the query

    Load booking 5513 where hotel = hers. Forgetting this is an IDOR (): logged in, but the object is never checked. Another hotel's booking: 404, so she can't even learn it exists.

  5. 5
    Ask the policy

    for the action: can(user, "booking:refund", booking). Receptionists up to €100 before check-in; managers any amount. Otherwise 403. Rules mix and like amount and time.

  6. 6
    Record sensitive actions

    Refunds, exports and role changes go to an audit log. And every user, service and key gets the minimum access needed: .

04

Watch a decision being made

4 min
Worked example

Lina closes the whole class of bugs

A grep finds 23 of 180 endpoints loading records by id only. Fix Inès's bug, and make sure the other 22 (and the next one someone writes) can't leak another hotel's data.

  1. 1Reproduce with a test

    Log in as a receptionist at hotel A, request a booking of hotel B. It returns 200 today.

    Why: A failing test proves the bug and becomes the guard that keeps it fixed.

  2. 2Make scoping impossible to forget

    Replace raw queries with a repository whose every method requires the tenant from the session.

    ts
    export const bookings = (hotelId: string) => ({
      byId: (id: string) => db.booking.findFirst({ where: { id, hotelId } }),
    });
    // usage: await bookings(req.auth.hotelId).byId(req.params.id)

    Why: Twenty-three endpoints forgot a filter because forgetting was possible.

  3. 3Centralise the rules, deny by default

    One pure can(subject, action, booking) function: tenant first, then roles and the €100 rule, return false at the end.

    Why: Rules in one place can be reviewed and tested. Rules spread over 180 handlers drift.

  4. 4Add nets: database and CI

    Turn on so Postgres hides other hotels' rows. A CI test calls all 180 endpoints as another hotel and expects 404 or 403.

    Why: Defence in depth: if a new endpoint skips the repository, the database and the test still catch it.

The whole thing in one line: Lina fixed the pattern, not the line: a data layer that needs a tenant, one deny-by-default policy, and two safety nets that catch the next forgotten check.
05

In real life

3 min
Two ways to model permissions at scale

Google Zanzibar: relationships

  • Described in a public 2019 paper as Google's global authorization system, used by products like Drive
  • Permissions follow relationships: viewer of a doc ⇐ member of a group
  • Needs a relationship store kept in sync

Fits deep hierarchies and sharing.

AWS IAM: policy documents

  • Publicly documented JSON policies: allow or deny actions on resources, with conditions
  • Deny by default; "who can do X?" can be hard to answer

Fits many services and fine-grained conditions.

06

What goes wrong

3 min
Treating "logged in" as "allowed"

Without an object-level check, any user can open any id.

Instead

Authorize every action on every resource, in the service that loads it.

Trusting a `hotelId` from the URL or body

It's attacker-controlled: she just sends the other hotel's id.

Instead

Take identity and tenant from the session or a verified token.

Broad, long-lived keys for partners and services

One leak gives full access to every hotel, for years.

Instead

Scoped (rates:write, own hotels only), short-lived, rotated, audited.

07

Trade-offs

3 min
OptionFits whenYou pay
Checks in each handlerA few endpoints, a quick hotfixRules drift; the next forgotten check is the next breach
Central policy + tenant-scoped data layerMany endpoints, several rolesWeeks of work and a learning curve
Row-level security in the databaseMany tenants in one shared databaseHarder to debug; an owner role silently bypasses it
Friday, 09:00. Planning the fix.
  1. Karim · CTO
    Add an if to each of the 23 endpoints today and ship. The central layer is a three-week project.
  2. Lina · Senior engineer, your mentor
    And the 24th endpoint, written next month by someone who doesn't know this story?
  3. Karim · CTO
    Code review will catch it.
08

Your job as tech lead

4 min

Claude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.

Decisions you own
  • Where the tenant comes from: always the session, never the URL or body.
  • The permission rules in plain words before any code: managers refund any amount; receptionists up to €100, before check-in. An agent will invent a rule if you don't state it.
  • What each partner key may do (): rates:write for its own hotels, never "admin".
Tell Claude Code
Security fix, plan first. An IDOR let a receptionist read another hotel's booking via GET /extranet/bookings/:id. List every extranet endpoint that loads a record by a request id without filtering by the session's hotel_id (we think 23 of 180), with file and line. Propose one pattern: a scoped loader returning 404 outside the hotel, plus can(user, action, record) for refunds (managers any amount; receptionists up to €100 before check-in). Add a test calling each endpoint as Hotel A with a Hotel B id.
Check before you accept
  • Claude's list is close to 23. Far fewer? Ask how it searched.
  • In changed handlers, the hotel id comes from the session, not req.params, req.query or req.body.
  • The cross-hotel test covers all endpoints automatically, not only the three Claude fixed.
Red flags
  • It switches ids to random UUIDs and calls the IDOR fixed. That's hiding, not checking.
  • It hides the button in the UI and doesn't change the API.
  • It adds MFA to fix an authorization bug.
09

Check yourself

2 min
Q1/3Debugging
Staff at Hotel A can open GET /api/guests/7731/passport for a guest of Hotel B. The handler is behind requireAuth. What is the fix?
const guest = await db.guest.findUnique({ where: { id: req.params.id } });
return res.json(guest.passport);
How confident are you?

Next episode: guests want "Sign in with Google", and a hotel chain wants its staff to log in with their own company accounts.

Finished?

Mark it complete to update your skill map.

Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Check your understanding, Practice ladder, Explain it yourself.
10

Technical deep dive

From one fix to a whole class of bugs
Worked example

Lina closes the whole class of bugs

Fix Inès's IDOR, and make sure the 22 other endpoints with the same pattern (and the next ones someone writes) can't leak another hotel's data.

  1. 1Reproduce with a test

    Write a test that logs in as a receptionist at hotel A and requests a booking of hotel B. It returns 200 today.

    Why: A failing test proves the bug and becomes the guard that keeps it fixed.

  2. 2Take the tenant from the session

    req.auth.hotelId comes from the verified session. Ignore any hotelId in the URL, query or body for deciding access.

    Why: Anything in the request is attacker-controlled. Only the session was established by our login.

  3. 3Make scoping impossible to forget

    Replace raw queries with a repository whose every method requires a tenant.

    ts
    export const bookings = (hotelId: string) => ({
      byId: (id: string) => db.booking.findFirst({ where: { id, hotelId } }),
      list: (filter: BookingFilter) => db.booking.findMany({ where: { ...filter, hotelId } }),
    });
    // usage: await bookings(req.auth.hotelId).byId(req.params.id)

    Why: Twenty-three endpoints forgot a filter because forgetting was possible. Now there's no method without a tenant.

  4. 4Centralise the rules

    One pure can(subject, action, booking) function: tenant first, deny by default.

    ts
    export function can(s: Subject, action: Action, b: Booking, now = new Date()) {
      if (b.hotelId !== s.hotelId) return false;                  // tenant first
      if (action === "booking:read") return s.roles.has("receptionist") || s.roles.has("manager");
      if (action === "booking:refund")
        return s.roles.has("manager") ||
          (s.roles.has("receptionist") && b.amountCents <= 10_000 && now < b.checkIn);
      return false;                                               // deny by default
    }

    Why: Rules in one place can be reviewed and tested exhaustively. Rules spread over 180 handlers drift.

  5. 5Add a database safety net

    Turn on row-level security for bookings: the app sets app.hotel_id per transaction, and the database hides other hotels' rows.

    Why: Defence in depth: if a report query or a new endpoint skips the repository, the database still refuses.

  6. 6Test every endpoint across tenants

    A CI test calls all 180 endpoints as a user from another hotel and expects 404 or 403.

    Why: The next endpoint someone writes is the next IDOR. An automatic test catches it before production.

The whole thing in one line: Lina reproduced the bug in a test, took the hotel from the session, made tenant-free queries impossible, put the rules in one deny-by-default function, added row-level security as a net, and made CI attack every endpoint from another hotel.
sql
ALTER TABLE bookings ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON bookings
  USING (hotel_id = current_setting('app.hotel_id')::uuid);
-- per request, inside the transaction:
SET LOCAL app.hotel_id = 'a1f3…';      -- from the authenticated session
SELECT * FROM bookings WHERE id = $1;   -- other hotels' rows are invisible
The app's database role must not own the table or have BYPASSRLS, or the policy is skipped.
ModelRule looks likeStrengthsWeak spots
RBACmanager → booking:refundSimple to understand, audit and show in an admin UIRoles multiply when rules depend on context; can't express ownership
ABACrefund if amount ≤ €100 and before check-inExpressive; handles time, amount, locationHarder to answer "who can do X?"
ReBACviewer of booking ⇐ staff of hotel ⇐ admin of chainNatural for hierarchies and ownershipNeeds a relationship store kept in sync
Scoped credentialspartner token: rates:write for hotels 12, 44Least privilege for machines and partnersNeeds scope design and rotation
11

Interactive simulation

12

Visual diagram

Click a component

Where authentication and authorization checks live in a multi-tenant hotel SaaS. Follow a refund request.

13

Analogy

  • A hotel key card systemFront desk checks your passportYour appAuthentication: proving who you are
  • A hotel key card systemEach door decides if your card opensYour appAuthorization on every request
  • A hotel key card systemCard types: guest, housekeeping, managerYour appRoles (RBAC)
  • A hotel key card systemLock only checks "is this a real card?"Your appIDOR: no ownership check
  • A hotel key card systemCleaner gets a floor key, not masterYour appLeast privilege

Where the analogy breaks: a hotel has a few hundred doors. Your app may have millions of records, and each one needs its own check, every time.

14

Check your understanding

Q1/3Reasoning
Classify each check:
Verifying a passkey signature at login
Checking the booking's hotel matches the staff member's hotel
Rejecting an expired access token
Allowing refunds only up to €100 for receptionists
How confident are you?
15

Practice ladder

Step 1 · Trace

Predict four responses

Code Warm-up

Using Lina's can() (tenant first; read: receptionist or manager; refund: manager any amount, receptionist ≤ €100 and before check-in) and the scoped repository (other hotels' bookings → 404), predict the HTTP status for each request. Today is 10 May.

  1. No session cookie: GET /bookings/5512
  2. Receptionist at Hotel A: POST /bookings/5512/refund for €80, booking at Hotel A, check-in 20 May
  3. Receptionist at Hotel A: POST /bookings/5512/refund for €300, same booking
  4. Manager at Hotel A: GET /bookings/5513, a booking at Hotel B
Step 2 · Complete

Fix the cancel endpoint

Code Practice

This guest-facing endpoint lets guests cancel their bookings. Fill in the two gaps so a guest can only cancel their own booking, and only before check-in.

app.post("/me/bookings/:id/cancel", requireAuth, async (req, res) => {
  const booking = await db.booking.findFirst({
    where: { /* gap 1 */ },
  });
  if (!booking) return res.sendStatus(404);
  if (/* gap 2 */) return res.sendStatus(409); // too late to cancel online
  await cancel(booking);
  return res.sendStatus(204);
});

Note: the request body also contains { "guestId": "…" } from the mobile app.

Step 3 · Solve

Permissions for hotel chains

Design Practice

A 120-hotel chain joins StayHub. They need:

  • Chain admins who see bookings and reports for all 120 hotels.
  • Regional managers for 3 regions (about 40 hotels each), who can refund any amount in their region.
  • Hotel staff as today (receptionists, managers), limited to their own hotel.

Design how StayHub decides access. Which model(s), what data you store, and how can() changes. Keep tenant isolation for the other 8,880 hotels.

Step 4 · Decide

Should support be able to "view as any hotel"?

Decision Stretch

StayHub's support team (25 people) asks for a permanent "view as any hotel" role, so they can see exactly what a hotel sees when it calls with a problem. Today they ask an engineer to look, which takes ~20 minutes per ticket; they handle ~60 such tickets a week.

Write a short decision: "We chose X over Y because…, accepting the risk of…".

16

Explain it yourself

Explain the difference between authentication and authorization to a new backend engineer, using an IDOR bug as the example, and say where checks should live.

0 words