Filter concepts by levelShowing all levels.

System Design · Section 57

Authentication and Authorization

Level
intermediate
Read
22 min
Concepts
5

Authentication proves who is calling — sessions and cookies, JWTs, OAuth 2.0 and OpenID Connect, API keys, mTLS and service-to-service identity are all mechanisms for establishing that. Authorization is the separate decision of what an already-proven identity may do, and RBAC, ABAC, tenant isolation and object-level authorization are the models that make that decision concrete. The two are easy to conflate — a request can be perfectly authenticated and still need to be rejected because the caller has no permission for this specific action on this specific resource — and skipping that final, specific-resource check (object-level authorization) is common enough that OWASP ranks it the top API security risk.

What is true here

  1. Authentication (who) and authorization (what they may do) are two separate checks — a valid token only ever answers the first.
  2. Session cookies keep identity server-side and instantly revocable; JWTs are stateless and scale without a shared store, but cannot be revoked before expiry without adding a denylist back in.
  3. OAuth 2.0 is a delegated-authorization protocol; OpenID Connect layers a verified identity (the id_token) on top of it — the access token alone was never meant to prove who a user is.
  4. RBAC and ABAC decide broad categories of permission; only object-level authorization checks whether the caller owns the specific resource in the request.

What you will be able to do

  • Explain why a valid, unexpired token does not by itself prove a caller is allowed to perform a given action
  • Choose between session-cookie and JWT-based authentication for a given service's revocation and scaling needs
  • Distinguish what OAuth 2.0 provides from what OpenID Connect adds, and read the correct token for each purpose
  • Design an authorization check that verifies resource ownership, not just role, to avoid broken object-level authorization

The core distinction

Authentication and authorization are two separate questions — everything else in this section is either a way to answer the first or a model for deciding the second.

Authentication vs authorization: who is the caller, what can they do

coreintermediate

Authentication proves who is making a request — a login, a token, a certificate. Authorization decides what that already-proven identity is allowed to do. A system can get the first right and still be broken if it never checks the second.

Think of it as

Think of a building with a badge reader at the front door and a locked door on each floor. The badge reader authenticates you — it confirms you are an actual employee, not a stranger off the street. But a valid badge at the front door says nothing about which floors you should reach; a separate check at each floor door is what authorizes you into finance versus the shared kitchen. A building that only checks badges at the front door lets any employee wander into any floor.

text
# Authentication: establishes identity
user = authenticate(request.token)   # -> User(id=482) or reject (401)

# Authorization: checks what that identity may do
if not authorize(user, action='delete', resource=invoice):
    return 403  # known caller, disallowed action

What we're doing: Show a request that passes authentication cleanly but must still be rejected by authorization.

delete-invoice-request.txttext
1. POST /invoices/900/delete
   Authorization: Bearer <valid JWT for user_id=482>

2. Authentication step:
   - signature valid, not expired -> identity = user_id 482
   - this step succeeds; 482 is a real, logged-in user

3. Authorization step:
   - invoice_id 900 belongs to user_id 117, not 482
   - 482 has no admin role either
   - this step fails

4. Response: 403 Forbidden
   (NOT 401 -- the caller proved who they are just fine;
   they are simply not allowed to delete someone else's invoice)
2
Authentication succeeding only proves the JWT belongs to a real, currently-valid user_id 482 — it proves nothing yet about invoice 900.
8
This is the authorization check: same confirmed identity, a different question entirely — ownership of this specific resource.
12
The status code distinguishes the two failure modes for the client: 401 means "prove who you are again," 403 means "you already did, and the answer is still no."

Why this works: The request never fails to authenticate — the token is completely valid — which is exactly the case that exposes a system that conflates the two checks: code that stops at "token is valid" and treats that as permission would incorrectly let user 482 delete an invoice they do not own.

Treating a valid token as proof of permission

Wrong

text
def delete_invoice(request, invoice_id):
    user = authenticate(request.token)  # raises if invalid
    invoice = db.get_invoice(invoice_id)
    invoice.delete()
    # no check that 'user' owns or may act on 'invoice'

Better

text
def delete_invoice(request, invoice_id):
    user = authenticate(request.token)
    invoice = db.get_invoice(invoice_id)
    if not authorize(user, action='delete', resource=invoice):
        return Response(status=403)
    invoice.delete()

What you see: Any logged-in user can delete, read, or modify any other user's resources by ID — a valid session is silently treated as blanket permission, and the bug produces no error anywhere until someone notices data that should have been private is reachable by guessing or enumerating IDs.

Why: Authentication only confirms the caller is a genuine, currently-valid identity — it carries no information about which specific resources or actions that identity is permitted to touch. Skipping the authorization check does not fail loudly; it just silently grants access that was never supposed to exist, which is why this exact gap is the root cause behind a large share of real-world broken-access-control incidents.

Two separate checks on one request
Client
API
Auth check
Authz check
  1. 1. Request + credentials/token
  2. 2. Who is this? (authentication)
  3. 3. user_id 482 (identity confirmed)
  4. 4. Can 482 delete invoice 900? (authorization)
  5. 5. No — not the owner
  6. 6. 403 Forbidden
  1. Client → API: Request + credentials/token
  2. API → Auth check: Who is this? (authentication)
  3. Auth check → API: user_id 482 (identity confirmed)
  4. API → Authz check: Can 482 delete invoice 900? (authorization)
  5. Authz check → API: No — not the owner
  6. API → Client: 403 Forbidden

Authentication and authorization compared

Authentication and authorization compared
PropertyAuthenticationAuthorization
Question answeredWho is making this request?What is this identity allowed to do?
RunsFirst, once per session or requestAfter authentication, per action or resource
Typical mechanismPassword + session, JWT, OAuth token, mTLS certificateRBAC role check, ABAC policy, ownership check
Failure response401 Unauthorized (you are not who you claim, or not logged in)403 Forbidden (we know who you are; you still cannot do this)
Example questionIs this really user_id 482?Can user_id 482 delete invoice_id 900?

Remember: Authentication proves identity (who); authorization decides permission (what they can do with that identity). They are two separate checks, not one — a valid token only ever answers the first question.

See also: session and cookie vs token auth · rbac abac and object level authorization

Advertisement

Proving identity: end-user and machine mechanisms

Sessions and JWTs for the stateful-vs-stateless trade-off, OAuth 2.0 and OpenID Connect for delegated access, and API keys, mTLS and service identity for machine-to-machine calls with no human at the other end.

OAuth 2.0 and OpenID Connect: delegated authorization and identity

coreintermediate

OAuth 2.0 lets a user grant a third-party app limited access to their data on another service, without sharing their password — it is an authorization protocol. OpenID Connect (OIDC) is a thin identity layer built on top of OAuth 2.0 that adds "and here is who you actually are," which OAuth alone never promises.

Think of it as

OAuth 2.0 is like a hotel keycard system: you never hand the front desk your house keys (your password); instead they issue a keycard (an access token) scoped to specific doors (specific permissions) for a limited time. OpenID Connect is the ID check the front desk does before issuing that keycard — OAuth by itself only proves "this keycard opens these doors," it does not promise anything about whose face is behind the card; OIDC is what adds a verified identity document (an ID token) to the transaction.

text
# Authorization code flow, HTTP-shaped

GET https://provider.com/authorize
    ?response_type=code
    &client_id=<app_id>
    &redirect_uri=<callback_url>
    &scope=openid email profile   # "openid" scope -> this is OIDC
    &state=<csrf_token>
    &code_challenge=<pkce_hash>

# ... user logs in and consents at the provider ...
# provider redirects to: <callback_url>?code=<auth_code>&state=<csrf_token>

POST https://provider.com/token
    grant_type=authorization_code
    &code=<auth_code>
    &code_verifier=<pkce_secret>
    &client_id=<app_id>&client_secret=<app_secret>

# response: { "access_token": "...", "id_token": "<jwt>", "expires_in": 3600 }

What we're doing: Show why reading the access token instead of the id_token to identify a user is a real, exploitable mistake.

oidc-login-handler.txttext
1. User completes "Sign in with Provider".
2. App backend exchanges the auth code for:
   - access_token: opaque string, scope=openid email
   - id_token: JWT with claims {sub, email, aud, exp}

3. App needs to know: which user just logged in?

WRONG: call the provider's /userinfo endpoint using
       the access_token and trust whatever comes back
       as if it were validated locally -- if /userinfo
       is ever called against the wrong provider config
       or over a misconfigured proxy, nothing locally
       verifies the response's signature or audience.

RIGHT: verify the id_token's signature against the
       provider's published public key, check aud
       matches this app's client_id, check exp has
       not passed, THEN read the 'sub' claim as the
       verified, stable user identifier.
3
access_token and id_token are handed back together but serve entirely different jobs — this is the exact point OAuth-vs-OIDC confusion happens.
10
Trusting an unverified network response as identity skips the one step (signature + audience verification) that actually makes an id_token trustworthy.
16
The id_token is a signed JWT specifically so the app can verify it locally without an extra network round trip — verifying it is what OIDC adds beyond plain OAuth.

Why this works: The access token proves the app may call certain APIs on the provider; only the signed, verified id_token proves who the user actually is — treating the two as interchangeable is the single most common way teams accidentally build authentication on a protocol (OAuth) that was never designed to provide it.

Using the OAuth access token itself as proof of user identity

Wrong

text
def handle_oauth_callback(code):
    tokens = exchange_code_for_tokens(code)
    # treats possession of a valid access_token
    # as "this request is from an authenticated user"
    session['logged_in'] = True
    session['token'] = tokens['access_token']
    # never inspects or verifies id_token at all

Better

text
def handle_oauth_callback(code):
    tokens = exchange_code_for_tokens(code)
    claims = verify_id_token(
        tokens['id_token'],
        expected_audience=CLIENT_ID,
        provider_public_keys=JWKS,
    )
    session['user_id'] = claims['sub']
    session['email'] = claims['email']

What you see: The app has no reliable, verified notion of "which user is this" — it only knows a token was issued for some scope. Impersonation or user-mixup bugs surface later, especially in any flow that compares two different logins from the same provider, since nothing locally ever pinned down a stable, verified user identifier.

Why: OAuth 2.0's access token is scoped to permissions, not identity, and its format is not required to be inspectable or even a JWT. Skipping the id_token verification step throws away the one artifact OIDC added specifically to solve this, leaving the app authenticating on a protocol component that was never designed to answer "who is this."

OAuth 2.0 authorization code flow (with OIDC id_token)
Browser
App backend
Auth provider
  1. 1. Click "Sign in with Provider"
  2. 2. Redirect to provider /authorize
  3. 3. Login + consent (scope: openid email)
  4. 4. Redirect back with ?code=...
  5. 5. Deliver code via redirect
  6. 6. Exchange code + PKCE verifier for tokens
  7. 7. access_token + id_token (JWT)
  1. Browser → App backend: Click "Sign in with Provider"
  2. App backend → Browser: Redirect to provider /authorize
  3. Browser → Auth provider: Login + consent (scope: openid email)
  4. Auth provider → Browser: Redirect back with ?code=...
  5. Browser → App backend: Deliver code via redirect
  6. App backend → Auth provider: Exchange code + PKCE verifier for tokens
  7. Auth provider → App backend: access_token + id_token (JWT)

What OAuth 2.0 and OpenID Connect each actually provide

What OAuth 2.0 and OpenID Connect each actually provide
PropertyOAuth 2.0OpenID Connect (OIDC)
SolvesDelegated authorization — "let this app do X on my behalf"Delegated authentication — "prove who this user is"
Core artifactAccess token (opaque or JWT, scoped to permissions)ID token (always a JWT, standardized identity claims)
Answers "who is this user?"No — not its job, though often misused for thisYes — that is its entire purpose
Built onIts own spec (RFC 6749)OAuth 2.0 — adds the id_token and a userinfo endpoint
Typical use"Let this app read my calendar""Sign in with Google"

Remember: OAuth 2.0 issues scoped access tokens for delegated authorization; OpenID Connect adds a signed id_token on top for delegated authentication. Read the id_token, verified, to identify a user — never the access token.

See also: authentication vs authorization · session and cookie vs token auth

Machine-to-machine identity: API keys, mTLS and service-to-service auth

standardintermediate

When the caller is another service rather than a human at a browser, the mechanisms change: API keys are a simple shared secret sent per request, mTLS authenticates both sides using certificates at the TLS layer itself, and service-to-service identity systems issue short-lived, automatically rotated credentials instead of a long-lived secret anyone can copy.

Think of it as

An API key is like a shared building passcode written on a sticky note — anyone who copies the note gets in, and changing it means telling everyone the new one. mTLS is like every employee AND the building itself wearing a tamper-proof, centrally-issued badge that both sides check before a single word is exchanged. A service-identity system (like a service mesh issuing short-lived certificates) is like badges that expire and reissue themselves automatically every hour, so a stolen badge is only useful for a narrow window.

text
# API key: a shared secret sent per request
GET /api/orders
X-API-Key: sk_live_9f8c2a1b7e4d...

# mTLS: both sides present certificates during the TLS
# handshake itself -- no secret travels in the request body
# (conceptually, both directions verified before HTTP starts)
client_cert = load_cert("service-a.crt")
server_requires_client_cert = True  # server rejects handshake without one

# OAuth 2.0 client credentials grant: service authenticates
# as itself, not on behalf of any user
POST /token
    grant_type=client_credentials
    &client_id=service-a
    &client_secret=<secret>
# response: { "access_token": "...", "expires_in": 3600 }

What we're doing: Compare how a leaked credential plays out under a static API key versus short-lived mTLS-issued identity.

leaked-credential-comparison.txttext
Static API key leaked in a public repo commit:
  - valid immediately for whoever finds it
  - stays valid until someone notices and manually
    rotates it -- could be hours, could be months
  - full blast radius: every permission that key had

Short-lived mTLS/workload certificate leaked:
  - valid only until its (typically short) expiry
  - auto-rotation means the leaked cert becomes
    useless on its own within the rotation window,
    with no manual action required
  - blast radius is bounded by time even before
    anyone notices the leak
2
A static key has no built-in clock — its exposure window is however long it takes a human to notice, which is the core weakness of any long-lived shared secret.
10
Automatic short-lived rotation converts "someone must notice and act" into "the problem resolves itself within a bounded window" — a structural difference, not just a smaller number.

Why this works: The mechanism itself decides whether a credential leak is an incident that resolves on its own or one that depends entirely on a human noticing — this is the practical reason internal, high-scale service-to-service traffic increasingly moves toward short-lived, auto-rotated identity instead of static keys.

One leak, three blast radii — set by the mechanism, not the response

The same leaked credential. How long it stays useful is decided when the mechanism is chosen, not when the leak is discovered.

  • Three horizontal bars on a shared time axis, all starting at the moment a credential leaks.
  • API key: the bar runs the full width of the axis — a static shared secret stays valid until a human notices and rotates it.
  • mTLS certificate: a much shorter bar — valid for the certificate lifetime, days to months, rotated by tooling.
  • Workload identity: the shortest bar by far — minutes to hours, auto-rotated, so the leak expires on its own.

Machine-to-machine authentication mechanisms compared

Machine-to-machine authentication mechanisms compared
MechanismCredential lifetimeTypical use
API keyLong-lived (until manually rotated)External third-party API access, simple internal scripts
mTLSCertificate-lifetime (often days to months), rotated by toolingService-to-service calls inside a trusted network or mesh
Workload identity (short-lived cert/token)Minutes to hours, auto-rotatedInternal microservice-to-microservice calls at scale
OAuth 2.0 client credentials grantAccess token expires (often ~1 hour); client secret longer-livedOne service calling another service's API on its own behalf, not a user's

Remember: API keys are simple long-lived shared secrets; mTLS verifies both sides via certificates at the TLS layer; workload/service identity issues short-lived, auto-rotated credentials — the further internal and higher-scale the traffic, the more the shorter-lived option wins.

See also: session and cookie vs token auth

Advertisement

Deciding what is allowed

RBAC, ABAC, tenant isolation and the object-level authorization check that role and policy checks alone leave out.

RBAC, ABAC, tenant isolation and object-level authorization

coreintermediate

RBAC grants permissions by role ("admins can delete users"). ABAC grants permissions by evaluating attributes of the user, resource and context together ("editors can edit posts they authored, during business hours"). Neither one, by itself, checks whether the caller owns the specific resource being touched — that final check is object-level authorization, and skipping it is one of the most common real-world access-control bugs.

Think of it as

RBAC is like a hotel issuing different keycards for "housekeeping" versus "guest" — the card type decides broad categories of doors you can open. ABAC is like a smarter lock that checks several conditions together: your card type, the time of day, and which wing you are in, before deciding. Object-level authorization is the one thing neither replaces: even with the right card type at the right time, the lock must still check this is YOUR room number, not just any room that type of card usually opens — a housekeeping card that opens every room without checking the room number is exactly the class of bug this concept exists to prevent.

text
# RBAC: role -> permission, blanket
if 'editor' in user.roles:
    allow('edit', any_post)   # true for ANY post, not just theirs

# ABAC: policy over attributes
allow = (
    'editor' in user.roles
    and post.author_id == user.id       # resource attribute
    and current_hour in business_hours  # context attribute
)

# Object-level authorization: the specific-instance check
def can_edit(user, post):
    return post.author_id == user.id or 'admin' in user.roles

What we're doing: Show an RBAC check that passes correctly, followed by the missing object-level check that lets it access the wrong resource.

edit-post-authorization.txttext
Request: PUT /posts/771/edit
Caller: user_id=482, roles=['editor']

Step 1 -- RBAC check:
  'editor' in user.roles -> True
  (this only proves 482 is SOME editor, not that
  they may edit post 771 specifically)

Step 2 -- fetch resource:
  post_771.author_id == 900   (belongs to a different user)

Step 3 -- object-level check (if present):
  post_771.author_id == user.id?  900 == 482?  False
  -> 403 Forbidden

If step 3 is skipped: the edit succeeds, because
step 1's role check was the only gate, and it was
satisfied by ANY editor regardless of which post
they targeted.
4
The role check answers "is this user an editor," a category question — it has no concept of post 771 specifically.
9
Only once the actual resource is fetched does its ownership become checkable at all.
13
This is the object-level authorization check — comparing the resource's own owner field against the caller, not just their role.

Why this works: RBAC and ABAC both operate on categories and policies; only a check that loads the specific resource and compares its ownership or grants against the caller closes the gap between "an editor may edit posts" and "this editor may edit this post" — omitting it is exactly what OWASP's API Security Top 10 calls Broken Object Level Authorization.

Enforcing a role check but never verifying resource ownership

Wrong

text
@require_role('editor')
def edit_post(request, post_id):
    post = db.get_post(post_id)
    post.update(request.body)
    post.save()
    # any editor can edit ANY post by changing post_id
    # in the URL -- role check never looked at post.author_id

Better

text
@require_role('editor')
def edit_post(request, post_id):
    post = db.get_post(post_id)
    if post.author_id != request.user.id and 'admin' not in request.user.roles:
        return Response(status=403)
    post.update(request.body)
    post.save()

What you see: Any user holding the "editor" role can edit or view every other user's posts simply by iterating post IDs in the URL — the role check passes for all of them identically, and nothing in the response or logs distinguishes an authorized edit from an unauthorized one.

Why: A role check only tests membership in a category ("is this caller an editor") and never loads the specific resource to compare it against the caller — the fix requires an additional, deliberate comparison between the resource's own ownership/grant data and the caller's identity, which no role or attribute policy provides for free unless the resource's attributes are explicitly part of that policy.

Three layers of an authorization decision

RBAC

role grants a category of action

ABAC

policy over user + resource + context

Object-level check

does THIS caller own THIS resource

Allowed

only if every layer passes

  1. RBAC — role grants a category of action
  2. ABAC — policy over user + resource + context
  3. Object-level check — does THIS caller own THIS resource
  4. Allowed — only if every layer passes

RBAC, ABAC and object-level authorization compared

RBAC, ABAC and object-level authorization compared
ModelDecides based onWhat it does NOT check by default
RBACThe role(s) assigned to the userWhich specific resource instance is being accessed
ABACAttributes of user + resource + context, evaluated by policyStill needs the resource's attributes (e.g. owner_id) actually fetched and compared — a policy engine only evaluates what it is given
Object-level authorizationDoes THIS user own or have a grant on THIS specific resourceN/A — this is the specific-instance check the other two models leave out unless deliberately added
Tenant isolationDoes this resource belong to the caller's tenant/organization at allRow-level permission within that tenant — a separate, finer-grained check still needed after

Remember: RBAC checks category (does this role permit this action); ABAC checks policy over attributes; neither checks the specific resource instance by default — object-level authorization (verify THIS caller against THIS resource's ownership) is the check that closes that gap, and OWASP ranks skipping it as the #1 API security risk.

See also: authentication vs authorization · api gateway responsibilities

Advertisement