API Design

Security · Lesson 05

Authentication vs Authorization

Proving who you are and deciding what you may do are two completely separate problems — and conflating them is how most access-control bugs are born.

⏱ 11 min Difficulty: core Prereq: HTTP basics

By the end you'll be able to

The bouncer and the guest list

Imagine a private gala. At the door stands a bouncer who checks your invitation — "you are who you say you are." That is authentication (authN). Inside, a separate coordinator checks whether you have a VIP wristband before letting you into the private lounge — "you have been verified, but can you go there?" That is authorization (authZ).

An API always needs both steps. AuthN answers "who are you?"; authZ answers "what are you allowed to do?" A 401 status code means the bouncer couldn't verify you at all. A 403 means you got past the door but the coordinator turned you away at the lounge.

Client request AuthN Gate "Who are you?" 401 if unknown AuthZ Gate "May you do this?" 403 if not allowed Resource 200 OK
Every inbound request must clear both gates. Reaching the resource means the caller was verified and permitted.

Sessions vs tokens: two ways to carry identity

Once a user logs in, the server needs a way to recognise them on every subsequent request. Two strategies dominate:

ApproachHow it worksUpsideDownside
Session cookie Server stores a session record; gives the client a random session ID in a cookie. Server looks up the ID on every request. Easy to invalidate instantly (delete the record). Requires shared, fast session storage — tricky to scale across many servers.
Token (e.g. JWT) Server issues a signed token containing the user's identity and claims. Client sends it on every request; server verifies the signature. Stateless — any server can verify without shared storage. Hard to revoke before expiry; token must be kept short-lived.

Neither is universally better. Traditional web apps often prefer sessions; REST APIs distributed across many nodes usually choose tokens for their stateless verification model.

Roles, attributes, and per-object checks

RBAC (Role-Based Access Control) assigns permissions to roles ("admin", "viewer") and assigns roles to users. Simple, auditable, but coarse-grained — a viewer can read any document.

ABAC (Attribute-Based Access Control) evaluates policies against attributes of the user, the resource, and the environment ("user.department == resource.department AND time.isBusinessHours"). More expressive, but more complex to implement and audit.

Neither replaces the most important check: does this user own or have explicit access to this specific object? Forgetting this produces BOLA (Broken Object Level Authorization), also called IDOR (Insecure Direct Object Reference). It is the top API vulnerability in the OWASP API Security Top 10.

Worked example: 401 vs 403, and a per-object check

# Scenario 1 — no credentials at all
GET /api/invoices/99 HTTP/1.1

# Server response
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api.example.com"

# Scenario 2 — valid token, but wrong role
GET /api/admin/users HTTP/1.1
Authorization: Bearer <viewer-token>

HTTP/1.1 403 Forbidden
{ "error": "insufficient_scope" }

# Scenario 3 — valid token, correct role,
# but invoice belongs to a different user (BOLA if not checked)
GET /api/invoices/99 HTTP/1.1
Authorization: Bearer <user-42-token>

# Correct server-side pseudocode — ALWAYS verify ownership
invoice = db.get("invoices", id=99)
if invoice.owner_id != current_user.id:
    return 403 Forbidden   # not 404 — see tip below
return 200 invoice
🎯 Interview angle

"What's the difference between 401 and 403?" is a classic screen question. The precise answer: 401 means the request lacked valid credentials — the client needs to authenticate first. 403 means the client is authenticated but lacks permission. A 403 on an object you shouldn't know exists could reveal its existence, which is why some APIs return 404 instead — a valid tradeoff worth mentioning.

⚠️ Common trap

Authenticating at the endpoint level but not at the object level. You verify the user is logged in and has the "invoices:read" scope — then immediately fetch the invoice by the ID from the URL without checking ownership. Any authenticated user can now read any invoice by cycling through integer IDs. This is BOLA/IDOR and it is embarrassingly common. The fix is one extra line: compare the resource's owner ID to the token's subject claim.

✅ Do this, not that

Do add an owner_id (or tenant_id) filter to every query that retrieves user-specific data: WHERE id = ? AND owner_id = ?. Don't fetch first and check ownership in code after — a race condition or oversight in the check logic still leaks data. Pushing the ownership constraint into the query is the safest pattern.

Under the hood: how a request flows through auth middleware

Every authenticated API request passes through four distinct checkpoints in sequence. Understanding each one — and what it returns when it fails — makes it easy to reason about access-control bugs.

① Extract credential from request ② Authenticate verify token/ key signature ③ Load principal decode claims, attach user ctx ④ Authorize object role + ownership check THIS record Handler 200 OK 401 missing/invalid credential 403 wrong role or not owner
Auth middleware executes left-to-right. Stages ①–② produce a 401 on failure (identity unknown). Stages ③–④ produce a 403 on failure (identity known but not permitted). The BOLA gap lives exclusively in stage ④.

Here is each stage in concrete terms, traced for a GET /api/invoices/99 request carrying Authorization: Bearer eyJhbGci...:

# ① EXTRACT — middleware reads the Authorization header and strips "Bearer " raw_token = request.headers["Authorization"].removeprefix("Bearer ") # if the header is absent or malformed → 401 with WWW-Authenticate: Bearer # ② AUTHENTICATE — verify the JWT signature and expiry payload = jwt.verify(raw_token, PUBLIC_KEY, algorithms=["RS256"]) # verifies signature, checks exp > now() and nbf <= now() # if signature bad or token expired → 401 "invalid_token" # ③ LOAD PRINCIPAL — decode the verified claims into a user context current_user = { id: payload["sub"], role: payload["role"], scopes: payload["scp"] } # attach to request context; downstream handlers read from here # ④ AUTHORIZE — two sub-checks: role/scope + object ownership if "invoices:read" not in current_user.scopes: return 403, "insufficient_scope" # role check fails invoice = db.query("SELECT * FROM invoices WHERE id = ?", invoice_id) if invoice.owner_id != current_user.id: return 403, "forbidden" # ← BOLA gap if this line is missing return 200, invoice # both checks passed

The BOLA gap: where auth passes but security fails

Broken Object Level Authorization occurs when stage ④ is split in two and only the first sub-check (role/scope) is written. The handler is correctly gated by a "logged-in user" check and the right scope — but the ownership comparison is never made. Any authenticated user with invoices:read can enumerate every invoice by cycling through IDs:

# Vulnerable handler — auth passes, object-level check is missing @app.get("/api/invoices/{invoice_id}") @require_scope("invoices:read") # ✓ scope checked def get_invoice(invoice_id, current_user): invoice = db.get("invoices", id=invoice_id) # ← fetches ANY invoice # no ownership check — BOLA vulnerability return 200, invoice # Attacker script (authenticated as user_42, cycling IDs): for id in range(1, 10000): r = requests.get(f"/api/invoices/{id}", headers={"Authorization": f"Bearer {TOKEN}"}) if r.status_code == 200: print(r.json()) # dumps every invoice in the DB

The fix is one extra line: if invoice.owner_id != current_user.id: return 403. Or, safer still, push the ownership constraint into the query so a race condition or oversight in application code cannot bypass it:

# Ownership pushed into SQL — the DB never returns a row that isn't yours
invoice = db.query(
    "SELECT * FROM invoices WHERE id = ? AND owner_id = ?",
    invoice_id, current_user.id
)
if not invoice:
    return 404   # 404 or 403 — see the 403-vs-404 tradeoff tip
return 200, invoice

How to debug & inspect it

The most common debug question is: "why am I getting a 401 or 403?" The answer lives in the token and the server logic, not in guesswork. These three steps resolve the vast majority of cases.

# Step 1 — decode the JWT without a library (shell) TOKEN="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyXzQyIiwic2NwIjpbImludm9pY2VzOnJlYWQiXSwiZXhwIjoxNzUwMDAzNjAwfQ.SIG" echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool { "sub": "user_42", "scp": ["invoices:read"], "exp": 1750003600 } # Check: is exp in the future? date -d @1750003600 date -d @1750003600 Fri Jun 15 01:00:00 UTC 2025 ← still valid # Step 2 — confirm the scope/role matches what the endpoint requires # token has "invoices:read" — does the endpoint need "invoices:write"? → 403 # Step 3 — test the object-level check directly with two different users curl -s -o /dev/null -w "%{http_code}" \ -H "Authorization: Bearer $TOKEN_USER_42" \ https://api.example.com/api/invoices/99 200 curl -s -o /dev/null -w "%{http_code}" \ -H "Authorization: Bearer $TOKEN_USER_99" \ https://api.example.com/api/invoices/99 # Should be 403 — if it's 200 the ownership check is missing (BOLA) 200 ← BOLA confirmed — user_99 can read user_42's invoice
SymptomStageCauseFix
401 with WWW-Authenticate: Bearer① ExtractNo Authorization header, or header doesn't start with Bearer Client must send the header; check for double "Bearer Bearer" or URL-encoding
401 "invalid_token" or "invalid signature"② AuthenticateToken tampered, wrong issuer key, or wrong algorithmDecode header to check alg and kid; verify server's key matches issuer's JWKS
401 "token_expired"② Authenticateexp in the past, or server clock skewRefresh the token; add a 30–60 s clock-skew allowance on the server
403 "insufficient_scope"④ Authorize — roleToken's scp/role doesn't include the required permissionRequest the correct scope at authorization time; check RBAC config
403 on a record you own④ Authorize — objectServer-side ownership check uses wrong field (created_by vs owner_id)Log the compared IDs server-side to find the field mismatch
200 on another user's record④ Authorize — objectOwnership check is missing entirely (BOLA)Add WHERE owner_id = ? to the query or an explicit equality check post-fetch

Authorization debug checklist:

  1. Decode the token payload (shell one-liner above) — confirm sub, role/scp, and exp.
  2. Match exp against the current time: date -d @<exp>.
  3. Compare the decoded scope/role against what the endpoint's middleware requires.
  4. Test object-level access with two different user tokens on the same object ID — a 200 from the non-owner proves BOLA.
  5. When a 403 fires for an object the user should own, add server-side logging of the compared IDs to find field-name mismatches.

In production: how leading APIs authenticate

Every major API solves the same two problems at once: prove who the caller is (authN), then decide what they may do (authZ). The mechanisms differ widely — from a single bearer key to a chain of signed credentials — but all of them ultimately embed a scope or policy that acts as the authZ boundary on the token itself.

API / ProviderAuthN mechanismAuthZ boundary on the credential
Stripe Bearer key sent in the Authorization header over TLS. Secret keys (sk_live_ / sk_test_) grant full account access; restricted keys carry only the permissions explicitly granted at creation time. Restricted-key scopes — e.g. read charges but not create payouts. The scope is baked into the key at issuance; the server enforces it on every call. See Stripe API keys docs.
AWS Signature Version 4 (SigV4): the client derives a signing key via an HMAC chain (date → region → service → aws4_request) and signs the canonical request. The resulting signature travels in the Authorization header; the secret never leaves the client. IAM policies attached to the identity (user, role, or assumed role) that signed the request. Every AWS API call is checked against IAM before proceeding. See AWS SigV4 docs.
GitHub GitHub Apps sign a short-lived JWT with the app's private key, then exchange it for a scoped installation access token (valid ≤ 1 hour). Personal access tokens (PATs) and OAuth Apps also exist but carry broader, less granular permissions. Installation token scopes are declared in the GitHub App's manifest and tightened at installation time by the repository admin. See GitHub App authentication docs.
HubSpot OAuth 2.0 (authorization-code flow) for third-party integrations; private-app access tokens for first-party scripts. Legacy API keys were deprecated in 2022 because they granted unscoped account-wide access. OAuth scopes (e.g. crm.objects.contacts.read) and private-app scopes are selected at setup; the access token carries only those scopes. See HubSpot OAuth docs.

Deep dive: scopes as the authZ boundary on a token

A scope is a string that names a permission — crm.objects.contacts.read, invoices:write, s3:GetObject. Its power comes from the fact that it travels inside the credential: the resource server does not need to query a separate permissions database on every call. It reads the token, checks the scope list, and accepts or rejects the call in a single step.

This makes scopes the primary authZ boundary for token-based systems. Stripe restricted keys embed their scope at issuance; GitHub installation tokens embed theirs at installation; HubSpot OAuth tokens list scopes approved on the consent screen. In all three cases, the authZ decision boils down to: does this token's scope list include the permission this endpoint requires?

The practical implication for API design: request the minimum scope your use case requires, and check the scope claim server-side on every call. A token that lacks a scope should receive a 403 with a body of {"error":"insufficient_scope"} — not a vague 401.

How leading APIs do it

🧠 Quick check

1. A user sends a request with a valid JWT, but their role does not include the required permission. The correct HTTP status is:

401 means the server cannot verify who is making the request. Here the identity is confirmed (valid JWT) but the permission is absent — that is 403 Forbidden.

2. BOLA (Broken Object Level Authorization) occurs when:

BOLA (aka IDOR) is a missing per-object ownership check. The caller is properly authenticated and may even hold the right role, but the API never verifies that the specific record belongs to them.

3. RBAC assigns permissions to:

Role-Based Access Control groups permissions into named roles (e.g. "viewer", "editor"). Users receive roles; permissions follow from the role. ABAC is the attribute-based variant that evaluates richer policies at runtime.

4. A key tradeoff of token-based (JWT) authentication vs session cookies is:

Tokens carry their own claims and can be verified by any server that holds the signing key, making them stateless and horizontally scalable. The downside is that a stolen token is valid until it expires unless you maintain a revocation list (which reintroduces state).

✍️ Exercise: spot the authorization bug

A developer writes the following endpoint for a note-taking app. Users are authenticated via JWT. Find all authorization problems and explain how you would fix each one.

POST /api/notes/{noteId}/share // Handler pseudocode note = db.notes.findById(noteId) if (!note) return 404 note.sharedWith.push(req.body.targetUserId) db.notes.save(note) return 200

Model answer:

  1. Missing ownership check (BOLA). Any authenticated user can share any note by guessing its ID. Fix: add if (note.ownerId !== currentUser.id) return 403 immediately after fetching the note.
  2. No validation of targetUserId. The target user might not exist, allowing phantom sharing or enumeration. Fix: verify the target exists in the users table before writing.
  3. Missing authN guard entirely (implied). If the middleware stack does not enforce a valid JWT on this route, unauthenticated callers reach this handler. Fix: ensure the route is behind the authN middleware, not just the "public" router.

Rubric: Full marks for identifying the BOLA gap with a correct fix. Partial marks for catching only the missing input validation or the middleware gap. Bonus: noting a 404 vs 403 disclosure tradeoff.

Key takeaways

Sources & further reading