Codetail

Article 12 of 15

Authentication & Authorization

Who are you? What are you allowed to do?

22 min read

Authentication vs Authorization

Authentication answers one question: who are you? It verifies identity, that the entity making the request is who they claim to be. Authorization answers a different question: what are you allowed to do? It enforces policy, given a verified identity, what resources and actions are permitted.

They are separate concerns that happen in sequence. A system cannot make an authorization decision without first establishing identity through authentication. But authentication alone is never sufficient, knowing who someone is does not determine what they are allowed to do. Both must be correct for a system to be secure.

Authentication (AuthN)

Who are you? Prove your identity.

Password + username

OAuth token from a trusted provider

mTLS client certificate

SSH key

Passkey / WebAuthn

Result: verified identity (e.g., user_123)

Authorization (AuthZ)

What can you do? Enforce access policy.

Role-based access control (RBAC)

Attribute-based access control (ABAC)

OAuth scopes

Row-level permissions

Resource ownership checks

Result: allow or deny

The sequence in practice

User provides correct email and password

AuthN result

Identity confirmed, this is alice@example.com

AuthZ check

Is alice allowed to access /admin? Check role.

API request arrives with a valid JWT

AuthN result

Signature valid, not expired, this is user_123

AuthZ check

Can user_123 delete this resource? Check ownership.

Service A calls Service B with an mTLS certificate

AuthN result

Certificate valid, signed by internal CA, this is the Orders service

AuthZ check

Is Orders service allowed to call /payments? Check service policy.

OAuth token presented by a mobile app

AuthN result

Token valid, issued by identity provider, this is user@gmail.com

AuthZ check

Does this token have the read:orders scope? Check scopes.

Common mistakes

!

Trusting unauthenticated input for authorization checks

Checking if req.body.userId === "admin" for authorization. The client controls req.body. The identity assertion must come from the server-verified token, not the request payload.

!

Conflating authentication with authorization

"The user is logged in, so they can access anything." Authentication says who you are. Authorization says what you can do. A logged-in free-tier user should not access paid features.

!

Authorization checks only at the API layer

Authorization enforced at the HTTP handler but not at the database layer. Direct database access, batch jobs, and internal APIs bypass the check. Enforce at every access point.

!

Storing sensitive data in the JWT payload

The JWT payload is Base64-encoded, not encrypted. Anyone with the token can read it. Never put passwords, SSNs, payment data, or secrets in JWT claims.

Sessions vs Tokens

Once a user authenticates, the server needs a way to recognize them on subsequent requests. HTTP is stateless: every request is independent. Two mechanisms exist for maintaining authenticated state across requests, server-side sessions and client-side tokens.

Server-side sessions

On login, the server generates a random session ID and stores session data (user ID, role, expiry) in a server-side store (Redis, database). The session ID is sent to the client as an HttpOnly, Secure cookie. On every subsequent request, the browser sends the cookie automatically. The server looks up the session ID in the store to get the user's identity.

The session ID is opaque, it reveals nothing about the user. Revocation is instant: delete the session from the store and the user is logged out on the next request. The cost is the session store itself: every authentication check requires a network round trip to Redis, and horizontal scaling requires either sticky sessions (route each user to the same server) or a shared session store.

Session flow

1. POST /auth/login { email, password }
   -> Server verifies credentials
   -> Server stores: sessions["abc123"] = { user_id: 42, role: "admin", exp: ... }
   -> Response: Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax

2. GET /api/dashboard
   Cookie: session_id=abc123
   -> Server: redis.get("sessions:abc123") -> { user_id: 42, role: "admin" }
   -> Request proceeds as user 42

3. POST /auth/logout
   -> Server: redis.del("sessions:abc123")
   -> Cookie cleared
   -> User instantly logged out, next request finds no session

JWTs (JSON Web Tokens)

A JWT encodes identity and claims directly in a signed token that the client holds. The server verifies the signature on every request without consulting a store, the token is self-contained. This eliminates the session store and scales trivially across instances. Any server with the public key can verify any token.

The tradeoff is revocation. Because the server holds no state, a JWT is valid until its expiry (exp claim). If a user is compromised or logs out, the token remains valid until it expires. Solutions: short expiry (15 minutes) with refresh tokens, or a token blocklist (which reintroduces server state). Neither is as clean as session deletion.

JWT Anatomy Inspector, click a segment to decode

Raw token (Base64URL encoded)

..

Click any segment above to decode it

Access tokens and refresh tokens

Refresh token flow

1. Login -> server issues:
   access_token:  short-lived JWT (15 min), sent in Authorization header
   refresh_token: long-lived opaque token (7 days), stored in HttpOnly cookie

2. API call:
   Authorization: Bearer <access_token>
   -> Server validates JWT signature, no DB lookup needed

3. access_token expires:
   POST /auth/refresh
   Cookie: refresh_token=<opaque_value>
   -> Server looks up refresh token in store (checks not revoked)
   -> Issues new access_token (15 min)

4. Logout:
   -> Server deletes refresh_token from store
   -> access_token expires naturally in <=15 min
   -> User cannot refresh -> effectively logged out

Sessions vs JWTs: when to use each

PropertySessionJWT
Server state requiredYes, session store (Redis, DB)No, self-contained
RevocationInstant, delete session from storeCannot revoke before exp without a blocklist
Horizontal scalingNeeds sticky sessions or shared storeTrivial, any instance validates the signature
Payload visibilityHidden, session ID is opaqueVisible, Base64-decoded by anyone with the token
Size on every request~32 bytes (session ID cookie)~300-800 bytes (varies with claims)
Cross-domain authDifficult, cookies are same-originSimple, Authorization header works everywhere
MicroservicesHard, every service needs session store accessEasy, every service verifies independently
Expiry modelSliding (activity extends expiry)Fixed (exp claim set at issue time)

For traditional web apps where instant revocation matters (security-sensitive applications, banking, admin panels), sessions win. For APIs serving mobile clients and microservices where stateless verification is valuable, JWTs with short expiry and refresh tokens are the standard. Most production systems use both: JWTs for API authentication, sessions for web app login state.

OAuth 2.0 and OpenID Connect

OAuth 2.0 is an authorization framework. It allows a user to grant a third-party application limited access to their account on another service without sharing their credentials. The key insight is delegation: the user consents to a specific set of permissions (scopes), and the authorization server issues a token that represents exactly those permissions.

OAuth 2.0 is authorization only, it says what the token permits, not who the user is. OpenID Connect (OIDC) is a thin identity layer on top of OAuth 2.0 that adds an ID token (a JWT) containing the user's identity (sub, email, name). Most modern identity providers (Google, GitHub, Auth0, Okta) implement both: OAuth 2.0 for authorization, OIDC for authentication.

OAuth 2.0 grant flows

Authorization Code + PKCE

Use: Web apps and mobile apps acting on behalf of a user

Actors: User, Browser, Auth Server, Resource Server

  1. 1App redirects user to auth server with client_id, scope, redirect_uri, code_challenge (PKCE)
  2. 2User authenticates and consents on auth server
  3. 3Auth server redirects to redirect_uri with authorization code
  4. 4App exchanges code + code_verifier for access_token and refresh_token
  5. 5App uses access_token in Authorization header for API calls
Why this flow: Code is short-lived (seconds) and useless without code_verifier. PKCE prevents interception attacks on mobile apps where client_secret cannot be kept secret.

Client Credentials

Use: Machine-to-machine: background jobs, microservices, cron tasks

Actors: Service A, Auth Server, Service B

  1. 1Service authenticates directly to auth server with client_id + client_secret
  2. 2Auth server returns access_token (no user involved)
  3. 3Service uses access_token to call downstream APIs
Why this flow: No user interaction needed. The client is the resource owner. Token scope limits which downstream APIs the service can call.

Device Code

Use: Smart TVs, CLI tools, IoT devices without a browser

Actors: Device (no browser), Auth Server, User's phone/PC

  1. 1Device requests device_code and user_code from auth server
  2. 2Device shows user_code and verification_uri to user
  3. 3User visits URI on separate device (phone/PC) and enters user_code
  4. 4Device polls auth server until user completes authorization
  5. 5Auth server returns access_token to device
Why this flow: Handles the case where the device cannot open a browser for a redirect flow.

Scopes: the unit of delegation

Scopes define what a token is permitted to do. The client requests a set of scopes. The user consents to those scopes. The token carries only the consented scopes, the resource server checks them on every request. Scopes follow the principle of least privilege: request only what is needed.

ScopePermits
read:profileRead the user's name, email, and avatar
read:ordersList the user's past orders
write:ordersCreate orders on the user's behalf
admin:usersManage all users, only for admin clients
offline_accessIssue a refresh token (persisted access)

OIDC: identity on top of OAuth 2.0

What OIDC adds to OAuth 2.0

ID tokenA JWT issued alongside the access_token containing the user's identity (sub, email, name). Never sent to APIs, only consumed by the client to know who the user is.
UserInfo endpointGET /userinfo with the access_token returns the user's profile claims. An alternative to embedding claims in the ID token.
Discovery documentGET /.well-known/openid-configuration returns the provider's endpoints, supported scopes, and public keys. Clients auto-configure from this.
openid scopeRequesting the openid scope signals to the auth server that this is an OIDC request and triggers ID token issuance.

Never implement your own OAuth server unless you have a very specific reason. Use an identity provider (Auth0, Clerk, Supabase Auth, Cognito, Keycloak) that handles token issuance, rotation, revocation, MFA, and compliance. The attack surface of a custom auth implementation is large and the mistakes are severe.

Authorization Models: RBAC, ABAC, ReBAC

Authorization is a policy problem: given a verified identity and a requested action, does this identity have permission? Three models dominate production systems, each expressing policy at different levels of sophistication. RBAC works for simple access control. ABAC handles context-dependent decisions. ReBAC models the relationship graphs used by collaborative applications.

RBACRole-Based Access Control

Users are assigned roles. Roles have permissions. Access decision: does this role have this permission?

Example

roles = {
  viewer:  ["read:posts", "read:comments"],
  editor:  ["read:posts", "write:posts", "read:comments"],
  admin:   ["*"],  # all permissions
}

user.role = "editor"
can_edit = "write:posts" in roles[user.role]  # True

Strengths

  • + Simple to understand and audit
  • + Permissions change by editing role definitions
  • + Works well when users fall into clear categories

Weaknesses

  • - Role explosion: 50+ roles to express fine-grained access
  • - Cannot express context: 'own resources only' requires extra logic
  • - No attribute-based decisions (time, location, resource state)

Use when: Internal admin tools, SaaS with clear tiers (free/pro/admin), APIs with well-defined scopes.

ABACAttribute-Based Access Control

Access decision based on attributes of the subject (user), resource, action, and environment. Policy is expressed as rules over attribute combinations.

Example

# Policy: editors can edit posts they authored;
# admins can edit any post;
# only during business hours in their timezone

def can_edit(user, post, context):
    if user.role == "admin":
        return True
    if user.role == "editor":
        authored = post.author_id == user.id
        in_hours = 9 <= context.local_hour < 18
        return authored and in_hours
    return False

Strengths

  • + Fine-grained: any combination of attributes
  • + Handles ownership, time, location, resource state
  • + One policy engine, no role explosion

Weaknesses

  • - Complex policies are hard to audit
  • - Performance: policy evaluation can be slow for complex rules
  • - Debugging access denials requires tracing the policy evaluation

Use when: Healthcare (HIPAA: access by role + department + patient relationship), financial data, multi-tenant SaaS with complex ownership rules.

ReBACRelationship-Based Access Control

Access is determined by the graph of relationships between users and resources. 'Can user U access resource R?' is answered by traversing the relationship graph.

Example

# Google Zanzibar model:
# user:alice is viewer of document:plan
# user:alice is member of group:eng
# group:eng is editor of folder:projects
# document:plan is in folder:projects

# Question: can alice edit document:plan?
# -> alice member of eng
# -> eng editor of projects
# -> plan in projects
# -> YES (inherited through relationship chain)

Strengths

  • + Natural model for hierarchical resources (drives, folders, documents)
  • + Handles group membership and inheritance automatically
  • + Used by Google Drive, GitHub, Notion, Figma

Weaknesses

  • - Complex to implement from scratch
  • - Graph traversal at request time requires optimization (caching, precomputation)
  • - Reasoning about policy requires graph visualization tools

Use when: Collaborative applications with hierarchical resources and group-based sharing (Google Docs model, GitHub orgs/teams/repos).

Practical guidance

Start with RBAC, add complexity only when needed

Most applications have fewer than five meaningful roles. RBAC is auditable, easy to explain to non-engineers, and sufficient for the majority of access control requirements.

Add ownership checks on top of RBAC for resource-level control

RBAC says "editors can edit posts". Add: "and only posts they created." This is the most common extension and does not require a full ABAC engine.

Enforce authorization at every access point, not just the HTTP layer

Direct database queries, async jobs, and admin scripts all need authorization checks. A row-level security policy at the database layer is the only guarantee.

Use OpenFGA or Casbin before building your own authorization engine

OpenFGA (based on Google Zanzibar) is the open-source reference for ReBAC. Casbin handles RBAC and ABAC. Both have client libraries for major languages.