Authentication vs Authorization
Authentication (AuthN) answers "Who are you?" — it verifies identity. Authorization (AuthZ) answers "What can you do?" — it enforces permissions. These are separate concerns, though they're often conflated because they happen in sequence: you authenticate first, then the system checks what you're authorized to access.
| Aspect | Authentication | Authorization |
|---|---|---|
| Question | Who are you? | What can you access? |
| Mechanism | Passwords, tokens, certificates, biometrics | Roles, policies, ACLs, scopes |
| HTTP status on failure | 401 Unauthorized | 403 Forbidden |
| Where it lives | Identity Provider (IdP) | Application / Policy Engine |
| Example | Logging in with username + password | Admin can delete users, viewer cannot |
Session-Based Authentication
The traditional web model: after login, the server creates a session (stored server-side in memory, Redis, or a database), assigns a random session ID, and sends it to the client as a cookie. Every subsequent request includes this cookie, and the server looks up the session to identify the user.
# A typical session cookie in HTTP headers
# Response after successful login:
# Set-Cookie: session_id=abc123def456; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600
# Every subsequent request includes:
# Cookie: session_id=abc123def456
# Server-side pseudocode:
# session = redis.get("session:abc123def456")
# if session is None: return 401
# user = session["user_id"] # authenticated!
Advantages: Server has full control — can invalidate any session instantly. Small cookie size (just the ID). Immune to token-size issues.
Disadvantages: Stateful — server must store all active sessions. Scaling requires shared session store (Redis). Not suitable for cross-domain APIs or mobile apps without cookies.
Token-Based Authentication
Instead of storing state server-side, the server issues a self-contained token that encodes the user's identity and claims. The client stores this token and sends it with every request. The server validates the token's signature without any database lookup — making it stateless and horizontally scalable.
JWT Anatomy
A JSON Web Token (JWT) consists of three Base64URL-encoded segments separated by dots: header.payload.signature.
flowchart LR
A["Header
(algorithm, type)"] --> D["Base64URL
encode"]
B["Payload
(claims: sub, exp, iat, roles)"] --> E["Base64URL
encode"]
D --> F["header.payload"]
E --> F
F --> G["HMAC-SHA256
or RSA sign"]
C["Secret Key
or Private Key"] --> G
G --> H["Signature"]
F --> I["header.payload.signature"]
H --> I
# Decode a JWT manually with bash (no verification — just inspect claims)
TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQyIiwibmFtZSI6IkFsaWNlIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzE2NjYwMDAwLCJpYXQiOjE3MTY2NTY0MDB9.signature_here"
# Extract and decode header (part before first dot)
echo "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null | python3 -m json.tool
# {"alg": "HS256", "typ": "JWT"}
# Extract and decode payload (part between dots)
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
# {"sub": "user-42", "name": "Alice", "role": "admin", "exp": 1716660000, "iat": 1716656400}
# Check if token is expired
EXP=$(echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -c "import sys,json;print(json.load(sys.stdin)['exp'])")
NOW=$(date +%s)
echo "Expired: $([ $NOW -gt $EXP ] && echo 'YES' || echo 'NO')"
# Create and verify JWTs with PyJWT
# pip install PyJWT
import jwt
import time
SECRET_KEY = "my-256-bit-secret-key-for-hmac"
# Create a JWT
payload = {
"sub": "user-42",
"name": "Alice",
"role": "admin",
"iat": int(time.time()),
"exp": int(time.time()) + 3600 # expires in 1 hour
}
token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
print(f"Token: {token[:50]}...")
# Verify and decode the JWT
try:
decoded = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
print(f"User: {decoded['sub']}")
print(f"Role: {decoded['role']}")
print(f"Expires: {decoded['exp']}")
except jwt.ExpiredSignatureError:
print("Token has expired!")
except jwt.InvalidTokenError as e:
print(f"Invalid token: {e}")
Bearer Tokens
A bearer token is any token that grants access to whoever "bears" (possesses) it — no additional proof of identity required. JWTs are commonly used as bearer tokens in the Authorization header.
# Making an API call with a Bearer token
# The token is a JWT obtained from login or OAuth flow
TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
# Standard Authorization header format
curl -s -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
https://api.example.com/v1/users/me | python3 -m json.tool
# If the token is expired or invalid, you get 401:
# {"error": "token_expired", "message": "The access token has expired"}
# If the token is valid but lacks permission, you get 403:
# {"error": "insufficient_scope", "message": "Requires admin role"}
localStorage is accessible to any JavaScript on the page — a single XSS vulnerability exposes all tokens. HttpOnly cookies are invisible to JavaScript (immune to XSS) but require CSRF protection. Best practice for web apps: store access tokens in memory (JavaScript variable), refresh tokens in HttpOnly Secure cookies with SameSite=Strict.
OAuth 2.0
OAuth 2.0 is an authorization framework (not authentication) that lets a user grant a third-party application limited access to their resources on another service — without sharing their password. It defines four roles: Resource Owner (user), Client (app), Authorization Server, and Resource Server (API).
Authorization Code Flow
The most secure OAuth flow for web applications with a backend server. The authorization code is exchanged server-side, keeping tokens off the browser.
sequenceDiagram
participant U as User (Browser)
participant A as App (Backend)
participant AS as Auth Server
participant API as Resource Server
U->>A: Click "Login with GitHub"
A->>U: Redirect to Auth Server /authorize
U->>AS: GET /authorize?client_id=X&redirect_uri=Y&scope=read:user&state=Z
AS->>U: Show consent screen
U->>AS: User approves access
AS->>U: Redirect to App callback with ?code=ABC&state=Z
U->>A: GET /callback?code=ABC&state=Z
A->>AS: POST /token (code=ABC, client_secret=S)
AS->>A: { access_token, refresh_token, expires_in }
A->>API: GET /user (Authorization: Bearer access_token)
API->>A: { id, name, email }
A->>U: Welcome, Alice!
# Step 1: Redirect user to authorize (done by your app's frontend)
# https://github.com/login/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:3000/callback&scope=read:user&state=random_csrf_token
# Step 2: Exchange authorization code for access token (server-side)
curl -s -X POST https://github.com/login/oauth/access_token \
-H "Accept: application/json" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "code=AUTHORIZATION_CODE_FROM_CALLBACK" \
-d "redirect_uri=http://localhost:3000/callback"
# Response: {"access_token":"gho_xxxx","token_type":"bearer","scope":"read:user"}
# Step 3: Use the access token to call the API
curl -s -H "Authorization: Bearer gho_xxxx" \
https://api.github.com/user | python3 -m json.tool
# Response: {"login":"alice","id":12345,"name":"Alice","email":"alice@example.com"...}
Client Credentials Flow
Used for machine-to-machine communication where no user is involved — the application itself is the resource owner. Common for microservices calling each other, CI/CD pipelines, and backend cron jobs.
# Client Credentials Grant — no user interaction
# The app authenticates itself directly with client_id + client_secret
curl -s -X POST https://auth.example.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=service-payment-api" \
-d "client_secret=super-secret-value" \
-d "scope=orders:read inventory:write"
# Response:
# {
# "access_token": "eyJhbGciOiJSUzI1NiI...",
# "token_type": "Bearer",
# "expires_in": 3600,
# "scope": "orders:read inventory:write"
# }
OpenID Connect (OIDC)
OpenID Connect is an identity layer built on top of OAuth 2.0. While OAuth tells you what a user can access, OIDC tells you who the user is. It adds an id_token (a JWT containing user identity claims) alongside the access token.
Key additions over plain OAuth 2.0:
- ID Token — a JWT with claims like
sub(user ID),email,name,picture - UserInfo Endpoint —
GET /userinforeturns profile data - Discovery Document —
/.well-known/openid-configurationpublishes all endpoints - Standard Scopes —
openid,profile,email,address,phone
# Discover an OIDC provider's configuration
curl -s https://accounts.google.com/.well-known/openid-configuration | python3 -m json.tool
# Shows: authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, etc.
# After OAuth flow, decode the id_token to get user identity
ID_TOKEN="eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJzdWIiOiIxMTAyODIxMDI1NiIsImVtYWlsIjoiYWxpY2VAZ21haWwuY29tIiwibmFtZSI6IkFsaWNlIn0.sig"
# Decode payload (identity claims)
echo "$ID_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
# {
# "iss": "https://accounts.google.com",
# "sub": "110282102561234",
# "email": "alice@gmail.com",
# "name": "Alice",
# "picture": "https://lh3.googleusercontent.com/...",
# "iat": 1716656400,
# "exp": 1716660000
# }
RBAC — Role-Based Access Control
RBAC assigns permissions to roles, then assigns roles to users. Instead of granting individual permissions to each user (which doesn't scale), you define roles like admin, editor, viewer and assign users to those roles. This is the dominant access control model in enterprise systems, cloud platforms, and Kubernetes.
| Method | Stateful/Stateless | Use Case | Pros | Cons |
|---|---|---|---|---|
| Session Cookies | Stateful | Traditional web apps | Revocable, small payload, HttpOnly | Requires session store, not cross-domain |
| JWT | Stateless | APIs, microservices, SPAs | No server state, scalable, self-contained | Can't revoke easily, token size grows with claims |
| API Keys | Stateful | Service-to-service, dev tools | Simple, no user interaction | No expiry by default, coarse-grained, hard to rotate |
| OAuth 2.0 | Both | Third-party access delegation | Scoped access, no password sharing | Complex flows, many moving parts |
| mTLS | Stateless | Service mesh, zero-trust | Strong identity, no secrets in headers | Certificate management overhead |
# RBAC policy as JSON — define roles and their permissions
cat <<'EOF'
{
"roles": {
"admin": {
"permissions": ["users:create", "users:read", "users:update", "users:delete",
"posts:create", "posts:read", "posts:update", "posts:delete",
"settings:manage"]
},
"editor": {
"permissions": ["posts:create", "posts:read", "posts:update", "posts:delete",
"users:read"]
},
"viewer": {
"permissions": ["posts:read", "users:read"]
}
},
"users": {
"alice": { "roles": ["admin"] },
"bob": { "roles": ["editor"] },
"charlie": { "roles": ["viewer"] }
}
}
EOF
# Check permission: does bob have users:delete?
# bob → editor → ["posts:*", "users:read"] → NO users:delete → 403 Forbidden
# Inspect JWT claims to see embedded roles
TOKEN="eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJib2IiLCJyb2xlcyI6WyJlZGl0b3IiXSwic2NvcGUiOiJwb3N0czpjcmVhdGUgcG9zdHM6cmVhZCBwb3N0czp1cGRhdGUgcG9zdHM6ZGVsZXRlIHVzZXJzOnJlYWQiLCJleHAiOjE3MTY2NjAwMDB9.sig"
# Decode and extract roles
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -c "
import sys, json
claims = json.load(sys.stdin)
print(f\"User: {claims['sub']}\")
print(f\"Roles: {claims['roles']}\")
print(f\"Scopes: {claims['scope']}\")
"
# User: bob
# Roles: ['editor']
# Scopes: posts:create posts:read posts:update posts:delete users:read
How Kubernetes RBAC Works
Kubernetes uses RBAC to control who can do what within a cluster. The model has four key objects:
- ServiceAccount — identity for pods (each pod runs as a ServiceAccount)
- Role / ClusterRole — defines what actions are allowed on which resources (namespaced vs cluster-wide)
- RoleBinding / ClusterRoleBinding — binds a Role to a user, group, or ServiceAccount
Example: A CI/CD pipeline's ServiceAccount gets a Role allowing get, list, create on Deployments in the staging namespace — but nothing else. Human admins authenticate via OIDC (e.g., Google, Azure AD) and get ClusterRoleBindings granting broader access.
The principle: least privilege. Every identity gets only the minimum permissions it needs. Kubernetes defaults to deny-all — if no RoleBinding grants a permission, it's denied.
Exercises
Practice authentication and authorization concepts hands-on:
# Exercise 1: Decode a JWT without any libraries
# Create a test JWT payload, base64url-encode it, and decode it back
HEADER=$(echo -n '{"alg":"HS256","typ":"JWT"}' | base64 | tr '+/' '-_' | tr -d '=')
PAYLOAD=$(echo -n '{"sub":"exercise-user","role":"admin","exp":9999999999}' | base64 | tr '+/' '-_' | tr -d '=')
echo "Header: $HEADER"
echo "Payload: $PAYLOAD"
echo "Fake JWT: ${HEADER}.${PAYLOAD}.fake-signature"
# Exercise 2: Inspect a real OIDC discovery document
curl -s https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration | python3 -c "
import sys, json
config = json.load(sys.stdin)
print('Issuer:', config['issuer'])
print('Auth endpoint:', config['authorization_endpoint'])
print('Token endpoint:', config['token_endpoint'])
print('JWKS URI:', config['jwks_uri'])
print('Supported scopes:', config.get('scopes_supported', [])[:5])
"
# Exercise 3: Simulate RBAC permission check
python3 -c "
rbac = {
'admin': {'users:*', 'posts:*', 'settings:*'},
'editor': {'posts:create', 'posts:read', 'posts:update', 'posts:delete', 'users:read'},
'viewer': {'posts:read', 'users:read'}
}
def check_permission(user_roles, required_permission):
resource, action = required_permission.split(':')
for role in user_roles:
perms = rbac.get(role, set())
if required_permission in perms or f'{resource}:*' in perms:
return True
return False
# Test cases
print('editor can posts:delete?', check_permission(['editor'], 'posts:delete')) # True
print('viewer can posts:delete?', check_permission(['viewer'], 'posts:delete')) # False
print('admin can settings:manage?', check_permission(['admin'], 'settings:manage')) # True (wildcard)
"
# Exercise 4: Check OAuth token expiry
python3 -c "
import time
# Simulate checking if a token needs refresh
token_exp = int(time.time()) + 300 # expires in 5 minutes
buffer = 60 # refresh 60s before expiry
needs_refresh = time.time() > (token_exp - buffer)
print(f'Token expires in: {token_exp - int(time.time())}s')
print(f'Needs refresh (with 60s buffer): {needs_refresh}')
"
Conclusion & Next Steps
Authentication proves identity; authorization enforces permissions. Session cookies are simple but stateful. JWTs are stateless and scalable but hard to revoke. OAuth 2.0 delegates access without sharing passwords. OIDC adds identity on top of OAuth. RBAC maps users to roles to permissions — and the principle of least privilege guides every design decision. In practice, most production systems combine several of these: OIDC for login, JWTs as bearer tokens, RBAC for permissions, and refresh tokens for longevity.