Back to Computing & Systems Foundations Series

Part 19: Authentication & Authorization — Tokens, RBAC & OAuth

May 13, 2026Wasil Zafar19 min read

How systems verify who you are (authentication) and what you're allowed to do (authorization) — from session cookies to OAuth 2.0, JWT tokens, and role-based access control.

Table of Contents

  1. Authentication vs Authorization
  2. Session-Based Authentication
  3. Token-Based Authentication
  4. OAuth 2.0
  5. OpenID Connect (OIDC)
  6. RBAC — Role-Based Access Control
  7. Exercises
  8. Conclusion

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.

AspectAuthenticationAuthorization
QuestionWho are you?What can you access?
MechanismPasswords, tokens, certificates, biometricsRoles, policies, ACLs, scopes
HTTP status on failure401 Unauthorized403 Forbidden
Where it livesIdentity Provider (IdP)Application / Policy Engine
ExampleLogging in with username + passwordAdmin can delete users, viewer cannot
Key Insight: A 401 means "I don't know who you are — provide credentials." A 403 means "I know who you are, but you're not allowed to do that." Many APIs return 401 for both, which makes debugging harder. Distinguish them correctly in your systems.

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.

JWT Structure
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}")
Stateless Tradeoff: JWTs are stateless — the server doesn't store them. This means you cannot revoke a single JWT before its expiry without maintaining a server-side blocklist (which defeats the stateless benefit). Mitigations: use short expiry times (15 minutes) with refresh tokens, or maintain a revocation list in Redis for critical logout/password-change scenarios.

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"}
Token Storage Security: Where you store tokens matters enormously. 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.

OAuth 2.0 Authorization Code Flow
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 EndpointGET /userinfo returns profile data
  • Discovery Document/.well-known/openid-configuration publishes all endpoints
  • Standard Scopesopenid, 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.

MethodStateful/StatelessUse CaseProsCons
Session CookiesStatefulTraditional web appsRevocable, small payload, HttpOnlyRequires session store, not cross-domain
JWTStatelessAPIs, microservices, SPAsNo server state, scalable, self-containedCan't revoke easily, token size grows with claims
API KeysStatefulService-to-service, dev toolsSimple, no user interactionNo expiry by default, coarse-grained, hard to rotate
OAuth 2.0BothThird-party access delegationScoped access, no password sharingComplex flows, many moving parts
mTLSStatelessService mesh, zero-trustStrong identity, no secrets in headersCertificate 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
Real-World Example

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.

KubernetesRBACZero TrustLeast Privilege

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.