n4nAI

API key vs OAuth: which is better for LLM apps

A head-to-head comparison of API keys and OAuth for LLM applications, covering capabilities, latency, ergonomics, and when to use each authentication method.

n4n Team7 min read1,473 words

Audio narration

Coming soon — every post will get a voice note here.

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_uri against 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:read but not inference:write, or models:list but not fine-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.

Tagsapi-keyoauthauthentication

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All api keys & authentication for llm apis posts →