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.
- 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.
- 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.
- 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.
- Technology
RBAC (roles), ABAC (attributes), ReBAC (relationships), scoped queries and row-level security, policy engines (OPA, Cedar, OpenFGA), scoped service credentials and OAuth scopes.
- 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.
The problem
2 min- Omar · On-call SREInès logged into the extranet as a receptionist at Hotel Amsterdam Central. She changed
/bookings/5512to/bookings/5513and got another hotel's guest. Passport number included. - Karim · CTOBut our login has two-factor. She couldn't have broken in.
- You · Engineer at StayHubShe didn't break in. She was logged in.
The big idea
4 minAuthentication 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?"
How it works
5 min- 1Authenticate
: session cookie → user 981, receptionist at Hotel Amsterdam Central. No valid session: 401.
- 2Coarse 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.
- 3Tenant 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.
- 4Scope 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.
- 5Ask 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. - 6Record sensitive actions
Refunds, exports and role changes go to an audit log. And every user, service and key gets the minimum access needed: .
Watch a decision being made
4 minLina 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.
- 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.
- 2Make scoping impossible to forget
Replace raw queries with a repository whose every method requires the tenant from the session.
tsexport 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.
- 3Centralise the rules, deny by default
One pure
can(subject, action, booking)function: tenant first, then roles and the €100 rule,return falseat the end.Why: Rules in one place can be reviewed and tested. Rules spread over 180 handlers drift.
- 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.
In real life
3 minGoogle 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.
What goes wrong
3 minWithout an object-level check, any user can open any id.
Authorize every action on every resource, in the service that loads it.
It's attacker-controlled: she just sends the other hotel's id.
Take identity and tenant from the session or a verified token.
One leak gives full access to every hotel, for years.
Scoped (rates:write, own hotels only), short-lived, rotated, audited.
Trade-offs
3 min| Option | Fits when | You pay |
|---|---|---|
| Checks in each handler | A few endpoints, a quick hotfix | Rules drift; the next forgotten check is the next breach |
| Central policy + tenant-scoped data layer | Many endpoints, several roles | Weeks of work and a learning curve |
| Row-level security in the database | Many tenants in one shared database | Harder to debug; an owner role silently bypasses it |
- Karim · CTOAdd an
ifto each of the 23 endpoints today and ship. The central layer is a three-week project. - Lina · Senior engineer, your mentorAnd the 24th endpoint, written next month by someone who doesn't know this story?
- Karim · CTOCode review will catch it.
Your job as tech lead
4 minClaude 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.
- 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:writefor its own hotels, never "admin".
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.
- 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.queryorreq.body. - The cross-hotel test covers all endpoints automatically, not only the three Claude fixed.
- 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.
Check yourself
2 minGET /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);Next episode: guests want "Sign in with Google", and a hotel chain wants its staff to log in with their own company accounts.
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.
Technical deep dive
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.
- 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.
- 2Take the tenant from the session
req.auth.hotelIdcomes from the verified session. Ignore anyhotelIdin the URL, query or body for deciding access.Why: Anything in the request is attacker-controlled. Only the session was established by our login.
- 3Make scoping impossible to forget
Replace raw queries with a repository whose every method requires a tenant.
tsexport 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.
- 4Centralise the rules
One pure
can(subject, action, booking)function: tenant first, deny by default.tsexport 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.
- 5Add a database safety net
Turn on row-level security for bookings: the app sets
app.hotel_idper 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.
- 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.
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| Model | Rule looks like | Strengths | Weak spots |
|---|---|---|---|
| RBAC | manager → booking:refund | Simple to understand, audit and show in an admin UI | Roles multiply when rules depend on context; can't express ownership |
| ABAC | refund if amount ≤ €100 and before check-in | Expressive; handles time, amount, location | Harder to answer "who can do X?" |
| ReBAC | viewer of booking ⇐ staff of hotel ⇐ admin of chain | Natural for hierarchies and ownership | Needs a relationship store kept in sync |
| Scoped credentials | partner token: rates:write for hotels 12, 44 | Least privilege for machines and partners | Needs scope design and rotation |
Interactive simulation
Visual diagram
Where authentication and authorization checks live in a multi-tenant hotel SaaS. Follow a refund request.
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.
Check your understanding
Practice ladder
Predict four responses
Code Warm-upUsing 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.
- No session cookie:
GET /bookings/5512 - Receptionist at Hotel A:
POST /bookings/5512/refundfor €80, booking at Hotel A, check-in 20 May - Receptionist at Hotel A:
POST /bookings/5512/refundfor €300, same booking - Manager at Hotel A:
GET /bookings/5513, a booking at Hotel B
Fix the cancel endpoint
Code PracticeThis 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.
Permissions for hotel chains
Design PracticeA 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.
Should support be able to "view as any hotel"?
Decision StretchStayHub'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…".
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.