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 sessionJWTs (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.
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
| Property | Session | JWT |
|---|---|---|
| Server state required | Yes, session store (Redis, DB) | No, self-contained |
| Revocation | Instant, delete session from store | Cannot revoke before exp without a blocklist |
| Horizontal scaling | Needs sticky sessions or shared store | Trivial, any instance validates the signature |
| Payload visibility | Hidden, session ID is opaque | Visible, Base64-decoded by anyone with the token |
| Size on every request | ~32 bytes (session ID cookie) | ~300-800 bytes (varies with claims) |
| Cross-domain auth | Difficult, cookies are same-origin | Simple, Authorization header works everywhere |
| Microservices | Hard, every service needs session store access | Easy, every service verifies independently |
| Expiry model | Sliding (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
- 1App redirects user to auth server with client_id, scope, redirect_uri, code_challenge (PKCE)
- 2User authenticates and consents on auth server
- 3Auth server redirects to redirect_uri with authorization code
- 4App exchanges code + code_verifier for access_token and refresh_token
- 5App uses access_token in Authorization header for API calls
Client Credentials
Use: Machine-to-machine: background jobs, microservices, cron tasks
Actors: Service A, Auth Server, Service B
- 1Service authenticates directly to auth server with client_id + client_secret
- 2Auth server returns access_token (no user involved)
- 3Service uses access_token to call downstream APIs
Device Code
Use: Smart TVs, CLI tools, IoT devices without a browser
Actors: Device (no browser), Auth Server, User's phone/PC
- 1Device requests device_code and user_code from auth server
- 2Device shows user_code and verification_uri to user
- 3User visits URI on separate device (phone/PC) and enters user_code
- 4Device polls auth server until user completes authorization
- 5Auth server returns access_token to device
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.
| Scope | Permits |
|---|---|
| read:profile | Read the user's name, email, and avatar |
| read:orders | List the user's past orders |
| write:orders | Create orders on the user's behalf |
| admin:users | Manage all users, only for admin clients |
| offline_access | Issue a refresh token (persisted access) |
OIDC: identity on top of OAuth 2.0
What OIDC adds to OAuth 2.0
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.
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] # TrueStrengths
- + 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.
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 FalseStrengths
- + 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.
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.