arrow_backBACK TO SECURITY ENGINEERING FOR BACKEND ENGINEERS — THREATS, AUTH, CRYPTO AND SHIPPING SECURELY
Lesson 03Security Engineering for Backend Engineers — Threats, Auth, Crypto and Shipping Securely6 min read

Authorization and Access Control

October 28, 2026

TL;DR

Authentication proves who you are; authorization decides what you may do — and mixing them up causes a huge class of bugs. Pick the simplest model that fits: RBAC (user → role → permissions) for most apps, ABAC (decisions from attributes like owner + org + context) when rules are finer-grained, ReBAC (permissions from relationships) for sharing-style products. Underneath every model sits one principle: least privilege with default-deny — grant the minimum rights needed and deny everything else, and check authorization on the SERVER for EVERY request against the ACTUAL resource. The bug to burn into memory is IDOR / BOLA (Broken Object Level Authorization): the app checks that you're logged in but not that you own the specific object, so changing /invoices/124 to /invoices/125 returns someone else's data. It tops real API breach reports. The fix is unglamorous and non-negotiable: on every object access, scope the query by owner/tenant and verify this user may touch this object — ids are not secrets, the ownership check is the real control.

Lesson 2 got the user through the front door — you know who they are. This lesson is about everything after: deciding what they’re allowed to do, and enforcing it. This is authorization, and it’s worth stating plainly because the confusion causes real breaches: authentication and authorization are different jobs. Authentication is “you are Asha.” Authorization is “Asha may read this invoice but not that one, and may not access the admin panel at all.” Being authenticated is not permission to do anything in particular. A logged-in user acting on data that isn’t theirs is one of the most common serious vulnerabilities there is — and the whole second half of this lesson is about it.

Models: Pick the Simplest That Fits

There are a few standard ways to structure authorization decisions. They’re not competitors so much as points on a scale from simple to expressive; use the least complex one that captures your rules.

Authorization models: what may you do, pick the simplest model that fits. RBAC Role-Based: user to role to permissions, 'admin' 'editor' 'viewer', simple, most apps start here. ABAC Attribute-Based: decision from attributes, role plus resource plus context, e.g. 'owner AND same org AND work-hours'. ReBAC Relationship-Based: permission from relationships, 'can edit docs I own or am shared on' Google-Docs style. The golden rule: LEAST PRIVILEGE. Default to DENY. Grant the minimum rights needed for the task, nothing more. Check authorization on the SERVER, for EVERY request, on the ACTUAL resource — never trust the UI to hide a button, and never trust an id the client sent as proof. Separate authentication (are you logged in?) from authorization (may YOU touch THIS?). Being logged in is not permission to act on someone else's data
  • RBAC (Role-Based Access Control) — users are assigned roles (admin, editor, viewer), and roles carry permissions. Simple, easy to reason about, and where most applications should start. “Is this user an editor? Then they may edit.” Its limit is that pure roles can’t express rules that depend on the specific resource or context.
  • ABAC (Attribute-Based Access Control) — decisions are computed from attributes of the user, the resource, and the context: “allow if the user is in the same organisation as the document and owns it and it’s not locked.” More expressive, handles fine-grained and contextual rules, at the cost of more complexity.
  • ReBAC (Relationship-Based Access Control) — permissions flow from relationships between entities: “you can edit a document if you’re its owner or someone shared it with you.” This is the Google-Docs model, and systems like Google’s Zanzibar popularised it for sharing-heavy products.

Most backends do well with RBAC, reaching for a few ABAC-style attribute checks (especially “is this user the owner of this resource?”) where roles alone fall short. Don’t over-engineer: a full policy engine for an app with three roles is wasted complexity. But do centralise the logic — scattering ad-hoc if user.role == ... checks across hundreds of handlers guarantees you’ll miss one.

The One Principle: Least Privilege, Default Deny

Underneath every model is a single principle that matters more than the choice of model: least privilege. Grant each user, service, and token the minimum rights needed to do its job, and nothing more. The mirror image is default deny: the baseline answer to “may I do this?” is no, and access is granted only by an explicit rule. This is the opposite of the tempting “allow by default, block the bad stuff” approach, which fails the moment you forget to block something — and you will.

Why it matters so much: least privilege shrinks the blast radius of every compromise. If a token can only read one customer’s data, a leaked token leaks one customer’s data, not the whole database. If a service account can only write to one queue, compromising it doesn’t hand over your infrastructure. You’ll see this exact idea again in cloud IAM (Lesson 10), where a wildcard permission turns one leaked key into a total account takeover.

Three enforcement rules turn the principle into practice, and they’re the ones that actually get violated:

  1. Enforce on the server, every time. Hiding a button in the UI, or not showing a menu item, is a usability choice, not a security control. Anyone can call the endpoint directly with curl. The authorization check must live on the server and run on every request.
  2. Check against the actual resource, not just the role. “Is this user an editor?” is not enough if the question is “may this user edit this specific document?” You must check their relationship to the concrete object — which brings us to the single most important bug in this lesson.
  3. Never trust an identifier the client sent as proof of permission. If the request says user_id=42, that’s a claim, not proof. Derive who the caller is from their authenticated session/token, and check that identity against the resource — never authorize based on an id in the request body or URL.

IDOR / BOLA: The Bug That Tops the Charts

Here is the vulnerability to burn into your memory, because it is consistently the number one issue in real-world API breach reports (OWASP calls it BOLA — Broken Object Level Authorization; the older name is IDOR — Insecure Direct Object Reference). It’s devastatingly simple.

IDOR / BOLA, the number one API bug: Broken Object Level Authorization, forgetting to check who owns the object. Asha logged in legitimately views her own invoice with GET /api/invoices/124, server returns invoice 124, it's hers. Asha as attacker just changes the id in the URL to GET /api/invoices/125. Vulnerable server returns invoice 125, Ravi's private data, no owner check. Fixed server does WHERE id=125 AND owner=asha, returns 404 or 403. The fix is boring and mandatory: on every object access verify the current user is allowed to touch that specific object — scope the query by owner/tenant, don't just fetch by id. Use unguessable ids (UUIDs) as defense-in-depth, but ids are not secrets: the ownership check is the real control

The whole bug: Asha legitimately requests her own invoice, GET /api/invoices/124. The app checks she’s logged in, fetches invoice 124, returns it. Fine. Now Asha changes the number to 125 and requests GET /api/invoices/125. If the app only checked that she’s authenticated and then fetched invoice 125 by its id — without checking whether 125 belongs to her — it cheerfully returns Ravi’s private invoice. Multiply by scripting across every id and you have a mass data-leak from a single missing check. This isn’t hypothetical; it’s how a large share of real API data breaches happen, precisely because it’s so easy to overlook.

The fix is unglamorous, repetitive, and non-negotiable: on every object access, verify that the authenticated user is allowed to touch that specific object. In practice:

  • Scope the query by owner/tenant, don’t just fetch by id. Instead of SELECT * FROM invoices WHERE id = 125, write SELECT * FROM invoices WHERE id = 125 AND owner_id = :current_user. Now a mismatch returns nothing — a clean 404/403 — instead of someone else’s data. Deriving current_user from the authenticated session (never from the request) is what makes this sound.
  • Do it everywhere, for every object type, on read and write and delete. The attacker only needs the one endpoint you forgot.
  • Unguessable ids (UUIDs) are defense-in-depth, not the fix. Sequential integer ids make enumeration trivial, so random UUIDs raise the bar — but an id is not a secret (it appears in URLs, logs, referrers). The ownership check is the real control; UUIDs just make a missing check harder to exploit.

The same pattern — “check the relationship to the specific object, not just the role” — is what OWASP’s API Top 10 (Lesson 8) is largely about. If you internalise one thing from this lesson, make it this: fetching by id without scoping to the owner is a vulnerability, every single time.

A Few More Access-Control Habits

  • Multi-tenancy needs isolation by construction. If you serve many customers from one system, every query must be scoped to the tenant, ideally enforced in a shared data layer rather than hand-written in each handler. One un-scoped query leaks across tenants.
  • Protect function-level access too. BOLA is about objects; there’s a sibling bug (Broken Function Level Authorization) where a normal user calls an admin-only endpoint that forgot to check the role. Admin and privileged endpoints need explicit role checks, not just obscurity.
  • Re-check on every step of a flow. Don’t authorize at step one of a wizard and assume steps two and three are safe — the attacker calls them directly.
  • Fail closed. If the authorization check errors out, deny. An exception in the permission code must never result in “allow.”

Pitfalls to Avoid

  1. Confusing authentication with authorization. Logged in ≠ allowed to touch this. Keep them separate and check both.
  2. IDOR/BOLA — fetching by id without an ownership check. The top API bug. Always scope queries by owner/tenant.
  3. Relying on the UI to enforce permissions. Hidden buttons aren’t security; anyone can call the endpoint. Enforce on the server.
  4. Authorizing from a client-supplied id. Derive the caller’s identity from their session/token, never trust user_id in the request.
  5. Allow-by-default. Start from deny; grant by explicit rule. Forgetting to block something shouldn’t open a door.
  6. Un-scoped queries in multi-tenant systems. Enforce tenant isolation in a shared layer so one missed WHERE doesn’t leak across customers.
  7. Forgetting function-level checks on admin endpoints. Obscurity isn’t a role check.

Quick Recap

  • Authorization (“what may you do?”) is a different job from authentication (“who are you?”). Logged in is not permission.
  • Pick the simplest model: RBAC for most apps, ABAC for attribute/context rules, ReBAC for sharing-style products.
  • The one principle is least privilege with default-deny: minimum rights, deny unless explicitly allowed — it shrinks the blast radius of every compromise.
  • Enforce on the server, every request, against the actual resource, using the caller’s authenticated identity — never a client-supplied id.
  • IDOR/BOLA is the top API bug: always scope object access by owner/tenant; UUIDs help but the ownership check is the real fix.

Next lesson: the oldest trick in the book and still one of the most dangerous — injection, where attacker data gets interpreted as code. We’ll see the real SQL-injection demo and the single principle that stops all of its cousins.