API Authentication Patterns
Most REST APIs use one of four authentication patterns: static API keys, OAuth 2.0 tokens, JWTs, or mutual TLS. Choosing the right pattern depends on your threat model, user session requirements, and whether you are authenticating a machine-to-machine integration or an end user.
1. API Keys
API keys are static secrets issued by the provider and sent with every request. They are the simplest auth mechanism and are appropriate for server-to-server integrations where the key can be stored securely. Never expose an API key in client-side code or a public repository.
When to use
- Server-to-server integrations
- Internal tooling and scripts
- Webhooks and backend workers
- Simple developer integrations
Avoid when
- Key would be exposed to end users
- Fine-grained per-user permissions needed
- Token rotation is a compliance requirement
- Third-party access delegation required
Most providers send the key as a Bearer token in the Authorization header:
GET /v1/resource HTTP/1.1
Host: api.example.com
Authorization: Bearer sk_live_abc123xyz...Some APIs use a custom header (e.g., Anthropic):
POST /v1/messages HTTP/1.1
Host: api.anthropic.com
x-api-key: sk-ant-api03-...
anthropic-version: 2023-06-01
Content-Type: application/jsonStore keys in environment variables, never in source code:
# .env (never commit this file)
OPENAI_API_KEY=sk-proj-...
STRIPE_SECRET_KEY=sk_live_...
# Reference in code
import os
api_key = os.environ["OPENAI_API_KEY"]2. OAuth 2.0
OAuth 2.0 is a delegation framework, not an authentication protocol. It lets users grant third-party applications access to their resources without sharing credentials. There are four grant types; the two most common in API work are Client Credentials (machine-to-machine) and Authorization Code (user-delegated access).
Client Credentials Flow (M2M)
Used for server-to-server calls where no user is involved:
# Step 1: Exchange client ID + secret for an access token
curl -X POST https://api.example.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}" \
-d "scope=read:data write:data"
# Response
# { "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600 }
# Step 2: Use the token
curl https://api.example.com/v1/resource \
-H "Authorization: Bearer eyJ..."Authorization Code Flow (User Access)
Used when a user must authorize your application to act on their behalf:
import urllib.parse, secrets
# Step 1: Redirect user to authorization URL
state = secrets.token_urlsafe(16)
params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": "https://yourapp.com/callback",
"scope": "read:user",
"state": state,
}
auth_url = "https://api.example.com/oauth/authorize?" + urllib.parse.urlencode(params)
# Redirect user to auth_url
# Step 2: Exchange the callback code for tokens
import httpx
resp = httpx.post("https://api.example.com/oauth/token", data={
"grant_type": "authorization_code",
"code": request.query_params["code"],
"redirect_uri": "https://yourapp.com/callback",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
})
tokens = resp.json()
# tokens["access_token"], tokens["refresh_token"]Token refresh
Access tokens expire (typically 1 hour). Use the refresh token to obtain a new access token without requiring the user to re-authorize. Store refresh tokens securely — they are long-lived secrets equivalent in sensitivity to a password.
# Refresh an expired access token
resp = httpx.post("https://api.example.com/oauth/token", data={
"grant_type": "refresh_token",
"refresh_token": stored_refresh_token,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
})
new_tokens = resp.json()3. JSON Web Tokens (JWT)
JWTs are self-contained tokens that encode claims (user ID, roles, expiry) and are cryptographically signed. Because the signature can be verified without a database lookup, JWTs are well-suited to stateless, horizontally-scaled APIs. They are commonly used as the access token format within OAuth 2.0 flows.
A JWT has three parts separated by dots: header.payload.signature
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9 ← header (base64url)
.eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTc0NDQ4MDAwMH0 ← payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← HMAC or RSA signatureValidate a JWT in Python using PyJWT:
import jwt # pip install PyJWT
# Verify and decode (RS256 with public key)
try:
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"],
audience="https://api.yourapp.com",
)
user_id = payload["sub"]
role = payload.get("role", "user")
except jwt.ExpiredSignatureError:
# Token has expired -- return 401
raise
except jwt.InvalidTokenError:
# Signature invalid or claims mismatch -- return 401
raise// Verify a JWT in Node.js using jose
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(
new URL("https://auth.example.com/.well-known/jwks.json")
);
async function verifyToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: "https://auth.example.com/",
audience: "https://api.yourapp.com",
});
return payload;
}Common JWT pitfalls
- Never accept the
alg: noneheader — always specify allowed algorithms explicitly - Always validate
exp,iss, andaudclaims - Use asymmetric keys (RS256, ES256) for tokens consumed by third parties
- Keep JWT payloads small — they are base64-encoded, not encrypted
4. Mutual TLS (mTLS)
In standard TLS, only the server presents a certificate. In mutual TLS (mTLS), both the server and client present certificates, allowing the server to verify the client's identity at the transport layer. This is the strongest authentication mechanism available for API access and is commonly required in financial services, healthcare, and high-security enterprise integrations.
# Generate a client certificate (self-signed, for testing)
openssl req -x509 -newkey rsa:4096 -keyout client.key -out client.crt \
-days 365 -nodes -subj "/CN=my-service"
# Use the certificate in a curl request
curl https://api.example.com/v1/resource \
--cert client.crt \
--key client.key \
--cacert server-ca.crtimport httpx
# Use mTLS with httpx
client = httpx.Client(
cert=("client.crt", "client.key"),
verify="server-ca.crt",
)
resp = client.get("https://api.example.com/v1/resource")APIs using mTLS: AWS Signature V4 with mutual authentication, some banking APIs (Open Banking / PSD2), Cloudflare Zero Trust tunnels, and gRPC service meshes.
5. Provider-specific examples
Stripe — API Key with idempotency
Stripe uses HTTP Basic Auth with the API key as the username. For write operations, always send an Idempotency-Key header to safely retry on network failure.
import stripe, uuid
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
# Create a payment intent with idempotency
intent = stripe.PaymentIntent.create(
amount=2000, # cents
currency="usd",
idempotency_key=str(uuid.uuid4()),
)curl https://api.stripe.com/v1/payment_intents \
-u "${STRIPE_SECRET_KEY}:" \
-H "Idempotency-Key: $(uuidgen)" \
-d amount=2000 \
-d currency=usdTwilio — Account SID + Auth Token
Twilio uses HTTP Basic Auth with your Account SID as the username and Auth Token as the password. For production, consider API Keys instead of the master Auth Token to limit blast radius.
from twilio.rest import Client
client = Client(
os.environ["TWILIO_ACCOUNT_SID"],
os.environ["TWILIO_AUTH_TOKEN"],
)
message = client.messages.create(
body="Hello from APIBench",
from_="+15551234567",
to="+15559876543",
)AWS — Signature Version 4
AWS uses a request signing scheme (SigV4) that includes a canonical request hash, date, region, and HMAC-SHA256 signature. Use the SDK or boto3 to handle signing automatically.
import boto3
# boto3 reads AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,
# and AWS_SESSION_TOKEN from environment automatically.
s3 = boto3.client("s3", region_name="us-east-1")
# Upload an object (signing happens transparently)
s3.put_object(
Bucket="my-bucket",
Key="example.txt",
Body=b"Hello",
)# AWS CLI uses the same credential chain
aws s3 cp file.txt s3://my-bucket/file.txt --region us-east-1PayPal / Square — OAuth 2.0 Client Credentials
Both PayPal and Square require an access token obtained via Client Credentials before calling any API endpoint. Tokens expire in 9 hours (PayPal) or 30 days (Square).
# PayPal: get an access token
curl -X POST https://api-m.sandbox.paypal.com/v1/oauth2/token \
-u "${PAYPAL_CLIENT_ID}:${PAYPAL_CLIENT_SECRET}" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials"
# Use the token
curl https://api-m.sandbox.paypal.com/v2/checkout/orders \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{"intent":"CAPTURE","purchase_units":[{"amount":{"currency_code":"USD","value":"10.00"}}]}'6. Pattern comparison
| Pattern | Best for | Revocable | Stateless | Complexity |
|---|---|---|---|---|
| API Key | M2M, simple integrations | Yes | No (server lookup) | Low |
| OAuth 2.0 | User-delegated access | Yes | Depends on token format | Medium |
| JWT | Stateless microservices | Requires allowlist | Yes | Medium |
| mTLS | High-security M2M | Yes (CRL/OCSP) | Yes | High |