A Practical Guide to Authentication & Authorization Methods
Most API security incidents don't happen because attackers found a clever zero-day. They happen because a developer grabbed the first auth pattern that came to mind, shipped it, and moved on.
I've seen API keys committed to public repos, JWTs without expiry running in production, and OAuth flows that skip PKCE on mobile clients. These aren't exotic mistakes — they're the default outcome when engineers don't have a clear map of what each method does, when it fits, and where it breaks down.
This guide gives you that map. We'll cover every major authentication and authorization method used to secure web APIs today, with code examples in Python and a decision matrix at the end so you can match the right tool to your specific context.
Authentication vs. Authorization — Get This Right First
These two words are often used interchangeably. That's a problem, because conflating them leads to real vulnerabilities.
- Authentication answers: Who are you? It verifies identity.
- Authorization answers: What are you allowed to do? It enforces permissions.
A request can be authenticated (we know it's from User A) but unauthorized (User A doesn't have access to this resource). A system that only checks "is this a valid token?" without checking "does this token have permission to do this?" is wide open to privilege escalation.
Keep both in mind as we go through each method.
1. API Keys
How it works
An API key is a long, random string generated by the server and shared with the client. The client sends it on every request, typically in a header:
GET /v1/data HTTP/1.1
Host: api.example.com
X-API-Key: sk_live_a3f8c2d1e9b7...
On the server side, you validate it against your store:
import secrets
from functools import wraps
from flask import Flask, request, jsonify
app = Flask(__name__)
# In production, store these in a database with metadata
# (owner, scopes, rate limit tier, created_at, last_used)
VALID_KEYS = {
"sk_live_a3f8c2d1e9b7abc123": {"owner": "client_A", "scopes": ["read"]},
"sk_live_z9y8x7w6v5u4t3s2r1": {"owner": "client_B", "scopes": ["read", "write"]},
}
def require_api_key(f):
@wraps(f)
def decorated(*args, **kwargs):
key = request.headers.get("X-API-Key")
if not key or key not in VALID_KEYS:
return jsonify({"error": "Invalid or missing API key"}), 401
request.api_client = VALID_KEYS[key]
return f(*args, **kwargs)
return decorated
@app.route("/v1/data")
@require_api_key
def get_data():
return jsonify({"message": f"Hello, {request.api_client['owner']}"})
When to use it
API keys work well for server-to-server communication where the client is a known system (not an end user), especially for public APIs where you want to track usage per consumer, enforce rate limits, or tier access. They're also the right choice for simple internal tooling where OAuth 2.0 would be overkill.
Security tradeoffs
- Keys don't expire by default — a leaked key is valid until you rotate it manually.
- They carry no user identity, only client identity.
- Never put them in URLs (they end up in server logs). Always use headers.
- Use
secrets.compare_digest()instead of==to prevent timing attacks when comparing keys.
2. Basic Authentication
How it works
The client encodes username:password in Base64 and sends it in the Authorization header on every request:
GET /v1/resource HTTP/1.1
Authorization: Basic c2hvdW1pazpteXBhc3N3b3Jk
import base64
from flask import request, jsonify
def check_basic_auth():
auth = request.headers.get("Authorization", "")
if not auth.startswith("Basic "):
return None
try:
decoded = base64.b64decode(auth[6:]).decode("utf-8")
username, password = decoded.split(":", 1)
return username, password
except Exception:
return None
When to use it
Basic Auth is appropriate for internal tools, admin dashboards behind a VPN, or simple scripts that call internal APIs. It is always required over HTTPS — Base64 is encoding, not encryption, and credentials are trivially reversible over plain HTTP.
Security tradeoffs
- Credentials are sent on every single request. One intercepted request = credentials compromised.
- No token expiry, no scoping, no revocation without a password change.
- Avoid for any externally exposed or user-facing API. It has no place in a mobile or browser client.
3. JWT — JSON Web Tokens
How it works
A JWT is a self-contained, signed token with three Base64URL-encoded parts: a header (algorithm), a payload (claims), and a signature. The server signs it on login; the client sends it back on every subsequent request. The server verifies the signature without hitting a database.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← header
.eyJ1c2VyX2lkIjoiMTIzIiwiZXhwIjoxNzE... ← payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_... ← signature
import jwt
import datetime
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET_KEY = "use-a-strong-secret-from-env-not-hardcoded"
def generate_token(user_id: str) -> str:
now = datetime.datetime.now(datetime.timezone.utc)
payload = {
"sub": user_id,
"iat": now,
"exp": now + datetime.timedelta(hours=1),
"scopes": ["read", "write"],
}
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
def verify_token(token: str) -> dict:
try:
# Always specify algorithms explicitly — never pass algorithms=None
return jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise ValueError("Token has expired")
except jwt.InvalidTokenError:
raise ValueError("Invalid token")
@app.route("/login", methods=["POST"])
def login():
# Validate credentials here (omitted for brevity)
token = generate_token(user_id="user_123")
return jsonify({"access_token": token, "token_type": "Bearer"})
@app.route("/v1/profile")
def profile():
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return jsonify({"error": "Missing token"}), 401
try:
claims = verify_token(auth[7:])
return jsonify({"user_id": claims["sub"]})
except ValueError as e:
return jsonify({"error": str(e)}), 401
When to use it
JWTs shine in stateless, distributed systems — microservices, APIs behind a CDN, or anywhere you can't share session state between servers. They're the standard for single-page apps and mobile clients that authenticate once and carry claims across services.
The most common JWT mistakes
| Mistake | Consequence | Fix |
|---|---|---|
alg: none accepted |
Anyone can forge a valid token | Always specify algorithms=["HS256"] explicitly |
No expiry (exp claim missing) |
Leaked tokens are valid forever | Always set exp; 15–60 min for access tokens |
| Secret stored in code | Any repo leak = all tokens compromised | Load from environment variable or secrets manager |
| Storing JWT in localStorage | XSS can steal it | Use HttpOnly cookies or a BFF pattern |
Trusting alg header from client |
Algorithm confusion attacks | Pin algorithm server-side |
4. OAuth 2.0
How it works
OAuth 2.0 is a delegation framework, not an authentication protocol. It lets a user grant a third-party application limited access to their resources without sharing their password.
The most important flows:
Authorization Code + PKCE (for browser/mobile clients — the only safe flow for public clients):
User → App → Authorization Server
← Authorization Code
App → Authorization Server (code + PKCE verifier)
← Access Token + Refresh Token
App → Resource Server (Access Token)
← Protected Resource
Client Credentials (for machine-to-machine, no user involved):
import httpx
def get_machine_token(client_id: str, client_secret: str, token_url: str) -> str:
response = httpx.post(token_url, data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": "api:read api:write",
})
response.raise_for_status()
return response.json()["access_token"]
# Use the token
token = get_machine_token(
client_id="my-service",
client_secret="from-env",
token_url="https://auth.example.com/oauth/token"
)
headers = {"Authorization": f"Bearer {token}"}
When to use it
Use OAuth 2.0 whenever you need delegated access — a user authorizing your app to act on their behalf with another service (Google Drive, GitHub, Stripe). Also use Client Credentials for any service-to-service communication within a trusted internal network.
Critical: always use PKCE for public clients
PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. Any OAuth flow running in a browser or mobile app that cannot securely store a client secret must use PKCE. It is no longer optional — the OAuth 2.1 draft mandates it for all flows.
5. OpenID Connect (OIDC)
How it works
OpenID Connect is an authentication layer built on top of OAuth 2.0. While OAuth 2.0 says "here is an access token that grants you access to a resource," OIDC adds an ID Token (a JWT) that asserts the user's identity.
# After OAuth flow completes, decode the ID token
import jwt
from jwt import PyJWKClient
def verify_id_token(id_token: str, issuer: str, client_id: str) -> dict:
# Fetch public keys from the provider's JWKS endpoint
jwks_client = PyJWKClient(f"{issuer}/.well-known/jwks.json")
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
return jwt.decode(
id_token,
signing_key.key,
algorithms=["RS256"],
audience=client_id,
issuer=issuer,
)
claims = verify_id_token(
id_token=token_response["id_token"],
issuer="https://accounts.google.com",
client_id="your-client-id",
)
print(claims["email"]) # Verified user email
print(claims["sub"]) # Stable user identifier
When to use it
Any time you need "Sign in with Google/GitHub/Microsoft" or need to verify who the user is (not just what they can access), OIDC is the right choice. It eliminates the need to manage passwords entirely for federated identity scenarios.
6. HMAC Request Signing
How it works
Instead of sending a credential, the client uses a shared secret to generate a cryptographic signature of the request itself — the method, URL, timestamp, and body. The server recomputes the signature and compares.
import hmac
import hashlib
import time
import base64
def sign_request(method: str, path: str, body: bytes, secret: str) -> dict:
timestamp = str(int(time.time()))
message = f"{method}\n{path}\n{timestamp}\n{hashlib.sha256(body).hexdigest()}"
signature = base64.b64encode(
hmac.new(secret.encode(), message.encode(), hashlib.sha256).digest()
).decode()
return {
"X-Timestamp": timestamp,
"X-Signature": signature,
}
def verify_request(method: str, path: str, body: bytes,
secret: str, headers: dict) -> bool:
timestamp = headers.get("X-Timestamp", "")
# Reject requests older than 5 minutes — prevents replay attacks
if abs(time.time() - int(timestamp)) > 300:
return False
expected = sign_request(method, path, body, secret)
return hmac.compare_digest(headers.get("X-Signature", ""), expected["X-Signature"])
When to use it
HMAC signing is the right choice for webhooks (Stripe, GitHub, and Twilio all use it), high-integrity financial APIs, and scenarios where you need to prove that a request hasn't been tampered with in transit. AWS Signature Version 4 is HMAC under the hood.
Why it's stronger than API keys for sensitive operations
An API key proves identity but not integrity. If an attacker can intercept and modify a request, the key is still valid. HMAC signs the entire request, so any modification breaks the signature. It also includes a timestamp, preventing replay attacks.
7. Mutual TLS (mTLS)
How it works
In standard TLS, only the server presents a certificate to prove its identity. In mTLS, both the client and server present certificates. This means the server cryptographically verifies the client's identity at the transport layer — before any application code runs.
import ssl
import httpx
# Client presents its certificate on every request
ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
ssl_context.load_verify_locations("ca-cert.pem") # Trust this CA
ssl_context.load_cert_chain("client-cert.pem", "client-key.pem") # Our identity
with httpx.Client(verify=ssl_context) as client:
response = client.get("https://internal-service.example.com/api/data")
print(response.json())
On the server (nginx configuration example):
server {
listen 443 ssl;
ssl_certificate /etc/ssl/server-cert.pem;
ssl_certificate_key /etc/ssl/server-key.pem;
ssl_client_certificate /etc/ssl/ca-cert.pem;
ssl_verify_client on; # Reject any request without a valid client cert
location /api/ {
proxy_pass http://app:8000;
proxy_set_header X-Client-Cert $ssl_client_cert;
}
}
When to use it
mTLS is the standard for zero-trust architectures, service meshes (Istio, Linkerd), and any microservice communication where you need hardware-level proof of identity. If you're running services in Kubernetes and want no implicit trust between pods, mTLS is the foundation. It is operationally complex (certificate rotation, PKI management) but offers the strongest guarantees.
Decision Matrix
Use this table to match your scenario to the right method:
| Scenario | Recommended Method | Why |
|---|---|---|
| Public API — third-party developers | API Keys | Simple to issue, rotate, and rate-limit per client |
| User logs in, gets access to their data | JWT + OAuth 2.0 | Stateless identity + delegated access |
| "Sign in with Google/GitHub" | OpenID Connect | Federated identity; no password management |
| Mobile / SPA accessing your API | OAuth 2.0 (PKCE) | PKCE protects public clients; never Client Credentials here |
| Service A calls Service B internally | OAuth 2.0 (Client Credentials) or mTLS | No user involved; machine-to-machine trust |
| Webhook endpoint receiving events | HMAC signature verification | Verify payload integrity and origin |
| Zero-trust / service mesh | mTLS | Transport-layer mutual identity, before any app code |
| Internal admin tool / script | Basic Auth over HTTPS | Simple, acceptable for low-risk internal use only |
| High-integrity financial / audit API | HMAC + JWT combined | Identity (JWT) + tamper-evidence (HMAC) |
Common Mistakes Across All Methods
1. Mixing up 401 and 403
Return 401 Unauthorized when the request has no valid credentials. Return 403 Forbidden when credentials are valid but the user lacks permission. Many APIs return 403 for everything — this breaks standard OAuth client libraries and confuses consumers.
2. Not validating token audience (aud)
A JWT from your auth server for Service A should not be accepted by Service B. Always validate the aud claim. An attacker who obtains a valid token for one service shouldn't be able to reuse it elsewhere.
3. Logging sensitive headers
It's shockingly common to log the full Authorization header in debug mode. One log aggregation misconfiguration and those tokens are in your SIEM or your cloud provider's log storage. Scrub auth headers before logging.
4. No token rotation
Access tokens expire (they should). Refresh tokens don't have to, but they need to be rotatable. Implement refresh token rotation — when a refresh token is used, issue a new one and invalidate the old one. If you detect reuse of an already-rotated token, revoke the entire family.
5. Trusting client-provided claims
Your API should verify tokens, not trust whatever claims a client sends in a custom header. Never do user_id = request.headers.get("X-User-Id") as your authorization check.
Conclusion
There's no single "best" authentication method — there's only the right method for the trust model, client type, and sensitivity level of your specific API. The biggest mistake isn't choosing the wrong method; it's treating auth as an afterthought and never revisiting the decision as your system evolves.
To recap:
- API Keys for known clients and developer APIs.
- Basic Auth only for internal tooling over HTTPS.
- JWT for stateless, distributed systems.
- OAuth 2.0 for delegated access and third-party integrations.
- OIDC whenever you need verified user identity.
- HMAC for webhooks and tamper-evident request signing.
- mTLS for zero-trust service-to-service communication.
Start with the decision matrix. Then read the spec for whichever method you choose — the RFCs and official documentation are far shorter than you'd expect, and reading them directly will save you from the edge cases that bite teams in production.
Top comments (0)