API key vs OAuth is the first authentication decision every LLM application team faces. The choice shapes your security model, operational burden, and how users experience your product. Most teams default to API keys because they’re simpler, but that simplicity becomes technical debt the moment you need delegated access, audit trails, or per-user billing. This post breaks down the trade-offs across the dimensions that actually matter in production.
How they work
API keys are opaque strings issued by a provider. The client sends the key in an Authorization: Bearer <key> header (or sometimes a custom header like x-api-key). The provider validates the key against a database, checks scopes or rate limits, and routes the request. There’s no handshake, no token refresh, no user consent flow. The key is the credential.
OAuth 2.0 (specifically the Authorization Code flow with PKCE for public clients) introduces a delegation layer. The user authenticates with the identity provider, consents to scopes, and receives an authorization code. Your backend exchanges that code for an access token (short-lived) and a refresh token (long-lived). Subsequent requests use the access token. When it expires, the refresh token obtains a new one without user interaction.
# API key request — one line
import httpx
client = httpx.Client(headers={"Authorization": f"Bearer {API_KEY}"})
resp = client.post("https://api.example.com/v1/chat/completions", json=payload)
# OAuth flow — multiple steps, token management
import httpx
from urllib.parse import urlencode
# 1. Redirect user to authorization server
auth_url = (
"https://auth.example.com/oauth/authorize?"
+ urlencode({
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": "inference:read inference:write",
"state": generate_state(),
"code_challenge": pkce_challenge(),
"code_challenge_method": "S256",
})
)
# 2. Handle callback, exchange code for tokens
async def exchange_code(code: str, verifier: str) -> TokenResponse:
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://auth.example.com/oauth/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"code_verifier": verifier,
},
)
return resp.json()
# 3. Use access token, refresh when expired
async def authenticated_request(access_token: str, payload: dict):
async with httpx.AsyncClient(
headers={"Authorization": f"Bearer {access_token}"}
) as client:
return await client.post("https://api.example.com/v1/chat/completions", json=payload)
Capabilities comparison
| Dimension | API key | OAuth 2.0 |
|---|---|---|
| Delegated access | No — key holder has full key privileges | Yes — user grants specific scopes to your client |
| Per-user identity | Requires custom mapping layer | Built-in via sub claim in access token |
| Revocation granularity | Whole key or nothing | Per-token, per-client, per-user |
| Audit trail | Key ID only | User ID, client ID, scopes, consent timestamp |
| Token lifetime | Static until rotated | Short-lived access tokens (5–60 min), rotating refresh tokens |
| Scope enforcement | Provider-defined tiers | Fine-grained, user-approved at consent time |
| Offline access | Native (key never expires unless rotated) | Requires offline_access scope + refresh token rotation |
| Provider support | Universal across LLM APIs | Varies — OpenAI, Anthropic, Google support it; many smaller providers don’t |
Latency and throughput
API keys add zero round trips after the first request. The key validates in a single database lookup (often cached in-memory or at the edge). Typical overhead: 1–3 ms at p99.
OAuth adds at least one extra round trip per session for the token exchange, plus periodic refresh requests. The authorization code exchange hits the token endpoint (usually a separate service from the inference API). Refresh tokens hit it again every 5–60 minutes. In a high-throughput LLM workload, this means:
- Cold start: +150–400 ms for the initial token exchange (network + auth server latency)
- Steady state: negligible if access tokens are cached client-side
- Refresh storms: if many clients refresh simultaneously (e.g., after a deploy), the token endpoint becomes a bottleneck
Mitigation: issue longer-lived access tokens (30–60 min) for trusted first-party clients, implement jittered refresh, and cache tokens in a distributed store (Redis) if you run multiple replicas.
# Token cache with automatic refresh — pattern for production OAuth clients
import time
import httpx
from dataclasses import dataclass
from threading import Lock
@dataclass
class CachedToken:
access_token: str
expires_at: float # unix timestamp
refresh_token: str
class TokenManager:
def __init__(self, client_id: str, token_url: str):
self.client_id = client_id
self.token_url = token_url
self._cache: dict[str, CachedToken] = {} # keyed by user_id
self._lock = Lock()
self._client = httpx.Client(timeout=10.0)
def get_token(self, user_id: str) -> str:
with self._lock:
cached = self._cache.get(user_id)
if cached and cached.expires_at > time.time() + 60: # 60s buffer
return cached.access_token
# Refresh or initial fetch
new_token = self._refresh(user_id, cached.refresh_token if cached else None)
self._cache[user_id] = new_token
return new_token.access_token
def _refresh(self, user_id: str, refresh_token: str | None) -> CachedToken:
data = {"grant_type": "refresh_token", "client_id": self.client_id}
if refresh_token:
data["refresh_token"] = refresh_token
else:
# Initial auth — assumes you have authorization code stored
raise ValueError("No refresh token; user must re-authenticate")
resp = self._client.post(self.token_url, data=data)
resp.raise_for_status()
payload = resp.json()
return CachedToken(
access_token=payload["access_token"],
expires_at=time.time() + payload["expires_in"],
refresh_token=payload["refresh_token"],
)
Ergonomics and developer experience
API keys win for:
- Server-to-server communication (your backend → LLM provider)
- CLI tools and scripts
- Internal services where you control both ends
- Rapid prototyping — one environment variable, done
OAuth wins for:
- User-facing applications where each user brings their own provider account
- Multi-tenant SaaS where customers connect their own OpenAI/Anthropic/Google credentials
- Compliance requirements (SOC 2, HIPAA) demanding per-user audit trails
- Scenarios where users must revoke access without rotating your master credentials
The ergonomics gap narrows with good libraries. authlib (Python), next-auth (Next.js), and oauth4webapi (framework-agnostic) handle PKCE, state, token storage, and refresh automatically. But you still own the redirect flow, callback route, secure token storage, and logout propagation.
Ecosystem and provider support
This is where LLM APIs diverge from standard SaaS.
| Provider | API key | OAuth 2.0 | Notes |
|---|---|---|---|
| OpenAI | ✅ | ✅ | Organization-level API keys; user OAuth for ChatGPT plugins / custom GPTs |
| Anthropic | ✅ | ✅ | Workspace API keys; OAuth for Claude.ai integrations |
| Google (Vertex AI / Gemini) | ✅ | ✅ | Service accounts (JWT) + user OAuth; ADC handles both |
| Azure OpenAI | ✅ | ✅ | Azure AD tokens (OAuth 2.0 client credentials + user flows) |
| Cohere | ✅ | ❌ | API keys only |
| Mistral | ✅ | ❌ | API keys only |
| Together / Fireworks / Anyscale | ✅ | ❌ | API keys only |
| n4n.ai | ✅ | ✅ | Single endpoint, 240+ models; forwards provider auth hints; per-token metering works with either method |
Most “model garden” gateways and inference providers only support API keys. If your application lets users bring their own keys (BYOK), you’re storing and forwarding API keys — effectively becoming a secrets manager. OAuth shifts that burden to the provider’s auth server, but only works where the provider implements it.
Security posture
API key risks:
- Keys leak in logs, git history, browser dev tools, error reporting
- No built-in rotation — you must build key rotation pipelines
- Compromised key = full access until revoked (no short expiry)
- Hard to enforce least privilege; most providers offer org-level or project-level keys, not per-endpoint scopes
OAuth risks:
- Refresh tokens are high-value targets; store encrypted at rest
- Authorization code interception (mitigated by PKCE, mandatory for public clients)
- Token endpoint becomes a denial-of-service target
- Complexity increases implementation bugs (state validation, redirect URI validation, scope downgrade attacks)
Practical hardening for API keys:
# Rotate keys programmatically — run as a scheduled job
import os
import httpx
from datetime import datetime, timedelta
def rotate_api_key(provider: str, current_key: str) -> str:
# Provider-specific; example for a hypothetical admin API
admin_client = httpx.Client(
headers={"Authorization": f"Bearer {os.getenv('ADMIN_API_KEY')}"},
timeout=30.0,
)
# Create new key
new_key_resp = admin_client.post(
f"https://api.{provider}.com/v1/admin/keys",
json={"name": f"auto-rotated-{datetime.utcnow().isoformat()}", "expires_in_days": 30},
)
new_key_resp.raise_for_status()
new_key = new_key_resp.json()["key"]
# Verify new key works
test_resp = admin_client.post(
f"https://api.{provider}.com/v1/models",
headers={"Authorization": f"Bearer {new_key}"},
)
test_resp.raise_for_status()
# Revoke old key (after grace period or immediately)
admin_client.delete(f"https://api.{provider}.com/v1/admin/keys/{current_key}")
return new_key
Practical hardening for OAuth:
- Enforce PKCE for all clients (including confidential ones — defense in depth)
- Validate
redirect_uriagainst exact allowlist (no wildcards, no open redirects) - Store refresh tokens encrypted (AES-GCM or cloud KMS)
- Implement token binding (DPoP or mTLS) for high-value deployments
- Log every token issuance and refresh with user ID, client ID, IP, user agent
Cost model
API keys: free at the protocol level. Providers charge for tokens consumed, not auth method.
OAuth: free at the protocol level, but identity providers (Auth0, Okta, Azure AD, Google Cloud Identity) charge per monthly active user (MAU) or per authentication transaction. Typical range: $0.015–$0.055 per MAU for workforce identity; higher for customer identity (CIAM). If you self-host (Keycloak, Ory Hydra, Authelia), you pay infrastructure and operational cost instead.
For LLM apps specifically, the inference cost dwarfs auth cost. Don’t let pricing drive this decision.
Limits and quotas
API keys: quotas attach to the key (or the organization/project it belongs to). You get one bucket. If you serve multiple customers from one key, a noisy neighbor consumes everyone’s quota.
OAuth: quotas can attach to the user (sub), the client (client_id), or both. This enables fair sharing — each user’s consumption tracks separately. Providers that support OAuth (OpenAI, Anthropic, Google) enforce per-user rate limits when tokens carry user identity.
If you’re building a multi-tenant gateway, this distinction matters. With API keys, you must implement your own per-tenant metering and enforcement layer. With OAuth, the provider does it for you — but only if the provider supports OAuth and per-user quotas.
Which to choose
Choose API keys when:
- Server-to-server only. Your backend calls the LLM provider. No user delegation needed.
- Single-tenant or internal tools. You control the credentials; rotation is a scheduled job.
- Provider doesn’t support OAuth. Most inference APIs (Together, Fireworks, Mistral, Cohere) only offer API keys.
- BYOK pattern. Users paste their own provider key into your UI. You store it encrypted and forward it. OAuth doesn’t apply here — the user isn’t delegating to your client; they’re giving you their credential.
- Extreme latency sensitivity. The 1–3 ms validation overhead is lower than any OAuth token exchange.
Choose OAuth when:
- Multi-tenant SaaS with user-owned provider accounts. Customers connect their OpenAI/Anthropic/Google accounts; you act on their behalf. Per-user quotas, audit trails, and revocation are requirements, not nice-to-haves.
- Compliance demands per-user audit trails. SOC 2, HIPAA, or customer contracts require knowing which user made which request at what time.
- Users must revoke access independently. A customer offboards an employee; that employee’s LLM access dies without rotating your master credentials.
- Fine-grained scopes matter. You need
inference:readbut notinference:write, ormodels:listbut notfine-tunes:create, and the user must consent to each. - You’re building a plugin/extension ecosystem. Third-party developers register clients; users install them. OAuth is the standard contract.
Hybrid approach (common in production):
- Control plane (your backend → provider): API key. One long-lived key per provider, rotated quarterly via automation. Used for admin operations: listing models, checking quota, managing fine-tunes.
- Data plane (user request → your gateway → provider): OAuth. Your gateway exchanges the user’s OAuth token for a provider token (or uses token exchange / impersonation where supported) and forwards the request. Per-user metering, audit, and revocation work end-to-end.
# Hybrid pattern: gateway exchanges user OAuth token for provider token
async def forward_request(user_token: str, payload: dict) -> httpx.Response:
# 1. Validate user's access token (JWT signature, exp, audience, scopes)
claims = verify_jwt(user_token, jwks_url=JWKS_URL, audience=GATEWAY_AUDIENCE)
user_id = claims["sub"]
scopes = claims.get("scope", "").split()
# 2. Enforce your gateway's scopes
if "inference:write" not in scopes:
raise PermissionError("Missing inference:write scope")
# 3. Get provider token for this user (token exchange or stored refresh token)
provider_token = await get_provider_token_for_user(user_id)
# 4. Forward to provider (or n4n.ai unified gateway like n4n.ai)
async with httpx.AsyncClient(
headers={"Authorization": f"Bearer {provider_token}"},
timeout=120.0,
) as client:
return await client.post(PROVIDER_CHAT_ENDPOINT, json=payload)
Final word
API key vs OAuth isn’t a religious choice — it’s an architectural one. If your application has no concept of “user identity” at the LLM layer, API keys are the correct tool. The moment you need to answer “which user made this request?” or “revoke Alice’s access without affecting Bob,” OAuth (or a token-exchange layer on top of API keys) becomes necessary complexity. Build the simple thing first. Migrate when the pain is real.