Library
12 min read

Authentication vs authorization

Published

At Delhi airport you are checked twice. At the terminal door, a CISF officer looks at your ID, then at your face, then at your ticket: are you the person this ticket was booked for? That is authentication. An hour later at gate 42, the airline scans your boarding pass: is this person on this flight, and in which seat? That is authorization. The officer at the door does not care which flight you are on. The gate agent does not check your face again. Two different questions, asked by two different people, in a fixed order.

Every API you build asks the same two questions. Most security bugs in APIs come from asking only one of them, or from asking them in the wrong place.

Your answer

A user is logged in. They change the URL from /orders/1001 to /orders/1002 and see a stranger's order, with the stranger's address on it. Which of the two checks failed, and where in the code would you look for it?

Two questions, always in this order#

Authentication (authn) answers "who are you?". The input is a credential: a password, an OTP, a passkey, a signed token. The output is an identity, something like { id: "ravi", org: "acme", role: "editor" }. Nothing more. Authentication does not know what Ravi is trying to do.

Authorization (authz) answers "may this identity do this action to this thing?". The input is the identity from step one, plus the action (edit), plus the resource (invoice 4471). The output is yes or no.

The order is not a convention. Authorization needs an identity to reason about, so if the first question fails, the second one cannot even be asked. Try it below. Pick a caller and an action, and watch where the request stops.

Send a request through both gates
Who is calling
What they want to do to invoice 4471 (owned by Acme)
PATCH /invoices/4471 Authorization: Bearer eyJhbGci...
Gate 1 · authenticationpassed
Who are you?
signature ok, not expired
→ { sub: "asha", org: "acme", role: "viewer" }
Gate 2 · authorizationstopped here
May you do this, to this invoice?
edit needs editor+, you are viewer
HTTP/1.1 403 Forbiddeninvoice untouched
The server knows exactly who this is: asha, a viewer at Acme. Edit needs editor or above. Signing in again changes nothing, so 403 means "I know who you are, and the answer is no".

Spend a minute on three cases. The edited token: decoding a JWT is easy, anyone can read it, but changing it breaks the signature, so it fails at gate one. Asha deleting: gate one is perfectly happy, she is exactly who she says she is, and that is why gate two exists. Meera from Globex: an admin, signed in properly, and still stopped, because being an admin somewhere is not the same as being an admin here.

401 or 403: the status codes have a naming bug#

HTTP has one status code for each gate, and the first one is misnamed.

  • 401 Unauthorized means gate one failed. The credentials are missing, expired or invalid. RFC 9110 describes it as lacking "valid authentication credentials". It should have been called Unauthenticated. The client's correct reaction is to refresh the token or send the user back to the login page.
  • 403 Forbidden means gate two failed. The server knows who you are, and the answer is no. Logging in again will not help, so the client should stop retrying and show a message.

Mixing them up has real consequences. Return 403 for an expired token and a well written client gives up instead of refreshing, and your users get logged out for no reason. Return 401 for a permission problem and the client loops: refresh, retry, 401, refresh.

There is a third answer worth knowing. When the resource belongs to someone else entirely, many APIs return 404 instead of 403, so a stranger cannot even confirm the thing exists. Ask GitHub for a private repository you cannot see and you get a 404, not a 403.

How the two gates look in a real system#

Notice where each gate lives. Authentication happens once, at the edge. You prove who you are to an identity provider with a password and an OTP, it hands you a signed token, and from then on every service only has to check the signature, which takes microseconds and needs no database call.

Authorization happens on every request, next to the data. The gateway can do coarse checks ("does this token have the invoices:write scope at all?"), but only the invoice service knows that invoice 4471 belongs to Acme. That fact lives in its database. A check that needs the resource has to run where the resource is.

1/6 Authentication, the expensive kind. Something Ravi knows (the password) and something he has (the phone that got the OTP).

The bug behind most API data leaks#

Go back to the question at the top. A logged in user changes /orders/1001 to /orders/1002 and sees a stranger's order. Authentication worked perfectly. The server knew exactly who was asking. It just never asked whether that person was allowed to see order 1002.

This has a name, broken object level authorization (BOLA, also called IDOR), and it has been number one on the OWASP API Security Top 10 in both the 2019 and 2023 editions. It is common because the buggy code looks completely normal:

ts
// Logged in: yes. Allowed to see this invoice: never asked.
app.get('/invoices/:id', requireLogin, async (req, res) => {
  const invoice = await db.invoice.findUnique({ where: { id: req.params.id } })
  if (!invoice) return res.sendStatus(404)
  res.json(invoice)
})

The middleware name gives the game away. requireLogin on a route feels like security, and it is, for gate one. Gate two has no middleware that can do it for you, because only the handler knows which invoice was asked for.

One of each, in the news

Optus, 2022. The Australian telecom's customer data, about 10 million people's, was reported to have been pulled from an API endpoint that needed no authentication at all, with customer IDs that simply counted upwards. Gate one was missing.

USPS, 2018. A US Postal Service API let any logged in user of usps.com look up account details of other users, around 60 million accounts. Everyone was properly authenticated. Gate two was missing.

Ways to make the authorization decision#

Once you know who someone is, there are three common ways to decide what they may do. Most real systems start with the first and add the others where it stops fitting.

ChoiceWhat you gainWhat you payPick it when
RBAC: rolesUsers get roles (viewer, editor, admin), roles get permissions. Easy to explain and to audit.Roles multiply. You end up with "editor-mumbai-except-payroll".Almost every app on day one.
ABAC: attributesRules over facts about the user, the resource and the moment: "doctors see patients in their own ward, during their shift".Hard to answer "who can see this record?" because the answer depends on the moment.Regulated domains: health, banking, government.
ReBAC: relationshipsPermissions follow a graph: you can edit a doc because you are in a group that owns the folder it is in.Needs a dedicated service and a copy of the relationships, which can drift from your database.Anything with a "Share" button. Google built Zanzibar for this; SpiceDB and OpenFGA are open source versions.

For any of the three, you can write the rules as plain if statements in your code, or move them into a policy engine such as Open Policy Agent (OPA) or AWS's Cedar, where rules live in their own files, get reviewed like code and can be tested on their own. Start with if statements in one well named function (can(user, action, resource)), and move to an engine when several services need the same rules.

"Sign in with Google" is not OAuth

OAuth 2.0 was designed for authorization: letting an app read your Google Calendar without ever seeing your Google password. An OAuth access token says what the app may do. It does not say who you are. OpenID Connect (OIDC) is the thin layer on top that adds authentication, an ID token that says who signed in. "Sign in with Google" is OIDC. If an interviewer asks you to design login with OAuth, this is the distinction they are listening for.

Authn or authz?
Typing the OTP that arrived by SMS
The "Delete project" button is only shown to admins
Checking a JWT's signature and expiry
An OAuth token with scope invoices:read
A Google Doc shared as "anyone with the link can view"
A Postgres row level security policy: org_id = current_setting('app.org_id')

Checkpoint#

Checkpoint

1. A logged in user changes /orders/1001 to /orders/1002 and sees a stranger's order. What failed?

2. Your API returns 403 when an access token has expired. What goes wrong?

3. Where should "may Ravi edit invoice 4471?" be decided?

Worth memorising
  • Authentication: who are you? Credential in, identity out. Once, at the edge.
  • Authorization: may you do this, to this? Identity, action and resource in, yes or no out. Every request, next to the data.
  • 401 is gate one, 403 is gate two. 404 when you would rather not admit the thing exists.
  • Broken object level authorization is OWASP API risk number one. Scope every query to the caller.
  • OAuth is delegated authorization. OIDC adds authentication on top.
Say this in 60 seconds

Authentication answers who you are: a credential like a password, an OTP or a signed token goes in, and an identity comes out. Authorization answers whether that identity may do this action to this resource, and it can only run after authentication succeeds. In a real system you authenticate once with an identity provider, get a short lived signed token, and every service verifies the signature cheaply. Authorization runs on every request inside the service that owns the data, because only it knows who owns the record. A failed authentication is a 401 and the client should re-authenticate. A failed authorization is a 403, or a 404 if you don't want to reveal the resource exists. The most common API bug, broken object level authorization, is a request that is authenticated but never authorized, so I scope every query to the caller and keep the rules in one can function, using roles first and attributes or relationships when roles stop fitting.

Finished this one?Mark it complete to track your progress through Articles.