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:

http
GET /v1/resource HTTP/1.1
Host: api.example.com
Authorization: Bearer sk_live_abc123xyz...

Some APIs use a custom header (e.g., Anthropic):

http
POST /v1/messages HTTP/1.1
Host: api.anthropic.com
x-api-key: sk-ant-api03-...
anthropic-version: 2023-06-01
Content-Type: application/json

Store keys in environment variables, never in source code:

bash
# .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:

bash
# 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:

python
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.

python
# 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

text
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9   ← header (base64url)
.eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTc0NDQ4MDAwMH0  ← payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c   ← HMAC or RSA signature

Validate a JWT in Python using PyJWT:

python
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
typescript
// 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: none header — always specify allowed algorithms explicitly
  • Always validate exp, iss, and aud claims
  • 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.

bash
# 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.crt
python
import 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.

python
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()),
)
bash
curl https://api.stripe.com/v1/payment_intents \
  -u "${STRIPE_SECRET_KEY}:" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d amount=2000 \
  -d currency=usd

Twilio — 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.

python
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.

python
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",
)
bash
# AWS CLI uses the same credential chain
aws s3 cp file.txt s3://my-bucket/file.txt --region us-east-1

PayPal / 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).

bash
# 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

PatternBest forRevocableStatelessComplexity
API KeyM2M, simple integrationsYesNo (server lookup)Low
OAuth 2.0User-delegated accessYesDepends on token formatMedium
JWTStateless microservicesRequires allowlistYesMedium
mTLSHigh-security M2MYes (CRL/OCSP)YesHigh