deask.
note2026-09-19

How software gets permission today

A hotel, a valet key and one very long answer to "can I do this?"

Before I start arguing that something is missing, I should probably show what is already there.

This is that note. Identity, authentication, authorization, RBAC and friends, OAuth, tokens, scopes, service accounts, policy engines. The whole pile.

It is long. Sorry. I tried splitting it, and every piece kept leaning on the others.

How to read this

People reading this sit anywhere between "what is authorization?" and "I have opinions about Zanzibar consistency tokens". So most ideas get explained more than once, from a different angle each time:

  • The hotel. One analogy for the whole note. Easy paragraphs, skip them if you know the concept.
  • The software. The same idea, in one made-up but very ordinary system.
  • Deeper cut boxes. Details, edge cases and a few opinions for people who do this for a living. Skip them if you are new, nothing later depends on them.
note
If you do IAM for a living

Skim the first two sections, have a look at Many ways to get it wrong, and then jump to Known actors, known requests. That's where this series actually starts going somewhere.

The cast

The hotel. You are checking in. There is a receptionist, a key card, rooms, a pool, a spa, housekeeping, a valet and a laundry company that shows up every morning.

The software. Acme is a company with an internal purchasing app. People request things, managers approve them, finance pays.

  • Anna is an engineer. She requests a monitor.
  • Ben is her team lead. He approves orders for his team.
  • Acme Purchasing is the app, with an API behind it.
  • ExpenseBuddy is a third-party app Anna wants to connect to her orders.
  • invoice-sync is a nightly job which pushes approved orders to accounting.

That's everyone. They will keep coming back.

The cast: hotel characters paired with their Acme counterpartsYou, the guest corresponds to Anna (user). Front desk corresponds to Acme login (identity provider). Key card corresponds to Access token (token). Doors, pool, spa corresponds to Purchasing API (resource). Gold status corresponds to Ben (role). Valet key corresponds to ExpenseBuddy (OAuth). Laundry company corresponds to invoice-sync (service account).THE HOTELACMEuser You, the guesthave a booking Annaengineer, requests a monitoridentity provider Front deskchecks passports, issues cards Acme loginwhere everyone signs intoken Key cardopens doors, no questions asked Access tokensent with every requestresource Doors, pool, spaeach one checks your card Purchasing APIchecks every requestrole Gold statusgets you into the pool Benteam lead, approves ordersOAuth Valet keydrives, can’t open the trunk ExpenseBuddythird-party appservice account Laundry companynot a guest, still needs doors invoice-syncnightly job, nobody logged in
fig 1Everyone in this note, and who they correspond to. The hotel is the analogy, Acme is the software. The line in the middle is the concept they both illustrate.

Who are you, prove it, what can you do

These three questions are the whole field in one line. They have names: identity, authentication and authorization. Put together (plus all the admin work of creating, changing and removing access) they are usually called IAM, Identity and Access Management.

They also always happen in this order, and each one only makes sense on top of the previous one. So let's go in order.

Identity: who are you?

The hotel. You walk up to the desk and say "Hi, I'm Dmitrijs, I have a booking."

Notice what happened. You didn't create your identity at the desk. The hotel already has a guest record: a name, a booking, a loyalty number, maybe a note saying you asked for a quiet room last time. You are just pointing at that record.

That record is the identity. Your name is just how you point at it.

The software. Anna has a user record in Acme's system. It has an ID like usr_8f3a, an email, a department, a manager. When Anna types her email into a login form, she is doing the same thing as you at the desk: claiming to be a specific record.

The claim alone proves nothing. Anyone can walk up and say "I'm Dmitrijs".

note
Deeper cut: identifiers are not identities

An email is an identifier, not the identity. Emails change, get recycled, and domains expire and get re-registered (a real, repeatedly exploited way to take over accounts). Good systems key everything on a stable, never-reassigned ID and treat email as an attribute.

In OpenID Connect this is the sub claim, and it is only unique per issuer, so the actual key is the pair (iss, sub). Also, the record often doesn't live in the app at all: it lives in an identity provider (Entra ID, Okta, Google Workspace…), and the app just trusts it. More on that in a second.

Authentication: prove it

The hotel. The receptionist asks for your passport. Photo matches your face, name matches the booking. Fine, you are you.

Then comes the important bit. The receptionist gives you a key card.

From now on nobody asks for your passport. Not the elevator, not the room door, not the pool. They check the card. The passport check happened once, and the card is proof that it happened.

The software. Anna logs in: password, passkey, a push notification on her phone, whatever Acme set up. That's authentication. Proving the claim from the previous step.

And then, exactly like in the hotel, the app hands her something to carry around: a session cookie or a token. Every following request carries that instead of her password.

This is why stealing a session or a token is so attractive to attackers. Nobody needs your passport if they can pick up your key card from the bar counter.

Authentication: a claim, a proof, and a token that is reused afterwardsAnna claims to be [email protected], proves it with a passkey, and receives a session token. Every following request, such as GET /orders, GET /orders/1041 and POST /orders, carries the token instead. The hotel equivalents are the booking, the passport at the desk, the key card, and the card at every door.IDENTITYAUTHENTICATIONSESSIONEVERY REQUEST AFTER I’m Anna[email protected] Passkeysigned challenge Session tokensid=7f3c…GET /orders GET /orders/1041 POST /orders “Hi, I have a booking” Passport at the desk Key card Card at every door
fig 2Authentication happens once. Anna proves who she is, and from then on every request carries a token instead of her passkey. Which is exactly why a stolen token is worth stealing.Nobody asks for your passport at the pool. They check the card.
note
Deeper cut: federation, and why the hotel trusts a passport

The hotel doesn't verify you itself. It trusts that a government checked you and issued a document it can validate. That's federation, and it's how most corporate login works: Acme Purchasing never sees Anna's password. Anna authenticates with Acme's identity provider, which hands the app a signed statement saying "this is usr_8f3a, authenticated 30 seconds ago, with MFA". In OpenID Connect that statement is the ID token. In older enterprise land it's a SAML assertion.

Authentication factors are traditionally something you know (password), have (phone, security key) or are (fingerprint). Passkeys are the interesting modern one: the credential is bound to the website's origin, so a phishing site simply can't ask for it. Most "MFA" is not phishing resistant. Passkeys actually are.

Authorization: what can you do?

The hotel. Your card opens room 412. It doesn't open 413. It opens the pool, because you are a gold member. It doesn't open the staff kitchen, no matter how nicely you ask.

Every door asks the same question: can this card open me?

And it only works because of the two steps before. If the hotel skipped the passport check, the pool door would be asking "is this person gold?" about someone who just said they were you. Authorization on top of weak authentication is decoration.

The software. Anna clicks "Approve" on an order. The API has to decide whether to let her. Stripped down, every authorization check in every system answers one question:

Can this subject do this action on this resource?

Can usr_8f3a do approve on order:1042? Yes or no.

That's it. Everything else in this note is about how systems answer that question, and who the subject is.

note
Deeper cut: authentication happens once, authorization happens always

Authentication is an event. Authorization is continuous: it runs on every request, often several times per request. That's why its performance, caching and consistency matter so much more than people expect.

The classic vocabulary (from XACML, still used everywhere) splits the job up: the PEP (policy enforcement point) is the door lock, the PDP (policy decision point) is whatever decides, the PIP (policy information point) is where facts like "is this guest gold?" come from, and the PAP (administration point) is where someone writes the rules.

Fun fact: HTTP 401 Unauthorized actually means unauthenticated. 403 Forbidden is the real "not authorized". The spec named it wrong in 1996, and we have been explaining it in code reviews ever since.

One more thing, and it's an opinion. Identity and authentication are mostly solved problems you can buy. Authorization is not, because the rules are your business. No vendor knows that at Acme, team leads can approve orders up to €2,000 but not their own. That knowledge has to live somewhere, and "somewhere" is usually a couple hundred if statements.

QuestionNameHotelAcmeWhen it fails
1Who are you?Identity"I have a booking"usr_8f3aWrong person gets the record
2Prove itAuthenticationPassportPasskey, MFA401
3What can you do?AuthorizationCard opens 412 and the poolAnna can request, Ben can approve403

How the "yes or no" gets decided

So the question is always subject, action, resource. The interesting part is where the answer comes from. There are a few families of models, and each one grew out of the limitations of the previous one.

Lists on the door (ACLs)

The hotel. Imagine the pool door with a paper list taped to it: names of everyone allowed in. Guard checks the list.

Simple. Also a nightmare for a hotel with 400 guests changing daily.

The software. An access control list attaches the "who can do what" directly to each resource. Unix file permissions, S3 bucket policies, "Share with…" in Google Drive. Order 1042 has a list: Anna can view, Ben can approve.

ACLs are great at answering "who can access this thing?" and awful at "what can Anna access?". You'd have to check every list in the building. And when Anna leaves, someone has to find her name on every door.

Roles (RBAC)

The hotel. Instead of names, the hotel thinks in roles: guest, gold guest, housekeeping, front desk, manager. The pool door lets in gold guests. The linen room lets in housekeeping. A new housekeeper gets the housekeeping role and every right door opens on day one.

The software. Role-based access control puts a layer in between people and permissions. Permissions go to roles, roles go to people.

PermissionRequesterApproverFinance
1Create order allow allow deny
2Approve order deny allow deny
3Pay invoice deny deny allow
4View all orders deny deny allow

Anna is a Requester. Ben is a Requester and an Approver. Done.

RBAC is by far the most common model in the wild, and for good reason. It's easy to understand, easy to audit ("who is an Approver?" is one query), and it maps to how companies already think about jobs.

Now the problems. According to the table, Ben can approve any order. From any team. For any amount. Including his own.

You can fight that with more roles: Approver-Platform-Team, Approver-Under-2000, Approver-Platform-Team-Under-2000… and now you have a role explosion. I have seen systems with more roles than employees. Not a joke.

The real issue is that a role describes the person, but half the rules depend on the thing: whose order is it, how big is it, which team.

RBAC as designed versus RBAC after role explosionTop: three people (Anna, Ben, Priya) map to three roles (Requester, Approver, Finance), which map to four permissions. Bottom, two years later: four people, including Sam who left in 2024 but still has roles, map to eleven roles such as Approver-Platform-Under-2000, Approver-Approver and Ben-Temp-Do-Not-Delete, with a tangle of lines to the same four permissions.AS DESIGNEDPEOPLEROLESPERMISSIONS Anna Ben PriyaRequesterApproverFinancecreate orderapprove orderpay invoiceview all ordersTWO YEARS LATER · 4 PEOPLE, 11 ROLESPEOPLEROLESPERMISSIONS Anna Ben Priya Sam (left 2024)RequesterRequester-LegacyApproverApprover-PlatformApprover-Platform-Under-2000Approver-Data-EUApprover-Temp-Q3-2024Approver-ApproverBen-Temp-Do-Not-DeleteFinanceFinance-ReadOnly-Except-Travelcreate orderapprove orderpay invoiceview all ordersNobody knows what Approver-Approver does. Nobody dares to delete it.
fig 3RBAC as designed, and RBAC after a couple of years of "can you just give me access to…". Every role on the bottom exists for a reason. Nobody remembers most of them.The hotel starts with guest, staff and manager. Then it adds "gold guest, but only weekends, but not the spa".
note
Deeper cut: RBAC is more than a table

The NIST RBAC model (the one standardized in the early 2000s) has levels: flat roles, role hierarchies (Manager inherits Employee), and constraints like separation of duties ("nobody may be both Requester and Payer"). Most real systems implement only the first level and bolt the rest on in code.

Cloud RBAC (Azure, GCP) quietly fixes part of the problem by attaching role assignments to a scope: "Approver on resource group Platform". That's RBAC plus a resource hierarchy, which is already halfway to the next two models.

Attributes and rules (ABAC)

The hotel. Some rules have nothing to do with who you are. The pool is open 7:00 to 22:00. The spa is 18+. Late checkout is fine for gold guests if the hotel is under 80% occupancy. The door isn't checking a list or a role, it's evaluating a rule over facts: about you, about the room, about the time.

The software. Attribute-based access control decides with rules over attributes of the subject, the action, the resource and the environment. Acme's actual approval rule looks something like this:

text
1allow approve on order when2  user.role           == "approver"3  and order.costCenter == user.costCenter4  and order.amount     <= user.approvalLimit5  and order.requester  != user.id

One rule. No role explosion. Ben can approve orders from his own cost center, up to his limit, and never his own.

That's the upside: ABAC is very expressive, and it handles amounts, time windows, locations and "not your own thing" naturally.

The downside is the reverse of RBAC. "Can Ben approve order 1042?" is easy. "Who can approve order 1042?" or "what can Ben approve?" means evaluating rules against everyone, or everything. Auditors hate that. And the decision is only as good as the attributes: if Anna's cost center is stale in the HR system, the rule is confidently wrong.

note
Deeper cut: the hard part of ABAC is the data

Writing the rule takes ten minutes. Getting user.approvalLimit, order.costCenter and "is this device managed?" to the decision point, fresh and trustworthy, takes the rest of the project. That's the PIP from earlier, and it is where most ABAC deployments quietly struggle.

ABAC's formal ancestor is XACML, an XML policy language that was very thorough and that almost nobody enjoyed writing. The modern tools (more on them below) are basically ABAC with a nicer syntax.

Relationships (ReBAC)

The hotel. Why can you open room 412? Not because of a role. Not really because of a rule. Because you are on the booking for 412. Your partner is on the same booking, so their card opens it too. Your kid's card opens the connecting room, because the booking says so.

Access follows relationships.

The software. Relationship-based access control models the world as a graph and asks whether a path exists. Why can Ben approve Anna's monitor order?

text
1order:1042  --requested_by-->  user:anna2user:anna   --member_of----->  team:platform3user:ben    --lead_of------->  team:platform

Rule: "a team lead can approve orders requested by members of their team". There's a path, so yes.

If this looks familiar, it's because you use it every day. Google Drive works exactly like this: a document sits in a folder, the folder is shared with a group, Anna is in the group, so Anna can open the document. Nobody assigned Anna a role for that document.

ReBAC is great for anything with ownership, sharing, hierarchy and teams, which is most modern SaaS. It's what RBAC wanted to be when it said "Approver of this team".

ReBAC relationship graph: can Ben approve order 1042?A graph of relationships. user:ben is lead_of team:platform. user:anna is member_of team:platform. order:1042 is requested_by user:anna. The path from Ben to order 1042 is highlighted, so the check "can user:ben approve order:1042" is allowed. Separately, order:1099 is requested_by user:carl, who is member_of team:data. Nothing connects Ben to it, so "can user:ben approve order:1099" is denied.lead_ofmember_ofrequested_byrequested_bymember_of user:ben team:platform order:1042 user:anna order:1099 user:carl team:dataCHECKcan user:benapprove order:1042? ALLOWpath via team:platformCHECKcan user:benapprove order:1099? DENYno path from ben
fig 4ReBAC answers by looking for a path. Ben leads the team Anna is in, and Anna requested order 1042, so the rule "a team lead can approve orders requested by their team" finds one. Nothing connects Ben to Carl’s order.Your card opens room 412 because you are on the booking, not because of your role.
note
Deeper cut: Zanzibar and its descendants

Modern ReBAC comes mostly from Google's Zanzibar paper (2019), describing the system behind permissions in Drive, YouTube, Calendar and others. Relationships are stored as tuples like team:platform#member@user:anna, and checks are graph traversals.

The genuinely hard part is consistency, the "new enemy problem": you remove Bob from a folder, then add a secret document to it. A stale replica can evaluate the second event without the first, and Bob sees the document. Zanzibar solves it with consistency tokens ("zookies"). OpenFGA, SpiceDB, Permify and others are open implementations, and most now support conditions on relationships (OpenFGA conditions, SpiceDB caveats), which is ABAC sneaking back in.

A few other names you'll run into

ModelOne-linerHotel
1DAC (discretionary)Owners decide who gets access to their stuffYou give a friend a spare card to your room
2MAC (mandatory)A central authority labels everything, nobody can overrideFloor 20 is for diplomats. Period.
3PBAC (policy-based)Mostly a marketing name for ABAC with policies as code-
4CapabilitiesHolding an unforgeable token is the permissionThe key card itself, whoever holds it

The last row matters for this series. A physical key doesn't care who holds it. That's a feature when you want to hand authority to someone else, and a disaster when it leaks. It'll come back in the note on delegation.

So which one?

All of them, honestly. I have never seen a real system running a pure model.

The usual shape is roles for the coarse stuff ("is this person allowed in the finance area at all?"), relationships for ownership and sharing ("is this order from your team?"), and attribute conditions for the fiddly bits ("under €2,000, not your own, not on weekends").

They aren't really competitors. They are three different ways of finding the facts needed to answer the same question: subject, action, resource.

Three authorization models answering one questionThree cards feed into one decision. RBAC: Ben has the approver role. ReBAC: Ben is lead of Anna’s team. ABAC: the order is €640, under his €2,000 limit, and not his own. Combined, the answer to "can user:ben approve order:1042?" is allow.andRBACwhat is Ben? ben has role approverREBAChow is Ben connected? ben lead_of anna’s teamABACwhat are the facts? €640 ≤ €2,000 · not his ownCHECKcan user:benapprove order:1042? ALLOW
fig 5One question, three ways of finding the facts. Real systems usually combine them: a role to get in the door, a relationship for "is this yours", and attributes for the fiddly limits.

Many ways to get it wrong

Here is the thing people outside security tend not to know.

Broken access control is the number one risk in the OWASP Top 10. It took the top spot in 2021 and kept it in the 2025 edition, now covering 40 different weakness types, the most of any category. The OWASP API Security Top 10 is even more blunt: three of its top five risks are authorization bugs.

Not injection. Not crypto. "The app let someone do something they shouldn't have."

And that's with humans clicking buttons and code written by people. No AI involved. Let's look at the usual suspects.

Changing the number in the URL

The hotel. You find out the room door doesn't actually check your card against the room. It checks that it's a valid card. So your card opens 413. And 414.

The software. Anna opens GET /orders/1041 and sees her order. She changes it to /orders/1042. The API checks that she is logged in, but not that order 1042 is hers.

This is called IDOR (insecure direct object reference), or BOLA (broken object level authorization) in the API world. It's the most boring bug imaginable, and it is everywhere. In 2019 First American Financial exposed around 885 million mortgage-related documents this way: sequential IDs in URLs, no ownership check.

The hidden button

The hotel. The staff door isn't locked. It just says "Staff only".

The software. The "Pay invoice" button only shows up for Finance. But POST /invoices/77/pay doesn't check the role, so anyone who knows the URL (or opens browser dev tools) can call it. Hiding UI is not authorization.

The confused deputy

The hotel. The front desk clerk can open every room. You say "I locked myself out of 413", confidently, and they open it. You never had authority over 413. The clerk did, and you borrowed it.

The software. A confused deputy is a program with more authority than its caller, tricked into using it on the caller's behalf. Acme Purchasing has a "fetch product details from URL" feature. Anna gives it an internal URL instead, and the server, which can reach internal systems, happily fetches it for her. That's SSRF, and it's the reason SSRF was folded into broken access control in the 2025 OWASP list. The 2019 Capital One breach started roughly like this: a server tricked into fetching cloud credentials it was allowed to see and the attacker wasn't.

idea
Remember this one

The confused deputy is from 1988, and I think it's about to become the most important idea in this whole series. An agent is, more or less by definition, a deputy which acts on instructions from text it didn't write.

Access that never goes away

The hotel. You checked out yesterday. Your card still opens the room.

The software. Anna moves from Platform to Data. Her old Approver role stays, because removing access is nobody's job and adding it is somebody's urgent ticket. Two years later she has permissions from four teams. This is privilege creep, and the fix (the "joiner-mover-leaver" process) is less a technology than an act of discipline.

"Just give it admin, we'll fix it later"

The hotel. The laundry company gets a master key, because making a special one was annoying.

The software. invoice-sync needs to read approved orders. It gets a service account with full admin rights, because the narrower permission didn't exist yet and the release was on Friday. Nobody fixes it later. Later never comes.

Forty endpoints, thirty-nine checks

The software. When authorization lives in if statements scattered across the codebase, every new endpoint is a new chance to forget one. And nobody notices, because a missing check doesn't break anything. It just lets something through.

FailureHotelAcmeResult
1IDOR / BOLACard opens any room/orders/1042 isn't Anna's allow
2Missing function check"Staff only" sign, no lockPay endpoint doesn't check role allow
3Confused deputyClerk opens 413 for youServer fetches internal URL allow
4Privilege creepCard works after checkoutAnna keeps old Approver role allow
5Over-permissioned serviceLaundry gets master keyinvoice-sync is admin allow

Every single one of those should have been a deny.

So why is this so hard?

My take: three reasons.

It's business logic. You can't buy a library that knows who at Acme may approve what.

It's everywhere. Every endpoint, every query, every background job, every export.

And the worst one: it fails silently. A broken feature gets a bug report. A broken permission check returns 200 OK with a nice JSON body, to the wrong person. Our tests check that people can do things. Very few check that they can't. Nobody files a ticket saying "I was able to see something I shouldn't have".

So, before anyone mentions agents: authorization is already a hard, unglamorous, badly tested problem. I think that alone makes it worth writing about.

The actual machinery

Now let's go one level down. Everything so far was mostly about humans in one app. Real systems have programs talking to programs, third-party apps, and tokens flying between them.

Not everyone is a person

The hotel. Not everyone walking the corridors is a guest. There's the laundry company, the elevator maintenance crew, the booking website pushing reservations into the hotel system, the minibar restocking robot. They all need to open doors too.

The software. invoice-sync runs at 2 AM. Nobody is logged in. Whose permissions does it use?

The wrong answers are "Ben's" and "the admin's". The right answer is: its own. That's a service account (also called a machine identity or non-human identity): an identity that belongs to a program instead of a person.

Traditionally, a service account authenticates with a secret: an API key, a client secret, a certificate. Like the laundry guy's badge. It works, but badges can be copied, and secrets end up in config files, CI logs and, occasionally, public GitHub repos.

The modern answer is workload identity. Instead of carrying a secret, the program proves who it is by where and how it runs. The platform vouches for it: "this is the invoice-sync job, running in Acme's Kubernetes cluster, in the finance namespace." In hotel terms, the elevator doesn't need a badge. It's bolted into the building. Everyone knows it's the elevator.

Secret-based service account versus workload identityLeft: a CLIENT_SECRET that never expires, copied to invoice-sync, a .env file in git, a CI log and an old laptop. Every copy works equally well. Right: inside Acme’s Kubernetes cluster, the platform issues invoice-sync a signed identity that expires in an hour, which Acme login trusts. There is nothing long-lived to copy.SECRET-BASED CLIENT_SECRETnever expires invoice-sync .env in git CI log old laptopEvery copy is invoice-sync,as far as Acme login can tell.WORKLOAD IDENTITYACME K8S · FINANCEsigned · expires in 1htoken exchange Kubernetesknows exactly what runs where invoice-syncspiffe://acme.com/finance/invoice-sync Acme logintrusts the platform’s signatureNothing long-lived to copy.
fig 6Two ways for a nightly job to prove who it is. On the left, a secret that works for whoever holds a copy. On the right, the platform vouches for the job because of where it runs, with a credential that expires within the hour.The laundry guy’s badge can be copied. The elevator is bolted into the building.
note
Deeper cut: workload identity in practice

AWS IAM roles for EC2/EKS, GCP service accounts, Azure managed identities, Kubernetes service account tokens: all variations of "the platform issues short-lived, signed credentials to the workload". Workload identity federation extends it across boundaries, the best known example being GitHub Actions getting an OIDC token and swapping it for AWS credentials, no stored keys. SPIFFE/SPIRE standardizes the whole thing in a platform-neutral way, with IDs like spiffe://acme.com/finance/invoice-sync.

Machine identities outnumber human ones by a very large factor in most companies (the figures you'll see quoted vary wildly, but it's never close). They also tend to have the broadest permissions and the least oversight. Nobody does quarterly access reviews for a cron job.

OAuth: the valet key

The hotel. You arrive by car. You don't hand the valet your house keys, your passport and your credit card. You hand them the valet key: it starts the engine and opens the doors. It doesn't open the trunk or the glovebox. And it's useless after you pick the car up.

The software. Anna wants ExpenseBuddy, a third-party app, to read her Acme orders and build expense reports.

The old way (and I mean pre-2010, but also some tools today): Anna types her Acme password into ExpenseBuddy. Now ExpenseBuddy can do everything Anna can do, forever, until she changes her password. Which breaks every other integration too.

OAuth 2.0 is a protocol for handing out valet keys instead. It has four parties:

  • Resource owner: Anna, whose orders these are.
  • Client: ExpenseBuddy, the app asking for access.
  • Authorization server: Acme's identity provider, which issues the keys.
  • Resource server: Acme Purchasing's API, which accepts the keys.

The flow, simplified:

  1. ExpenseBuddy sends Anna to Acme's authorization server: "ExpenseBuddy would like to read your orders."
  2. Anna logs in (authentication, at Acme, not at ExpenseBuddy) and sees a consent screen. She clicks Allow.
  3. The authorization server sends Anna back to ExpenseBuddy with a one-time code.
  4. ExpenseBuddy exchanges that code, server-to-server, for an access token.
  5. ExpenseBuddy calls Acme Purchasing's API with the token.

ExpenseBuddy never sees Anna's password. The token only allows reading orders. Anna can revoke it without changing anything else.

That's delegated authorization: Anna delegates a slice of her authority to an app.

OAuth authorization code flow between Anna, ExpenseBuddy, Acme login and the Purchasing APIAnna asks ExpenseBuddy to connect her orders. ExpenseBuddy sends her to Acme login asking for orders:read. Anna logs in and allows it on a consent screen. Acme login sends a one-time code back to ExpenseBuddy through the browser. ExpenseBuddy exchanges the code for an access token with scope orders:read, valid for 15 minutes, and uses it to call GET /orders on the Purchasing API, which returns Anna's orders. Annaresource owner ExpenseBuddyclient Acme loginauthorization server Purchasing APIresource serverconnect my Acme ordersrequest orders:readvia Anna’s browser1log in, then review access2LOGIN.ACME.COMExpenseBuddy wants to:· read your ordersAllowpasskey + Allowone-time codevia Anna’s browser3code + client secret4access token · orders:read · 15 minGET /orders + token5Anna’s orders
fig 7The OAuth authorization code flow. Numbers match the steps in the text. Anna logs in at Acme, never at ExpenseBuddy, and what comes back is a token for one job: reading orders.You hand the valet a valet key, not your house keys.

There's a second flow that matters just as much: client credentials. No user involved, the client just authenticates as itself and gets a token. That's how invoice-sync gets a token at 2 AM. Service accounts and OAuth meet here.

note
Deeper cut: OAuth is not authentication

The most common OAuth misunderstanding: it's a protocol for authorization, and the access token is meant for the API, not for the client to learn who the user is. "Sign in with Google" is OpenID Connect, a thin identity layer on top of OAuth which adds the ID token. Apps that used plain OAuth access tokens as proof of login have historically been easy to break.

Also worth knowing: OAuth 2.1 consolidates a decade of security lessons (implicit grant gone, PKCE required for everyone, exact redirect URI matching). Token exchange (RFC 8693) lets a service swap one token for another, which is how "on behalf of" chains between services are usually built. That one gets its own note next time.

Access tokens: the key card

The hotel. The key card is the thing that actually opens doors. The door doesn't call the front desk every time. It reads the card and trusts it, because only the front desk can make valid cards.

The software. The access token is the key card. Often it's a JWT: a small JSON document, signed by the authorization server, so the API can verify it without calling anyone. Here's what ExpenseBuddy's token might look like, decoded:

json
1{2  "iss": "https://login.acme.com",3  "sub": "usr_8f3a",4  "aud": "https://purchasing.acme.com",5  "client_id": "expensebuddy",6  "scope": "orders:read",7  "iat": 1789812000,8  "exp": 17898129009}
  • iss: who issued it. The API only trusts tokens from Acme's login server.
  • sub: whose authority this is. Anna's.
  • aud: who it's for. Only the Purchasing API should accept it.
  • client_id: who is holding it. ExpenseBuddy, not Anna.
  • scope: what it's for. Reading orders.
  • iat / exp: issued at, expires at. Fifteen minutes here.

The API checks the signature, the issuer, the audience and the expiry. If all good, it knows who (Anna), through what (ExpenseBuddy) and for what (reading orders).

The catch: these are bearer tokens. Like a key card, whoever holds it can use it. No questions asked. That's why they're short-lived, and why long-lived refresh tokens (used to get new access tokens) are guarded much more carefully.

note
Deeper cut: the problems with self-contained tokens

A JWT is valid until it expires. You can't really "un-sign" it, so revocation is hard. The alternatives are opaque tokens plus introspection (RFC 7662), where the API asks the authorization server every time, trading latency for control. Most systems compromise with short lifetimes.

The fix for "whoever holds it can use it" is sender-constrained tokens: DPoP (RFC 9449) or mutual TLS bind the token to a key the client holds, so a stolen token alone is useless. Adoption is growing, but bearer tokens are still the default almost everywhere.

And forgetting to check aud is a classic: a token issued for one API gets replayed against another one that trusts the same issuer.

Scopes: what the valet key is for

The hotel. The valet key opens doors and starts the engine. Not the trunk. That restriction is the scope.

But here is the part people miss: the valet key can't do anything the car owner couldn't. If you rented the car and the rental contract says "no leaving the country", the valet can't leave the country with it either.

The software. A scope like orders:read limits what the client may do with the user's authority. It does not grant anything on its own.

So what ExpenseBuddy can actually do is the intersection: what Anna can do, and what the token's scopes allow.

Scopes intersect with the user’s own permissionsTwo overlapping circles. Left: what Anna can do, including creating orders, cancelling her orders and updating her profile. Right: what the token with scope orders:read allows, including reading Ben’s orders and reading all orders. Only the overlap, reading Anna’s orders, is allowed for ExpenseBuddy. The rest is denied, either because the scope says no or because Anna can’t.what Anna can dowhat the token allowsscope: orders:readcreate orderscancel her ordersupdate her profilereadAnna’sordersread Ben’s ordersread all orders DENYscope says no ALLOWboth say yes DENYAnna can’t
fig 8A scope doesn’t grant anything by itself. What ExpenseBuddy can actually do is the overlap: things Anna is allowed to do and the token allows. The API has to check both.What the car owner may do, intersected with what the valet key opens.

This means the API still has to do its own authorization. orders:read doesn't mean "read all orders", it means "read orders, as Anna". If the API only checks the scope and not whether order 1042 is Anna's, we're back to the IDOR from earlier, just with extra steps.

Request from ExpenseBuddyAnna can?Scope allows?Result
1Read Anna's order 1041yesyes allow
2Read Ben's order 1099noyes deny
3Create a new order for Annayesno deny
4Approve an ordernono deny

Scopes also have a granularity problem. They are defined at design time, by the API developer, and they're usually coarse: orders:read, orders:write. Try to make them precise and you get orders:read:team:platform:under-2000, which is role explosion wearing a different hat.

note
Deeper cut: beyond string scopes

Rich Authorization Requests (RFC 9396) replace the scope string with structured JSON, authorization_details, which can carry things like "one payment of €640 to this vendor". It came from open banking, where "payments:write" was obviously not enough. Keep it in mind, it's the closest thing the OAuth world has to what later notes will be poking at.

Policy engines: one rulebook instead of forty doors

The hotel. In an old hotel, each door had its own logic, wired in when it was installed. Change the pool hours, and someone walks around reprogramming locks. A modern hotel has one central system: the doors ask it, it answers. Change a rule once, every door follows.

The software. Remember the if statements scattered across forty endpoints? A policy engine pulls the decision out of the application. The app (the enforcement point) asks a question, the engine (the decision point) answers it.

The question has a very familiar shape. Here is roughly what it looks like in AuthZEN, the OpenID Foundation's standard API between the two, finalized in early 2026:

json
1{2  "subject": { "type": "user", "id": "usr_ben" },3  "action": { "name": "approve" },4  "resource": { "type": "order", "id": "1042" },5  "context": { "time": "2026-09-19T10:24:00Z" }6}

Subject, action, resource. Plus context. The answer is { "decision": true }.

And the rule lives in one place, written in a language made for it. Acme's approval rule from the ABAC section, in Cedar (the policy language behind AWS Verified Permissions):

cedar
1permit (2  principal in Acme::Role::"approver",3  action == Acme::Action::"approve",4  resource is Acme::Order5)6when {7  resource.costCenter == principal.costCenter &&8  resource.amount <= principal.approvalLimit9}10unless {11  resource.requester == principal12};

Readable, testable, reviewable, versioned in git. It's the difference between "the rules are somewhere in the code" and "the rules are here".

Other engines worth knowing: OPA with its Rego language (the default choice in the Kubernetes and cloud-native world), OpenFGA and SpiceDB for ReBAC, Cerbos, Oso, Casbin. Different languages, same idea.

note
Deeper cut: what policy engines don't solve

The rules are the easy part. The hard parts are:

  • Data. The engine needs to know Ben's approval limit and the order's cost center. Either the app sends everything in the request, or the engine syncs data, or it fetches it. Each option has its own consistency and latency headaches.
  • Lists. "Can Ben approve 1042?" is one check. "Show Ben every order he can approve" is a filtering problem, and naive loops over thousands of checks don't scale. Partial evaluation (OPA, Cedar) and ReBAC reverse lookups exist for exactly this.
  • Enforcement. A perfect engine is useless if one endpoint forgets to ask it. Which is the forty-endpoints problem again.

One request, end to end

Let's put it all together. ExpenseBuddy reads Anna's monitor order.

  1. Identity. Anna's record, usr_8f3a, lives in Acme's identity provider.
  2. Authentication. She logs in with a passkey, at Acme, not at ExpenseBuddy.
  3. Delegation. She consents, and ExpenseBuddy gets an OAuth access token with scope: orders:read, aud: purchasing, valid for 15 minutes.
  4. Token validation. Acme Purchasing's API checks signature, issuer, audience, expiry. Also that the scope covers reading orders.
  5. Authorization. The API asks the policy engine: can usr_8f3a, through expensebuddy, read order:1041? Anna requested that order. Allow.
  6. Response. ExpenseBuddy gets one order. Hers.

And at 2 AM, invoice-sync gets its own token through client credentials, using its workload identity instead of a stored secret, with a scope that lets it read approved orders and nothing else. (In the good version of the story, anyway. See "just give it admin".)

One request, end to end: ExpenseBuddy reads Anna’s orderIdentity: Anna’s record usr_8f3a lives in Acme login. Authentication: Anna logs in with a passkey. Delegation: she allows ExpenseBuddy orders:read, and Acme login issues ExpenseBuddy an access token. Token: ExpenseBuddy calls GET /orders/1041 with the token. Scope: the Purchasing API checks signature, issuer, audience, expiry and scope. Policy: the API asks the policy engine whether Anna, via ExpenseBuddy, may read order 1041, and gets allow. The API returns order 1041. Separately, at 2 AM, invoice-sync uses client credentials with Acme login and reads approved orders from the Purchasing API with no user involved. Annauser ExpenseBuddyclient Acme loginidentity provider Purchasing APIresource server Policy enginedecision pointUSER RECORDusr_8f3a · anna IDENTITYthe bookingpasskey AUTHENTICATIONthe passportallow ExpenseBuddy: orders:read DELEGATIONthe valet keyaccess token · 15 minGET /orders/1041 + token TOKENthe key card signature · iss aud · not expired scope orders:read SCOPEwhich doors it opensread order:1041?as Anna, via ExpenseBuddy POLICYthe rulebook ALLOWorder 1041MEANWHILE, AT 2 AM · NO USER INVOLVEDclient credentialsread orders Acme login invoice-sync Purchasing API SERVICE ACCOUNTthe laundry van
fig 9Every concept from this note in one request. Each layer adds a piece of the answer, and the API has to put them all together.

Every layer adds a piece of the answer:

LayerWhat it knows
1Identity providerWho Anna is and how she proved it
2OAuth tokenThat Anna delegated reading orders to ExpenseBuddy, until 10:39
3ScopesWhich kinds of actions the client may attempt
4Policy engineWhether this subject may do this action on this specific resource
5APIEnforces all of it, or doesn't, and that's the bug

Wrapping up: known actors, known requests

That's the machinery. It's genuinely good. Decades of hard-won lessons, a lot of it written after something went badly wrong in public. I don't want this note to read as a setup for "and it's all broken". It isn't.

But look at what every piece of it quietly assumes.

We know who the actor is. A human with a passkey, or a program with a workload identity, which somebody deployed on purpose.

And we know what it will ask for. Roles are designed by admins who know the jobs. Scopes are designed by API developers who know what clients do. Policies are written for actions somebody anticipated. When ExpenseBuddy asks for orders:read, it's because a developer wrote code that reads orders, and that code will do exactly that, every time.

The human decides and clicks. Or the developer decided, months ago, and the code executes. Either way, by the time the request hits the API, the intent behind it was settled by someone we can point at.

allowed
This machinery works very well for known actors making known requests.

Which is most software. Or was.

What's next

Next note: delegation. OAuth already showed the basic idea, Anna handing ExpenseBuddy a slice of her authority. But what does it actually mean for software to hold authority that started with a human? What's the difference between acting as someone and acting for them? And what happens when the thing holding the valet key decides for itself where to drive?

Catch you in the next one.