API Auth & Authorization
When Your API Becomes a Security Liability
Imagine you’ve deployed an API that handles user profiles, payment transactions, and admin operations. Your first week of production goes well — happy customers, smooth deployments. Then, during an audit, your security team asks a simple question: “What happens if someone just makes up an API request?”
Without proper authentication and authorization, the answer is terrifying. An attacker could read every customer’s profile, process payments from accounts they don’t own, or execute admin commands to delete your entire database. These aren’t theoretical vulnerabilities — they’re consistently found in real-world breaches.
Authentication and authorization form the twin pillars of API security. Authentication answers “Who are you?” Authorization answers “What are you allowed to do?” Get either one wrong, and your API becomes a liability, not an asset. A missing authentication check causes data breaches. A missing authorization check causes privilege escalation. Both can trigger regulatory fines (GDPR, HIPAA), destroy customer trust, and end careers.
This chapter equips you with the knowledge to design and implement authentication and authorization systems that are both secure and practical. We’ll explore the mechanisms, trade-offs, and real-world patterns that separate production-grade APIs from vulnerable systems.
Authentication vs. Authorization: Know the Difference
Authentication is proving who you claim to be. When you show your passport at airport security, you’re authenticating — the agent verifies that the person in the photo matches you. In APIs, authentication means proving your identity through a credential: an API key, a username and password, a signed token, or a certificate.
Authorization is proving you have permission to access something. Once you’re past airport security with your verified passport, a security guard checks whether you’re allowed in the executive lounge. In APIs, authorization determines what resources you can access and what operations you can perform.
Here’s the critical distinction: you can be authenticated but not authorized. You’ve proven who you are, but you lack permission. For example, a valid API key (authenticated) trying to delete a user (unauthorized for that key’s scope) should fail. Conversely, you should never reach authorization checks without authentication — that’s a logical prerequisite.
Authentication Methods
Let’s explore the most common authentication mechanisms, ordered by simplicity to sophistication:
API Keys: A simple shared secret between client and server. The client includes the key in a request header or query parameter. The server validates it against a stored list or database. API keys are lightweight, stateless, and ideal for server-to-server communication or tracking API usage. However, they offer no inherent expiration, no user context, and are vulnerable if leaked. Never transmit them over HTTP.
GET /api/users HTTP/1.1
Host: api.example.com
X-API-Key: sk_live_4eC39HqLyjWDarhtT657j8wp
Basic Authentication: Encodes username and password as base64 and sends them in an Authorization header. Simple but dangerously insecure without HTTPS, since base64 is encoding (not encryption) and easily decoded.
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
Bearer Tokens: A generic term for tokens sent in the Authorization header. OAuth 2.0 access tokens are the most common example.
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
JWT (JSON Web Tokens): Self-contained tokens that encode user claims (identity, permissions, expiration) in a verifiable format. JWTs are stateless — the server doesn’t store session data — and cryptographically signed to prevent tampering. We’ll explore JWTs in depth shortly.
OAuth 2.0: An authorization framework that delegates authentication to a trusted provider (Google, GitHub, Microsoft). The client never sees the user’s password; instead, the provider issues tokens that the client uses to access resources. OAuth is the industry standard for web and mobile authentication.
Mutual TLS (mTLS): Both client and server authenticate each other using X.509 certificates. Common in service-to-service communication within cloud infrastructure. Offers strong cryptographic guarantees but adds operational complexity.
Authorization Models
Once you’ve authenticated a user, how do you decide what they can access?
Role-Based Access Control (RBAC): Users are assigned roles (admin, editor, viewer), and each role has a set of permissions. Simple and intuitive. “Is the user an admin? Grant access.” RBAC scales well for homogeneous permission models.
Attribute-Based Access Control (ABAC): Permissions are granted based on attributes of the user, resource, and action. “Allow if user department equals resource owner’s department and action is not delete.” More expressive and flexible than RBAC but harder to reason about.
Scope-Based Authorization: OAuth scopes define granular permissions: read:users, write:posts, admin:config. Scopes are coarse-grained but user-friendly and widely understood.
Policy-Based Authorization: Permissions are defined by policies evaluated at request time. Tools like Open Policy Agent (OPA) let you express complex policies in a dedicated language. “Allow if (user.role == ‘editor’ AND resource.published == false) OR user.id == resource.author_id.”
The right model depends on your domain. RBAC is sufficient for most APIs. ABAC or policy-based approaches are necessary when permissions depend on complex context or relationships.
The Building Analogy
Think of authentication and authorization as a physical building’s security:
Authentication is the ID check at the front door. The security guard examines your driver’s license, verifies it’s legitimate, and confirms you’re the person in the photo. You’ve proven who you are.
Authorization is the keycard system inside. Your keycard (encoded with your identity and permissions) opens doors you’re allowed through: your floor, the cafeteria, maybe the gym. But it won’t open the server room (too privileged), the CEO’s office (wrong department), or the locked bathroom on the third floor (not your zone). Different people have different keycards with different access levels.
An API key is like a generic visitor badge — anyone with the same badge can do the same things. A JWT is like a driver’s license — it contains information about you (name, address, birth date — your “claims”) that the bouncer can verify by checking the signature without calling the DMV. An OAuth token is like a temporary day pass — you got it from an authorized issuer (the visitor center), and it works here, but it expires at the end of the day.
Under the Hood: How It Works
API Keys in Practice
API key validation is straightforward but requires discipline:
# Insecure: Never do this
stored_keys = {
"sk_live_4eC39HqLyjWDarhtT657j8wp": "customer_1",
"sk_live_otherkey": "customer_2",
}
@app.middleware
def authenticate(request):
key = request.headers.get("X-API-Key")
if key in stored_keys:
request.user_id = stored_keys[key]
else:
raise Unauthorized()
This approach stores keys in plaintext, making them vulnerable if your database is breached. Instead, hash keys using bcrypt, the same way you’d hash passwords:
import bcrypt
# During key creation
plain_key = "sk_live_4eC39HqLyjWDarhtT657j8wp"
hashed = bcrypt.hashpw(plain_key.encode(), bcrypt.gensalt())
# Store hashed value in database
# During validation
provided_key = request.headers.get("X-API-Key")
if bcrypt.checkpw(provided_key.encode(), stored_hash):
request.user_id = customer_id
else:
raise Unauthorized()
Additionally:
- Rotate keys regularly (quarterly or upon suspected compromise).
- Scope keys to specific resources (one key for webhooks, another for data export).
- Log all key usage for audit trails.
- Implement rate limiting on authentication failures.
JWT: Self-Contained Identity
A JWT is a three-part token: header.payload.signature.
// Header (specifies algorithm)
{
"alg": "HS256",
"typ": "JWT"
}
// Payload (claims about the user)
{
"iss": "https://auth.example.com", // issuer
"sub": "user_12345", // subject (user ID)
"aud": "api.example.com", // audience
"exp": 1640995200, // expiration (Unix timestamp)
"iat": 1640908800, // issued at
"email": "[email protected]",
"role": "editor",
"permissions": ["read:users", "write:posts"]
}
// Signature (HMAC or RSA signature of header + payload)
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
The beauty of JWTs is statelessness. The server doesn’t maintain a session database. To verify a JWT:
- Decode the header and payload (no secret needed — they’re base64).
- Verify the signature using the issuer’s public key (for asymmetric signing).
- Check expiration:
exptimestamp should be greater than current time. - Validate claims:
audshould match your API,issshould be your trusted issuer.
import jwt
from datetime import datetime, timedelta
# Creating a JWT
payload = {
"sub": "user_12345",
"email": "[email protected]",
"role": "editor",
"exp": datetime.utcnow() + timedelta(hours=1),
"iat": datetime.utcnow(),
}
token = jwt.encode(payload, secret_key, algorithm="HS256")
# Validating a JWT
try:
decoded = jwt.decode(token, secret_key, algorithms=["HS256"])
print(f"User: {decoded['sub']}")
except jwt.ExpiredSignatureError:
print("Token has expired")
except jwt.InvalidSignatureError:
print("Token is forged")
JWT Pitfalls:
- No built-in revocation: If you need to revoke a token (e.g., user logs out), you must maintain a blacklist or use short expiration with refresh tokens.
- Token bloat: Large payloads increase bandwidth overhead. Don’t include unnecessary claims.
- Sensitive data: The payload is base64-encoded, not encrypted. Never put passwords, API keys, or credit card numbers in a JWT.
- Clock skew: If server clocks are out of sync, expiration validation may fail. Use a small tolerance (e.g., 5 seconds).
Refresh Token Rotation
To balance security and user experience, use short-lived access tokens (15 minutes) with longer-lived refresh tokens (7 days):
# Initial login: issue both tokens
access_token = create_jwt(user_id, expires_in=15*60) # 15 minutes
refresh_token = create_refresh_token(user_id, expires_in=7*24*60*60) # 7 days
# Client stores both securely
response = {
"access_token": access_token,
"refresh_token": refresh_token,
"expires_in": 900, # seconds
}
# When access token expires, client uses refresh token
POST /api/auth/refresh
{
"refresh_token": refresh_token
}
# Server validates refresh token (check database, not just signature)
# and issues new access token + new refresh token (rotation)
new_access_token = create_jwt(user_id, expires_in=15*60)
new_refresh_token = create_refresh_token(user_id, expires_in=7*24*60*60)
This pattern limits the damage if an access token is compromised (only 15 minutes of access), while refresh token rotation (issuing a new one on each refresh) prevents token replay attacks.
OAuth 2.0: Delegated Authentication
OAuth 2.0 is not a single protocol but a framework with multiple “grant types” for different scenarios:
Authorization Code Flow (web applications):
- User clicks “Login with Google”
- Your app redirects to Google’s authorization server
- User logs in and grants permission
- Google redirects back with an authorization code
- Your app exchanges the code for an access token (backend-to-backend, no user involvement)
- Your app uses the access token to fetch user info from Google
This flow is secure because the user’s credentials never touch your app, and tokens are exchanged backend-to-backend.
Client Credentials Flow (service-to-service):
- Service A needs to call Service B
- Service A presents credentials (client ID + client secret) to an authorization server
- Authorization server issues an access token
- Service A uses the token to call Service B
No user involved — just machine-to-machine authentication and authorization.
PKCE (Proof Key for Code Exchange) (mobile and single-page apps): A security enhancement to the Authorization Code flow that prevents authorization code interception in mobile apps and SPAs.
Device Code Flow (CLIs, smart TVs, devices without browsers):
- Device requests a device code from the authorization server
- Authorization server returns a code and a URL
- Device displays “Go to https://auth.example.com/device?code=ABC123”
- User opens URL on any device and approves
- Device polls the authorization server, which eventually returns an access token
Here’s a visualization of the OAuth Authorization Code flow:
sequenceDiagram
participant User
participant Client as Your App
participant AuthServer as Authorization Server<br/>(Google/GitHub)
participant API as Resource Server<br/>(Google API)
User->>Client: Click "Login with Google"
Client->>AuthServer: Redirect to authorize?client_id=...&redirect_uri=...
AuthServer->>User: Show login + consent screen
User->>AuthServer: Approve access
AuthServer->>Client: Redirect with code parameter
Client->>AuthServer: Exchange code for token<br/>(backend, client_secret)
AuthServer->>Client: Return access_token
Client->>API: GET /userinfo<br/>Authorization: Bearer token
API->>Client: Return user data
Client->>User: Log in, set session
OIDC: Adding Authentication to OAuth
OAuth 2.0 is an authorization framework — it doesn’t inherently prove identity, only provide access tokens. OpenID Connect (OIDC) adds an authentication layer on top, issuing an ID token alongside the access token. The ID token is a JWT containing identity claims (name, email, profile picture). This is why modern “Login with Google” flows use OIDC, not bare OAuth.
Building a Secure API with RBAC
Let’s implement role-based access control in a practical API:
from flask import Flask, request, jsonify
import jwt
app = Flask(__name__)
SECRET_KEY = "your-secret-key"
# Middleware to extract and validate JWT
@app.before_request
def authenticate():
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return jsonify({"error": "Missing or invalid authorization header"}), 401
token = auth_header[7:] # Remove "Bearer "
try:
request.user = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
return jsonify({"error": "Token expired"}), 401
except jwt.InvalidSignatureError:
return jsonify({"error": "Invalid token"}), 401
# Decorator to enforce role-based access
def require_role(*roles):
def decorator(f):
def wrapper(*args, **kwargs):
if request.user.get("role") not in roles:
return jsonify({"error": "Insufficient permissions"}), 403
return f(*args, **kwargs)
wrapper.__name__ = f.__name__
return wrapper
return decorator
@app.route("/api/users", methods=["GET"])
@require_role("admin", "editor")
def list_users():
return jsonify([
{"id": 1, "name": "Alice", "role": "editor"},
{"id": 2, "name": "Bob", "role": "viewer"},
])
@app.route("/api/users/<user_id>", methods=["DELETE"])
@require_role("admin")
def delete_user(user_id):
# Only admins can delete
return jsonify({"message": f"User {user_id} deleted"}), 200
@app.route("/api/me", methods=["GET"])
def get_current_user():
# All authenticated users can access their own profile
return jsonify(request.user), 200
For more granular control, use scopes:
def require_scope(*scopes):
def decorator(f):
def wrapper(*args, **kwargs):
token_scopes = request.user.get("scope", "").split()
if not any(scope in token_scopes for scope in scopes):
return jsonify({"error": "Insufficient permissions"}), 403
return f(*args, **kwargs)
wrapper.__name__ = f.__name__
return wrapper
return decorator
@app.route("/api/payments", methods=["POST"])
@require_scope("write:payments")
def create_payment():
# Only tokens with write:payments scope
return jsonify({"payment_id": "pay_123"}), 201
Comparing Authentication Methods
Choosing the right approach depends on your architecture:
| Method | Use Case | Complexity | Revocation | Statefulness | Best For |
|---|---|---|---|---|---|
| API Keys | Server-to-server, public APIs | Low | Database lookup | Stateful | Internal tools, webhooks |
| Basic Auth | Legacy systems, internal APIs | Very Low | N/A (per-request) | Stateful | Quick prototypes (not production) |
| JWT | Stateless microservices, SPAs | Medium | Blacklist or short expiry | Stateless | Modern web/mobile apps |
| OAuth 2.0 | Delegated auth, third-party apps | High | Depends on issuer | Stateless | Social login, integrations |
| mTLS | Service-to-service in clusters | Very High | Certificate revocation | Stateless | Kubernetes, service mesh |
Real-World Considerations
Token Expiration and Size
Tokens have trade-offs. Shorter expiration (5 minutes) improves security but increases refresh token requests. Longer expiration (24 hours) improves performance but increases breach window. A typical balance is 15 minutes for access tokens.
Token size grows with claims. A JWT with 10 claims might be 500 bytes. If your API serves millions of requests per second, this overhead matters. Minimize claims and use refresh tokens to avoid encoding unnecessary data in every request.
From Building Your Own Auth to Using Managed Services
Early on, you might implement auth yourself (as shown above). But managing authentication at scale is complex:
- User registration, email verification, password reset flows
- Multi-factor authentication (MFA) support
- Attack detection (brute force, credential stuffing)
- Compliance with standards (OIDC, SAML)
- Audit logging and compliance reporting
Managed services like Auth0, AWS Cognito, Google Cloud Identity, Keycloak (open-source), or Firebase Auth handle these. They let you focus on your API logic, not security plumbing. The trade-off is cost and vendor lock-in.
Audit Logging and Monitoring
Log every authentication and authorization decision:
import logging
import json
logger = logging.getLogger("auth")
@app.after_request
def log_auth(response):
if hasattr(request, "user"):
log_entry = {
"timestamp": datetime.utcnow().isoformat(),
"user_id": request.user.get("sub"),
"method": request.method,
"path": request.path,
"status": response.status_code,
"ip": request.remote_addr,
}
logger.info(json.dumps(log_entry))
return response
Monitor for:
- Multiple failed authentication attempts (brute force)
- Unusual access patterns (user suddenly accessing admin resources)
- Expired tokens being reused
- Tokens with manipulated claims
Key Takeaways
- Authentication and authorization are prerequisites, not afterthoughts. Design them before building your API.
- Use HTTPS everywhere. Tokens are only secure in transit if encrypted.
- Apply the principle of least privilege. Give users and services only the permissions they need.
- Prefer stateless tokens (JWT) for scalability, but accept the trade-off of short expiration times and potential difficulty revoking tokens.
- Use OAuth 2.0 and OIDC for user-facing apps, but keep API key or mTLS for service-to-service communication.
- Rotate credentials regularly (keys, client secrets, certificates) and implement monitoring for unusual access patterns.
Practice Scenarios
Scenario 1: Mobile App Security You’re building a mobile app that calls your backend API. The app should work offline and not require the user to log in frequently. However, if the user uninstalls the app and later reinstalls it, they should need to authenticate again. Design an authentication strategy that balances convenience and security.
Hint: Consider refresh tokens, secure local storage, and token rotation.
Scenario 2: Multi-Tenant SaaS Your SaaS platform serves 10,000 customers, each with their own API tokens for integrations. A customer accidentally commits their API token to a GitHub repo, where it’s discovered by attackers. What mechanisms would you implement to detect and respond to token compromise quickly?
Hint: Rate limiting, anomaly detection, token metadata, and invalidation strategies.
Scenario 3: Microservices Authentication You have 20 microservices in your architecture. Each service needs to authenticate requests from other services, and the identity of the calling service matters for audit trails. Should you use OAuth 2.0, mTLS, or something else? What are the operational trade-offs?
Hint: Consider certificate management, token issuance, network overhead, and observability.
Looking Ahead
Authentication and authorization are foundational to API security, but they’re only one piece of the puzzle. In the next chapter, we’ll explore how these principles scale across distributed systems. We’ll discuss resilience patterns, graceful degradation, and how to keep your API secure and performant under load. As you move from designing individual APIs to orchestrating systems of services, you’ll apply these security patterns alongside monitoring, rate limiting, and circuit breakers to build truly production-grade infrastructure.