C.W.K.
Stream
Lesson 02 of 06 · published

API Keys vs Short-Lived Tokens — Pick by Leak Risk

~10 min · auth-security, api-keys, tokens, rotation

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"Both ride the Bearer wire. The difference is the LIFETIME and the ISSUANCE process. That difference dictates everything — security posture, rotation policy, integration shape, support workload."

Same Wire, Different Lifecycle

From the HTTP perspective, an API key and a short-lived bearer token look identical — both are Authorization: Bearer <string>. The differences are entirely off the wire:

  • API key: issued once (manually, via a dashboard), long-lived (months to years, until manually revoked), typically a single secret per integration. Server-to-server is the classic fit.
  • Short-lived token: issued programmatically (login, OAuth flow, exchange), expires in seconds to hours, refreshable. End-user sessions and OAuth-grant flows are the classic fit.

Choosing one wrong for the context creates the wrong security posture.

The Leak Window Math

The decision often comes down to: if this credential leaks, how much damage does it do, and how long does the damage last?

Leaked API key: valid until manually revoked. May leak in a git commit, a frontend bundle, a customer-shared screenshot, a CI log. The discovery-to-revocation window can be days. During that window, the attacker has full integration access.

Leaked short-lived token: valid for the remaining seconds of its lifetime — often minutes. By the time the leak is noticed, the token is already invalid. The blast radius is bounded by the token's TTL.

That's why production AI APIs (Anthropic, OpenAI), payment APIs (Stripe), and any API with serious abuse potential lean on short-lived tokens for end-user flows. API keys are reserved for server-side use where the storage environment is tightly controlled.

When API Keys Are the Right Tool

API keys are simple, durable, and easy to integrate. They fit when:

  • Server-to-server — the key lives in environment variables on a server you control. No browser, no end-user, no leak vector beyond your own infrastructure.
  • Single tenant per key — the key represents one organization or service, not one human user.
  • Low-frequency rotation acceptable — manual rotation every 90 days is workable.
  • Stable identity — the integration won't change identities often.

Stripe's secret API keys (sk_live_...) fit all of these. Twilio's account SID + auth token, Anthropic's ANTHROPIC_API_KEY, GitHub's personal access tokens (when used by a server) — all server-to-server API keys.

When Short-Lived Tokens Are the Right Tool

  • End-user sessions — the user logs in, receives a token, uses it for hours, refreshes or re-logs in. Browser-stored credentials with TTL.
  • OAuth-granted access — third-party apps acting on a user's behalf. The user can revoke at any time; the token expires naturally as another layer.
  • Multi-tenant with per-user scoping — each token represents a specific user + scope.
  • Mobile or SPA contexts — anywhere the credential lives in environments where any leak could happen.

OAuth2 access tokens, JWT-based session tokens, and cwkPippa's PIN-issued bearer tokens all fit this pattern.

Pick by leak risk, not by 'what's easier to implement.' An API key is easier — generate, copy, paste, done. But if the key ends up in a place you didn't expect (developer's screenshot, browser extension, AI tool reading the project), the consequences are weeks of exposure. A token-based flow takes more code but bounds the leak window. The first one to lose a credential the hard way teaches the difference.

Rotation — The Forgotten Half

Both API keys and tokens need rotation policies:

  • API keys: support multiple active keys per integration (so rotation doesn't require downtime), surface key age in the dashboard, encourage 90-day rotation, log usage per key (so you know which key to revoke if one looks suspicious).
  • Tokens: short TTL is rotation by design. Add a refresh token (longer TTL, used only to mint new access tokens) and you get sliding sessions without re-authentication.

Stripe lets you have multiple secret keys active simultaneously so you can rotate without taking your integration down. JWT-based flows split short-lived access tokens (~15 min) from longer-lived refresh tokens (~30 days). Both patterns; both work.

cwkPippa's Mix

cwkPippa carries both kinds. The user-facing API uses short-lived bearer tokens (PIN login → 24-hour token; refreshable). The brain adapters (Claude, Codex, Gemini, Ollama) use long-lived API keys stored in .env — server-to-server, single-tenant, low rotation risk because the env file never leaves the Mac. The two patterns coexist because the leak surface differs: a user token leak compromises one session; an Anthropic API key leak runs up a real bill until I notice. The protection matches the risk.

Code

One endpoint, two credential types — both via Bearer scheme·python
# Server side — accept BOTH long-lived API keys and short-lived tokens at one endpoint
from fastapi import FastAPI, Depends, HTTPException, status, Header
from typing import Annotated

app = FastAPI()

# Pretend stores; in production: DB + Redis
_api_keys: set[str] = {'sk_live_abc123_owned_by_org_xyz'}
_active_tokens: dict[str, str] = {'tok_short_lived_xyz': 'u_42'}

async def authenticate(
    authorization: Annotated[str | None, Header()] = None,
) -> dict:
    if not authorization or not authorization.startswith('Bearer '):
        raise HTTPException(401, detail='Bearer required',
                            headers={'WWW-Authenticate': 'Bearer'})
    credential = authorization.removeprefix('Bearer ').strip()

    # Try API key first (long-lived, server-to-server)
    if credential in _api_keys:
        return {'kind': 'api_key', 'identity': 'org_xyz'}

    # Fall back to short-lived token (user session)
    if credential in _active_tokens:
        return {'kind': 'session_token', 'identity': _active_tokens[credential]}

    raise HTTPException(401, detail='unknown credential',
                        headers={'WWW-Authenticate': 'Bearer'})

@app.get('/account')
async def me(auth = Depends(authenticate)):
    return auth
Short-lived access token + refresh — the canonical session pattern·python
# Short-lived token issuance + refresh
import secrets, time
from fastapi import FastAPI, HTTPException

app = FastAPI()
_users: dict[str, dict] = {'pippa': {'password_hash': '...', 'id': 'u_42'}}
_tokens: dict[str, dict] = {}  # token -> {user_id, issued_at, expires_at}
_refresh: dict[str, str] = {}  # refresh_token -> user_id

@app.post('/login')
async def login(payload: dict):
    user = _users.get(payload['username'])
    if not user or not verify_password(payload['password'], user['password_hash']):
        raise HTTPException(401, detail='invalid credentials')

    access = secrets.token_urlsafe(32)
    refresh = secrets.token_urlsafe(32)
    now = time.time()
    _tokens[access]  = {'user_id': user['id'], 'issued_at': now, 'expires_at': now + 900}     # 15 min
    _refresh[refresh] = user['id']  # 30 day in real life — store with expiry too
    return {'access_token': access, 'refresh_token': refresh, 'expires_in': 900}

@app.post('/refresh')
async def refresh_endpoint(payload: dict):
    user_id = _refresh.get(payload['refresh_token'])
    if not user_id:
        raise HTTPException(401, detail='invalid refresh token')
    new_access = secrets.token_urlsafe(32)
    now = time.time()
    _tokens[new_access] = {'user_id': user_id, 'issued_at': now, 'expires_at': now + 900}
    return {'access_token': new_access, 'expires_in': 900}
Client: silent refresh keeps sessions warm without re-login·python
# Client — silently refresh access tokens when they expire
import httpx, time

class SessionClient:
    def __init__(self, base_url: str, access: str, refresh: str, expires_in: int):
        self.base = base_url
        self.access = access
        self.refresh = refresh
        self.expires_at = time.time() + expires_in - 30  # 30s safety margin
        self.client = httpx.Client(base_url=base_url)

    def _refresh_if_needed(self):
        if time.time() < self.expires_at:
            return
        resp = self.client.post('/refresh', json={'refresh_token': self.refresh})
        resp.raise_for_status()
        data = resp.json()
        self.access = data['access_token']
        self.expires_at = time.time() + data['expires_in'] - 30

    def get(self, path: str):
        self._refresh_if_needed()
        return self.client.get(path, headers={'Authorization': f'Bearer {self.access}'})

External links

Exercise

Extend your FastAPI auth from the previous lesson to support BOTH long-lived API keys (read from environment variable on startup) and short-lived session tokens (issued by a /login endpoint with 15-minute expiry + a /refresh endpoint for renewal). The same Authorization: Bearer ... header should work for both. Test scenarios: (1) successful API key access, (2) successful short-token access, (3) expired short-token access (should 401), (4) refresh flow restoring access without re-login. Bonus: log which credential type was used for each request, so future-you can audit traffic patterns.
Hint
The dispatch is simple: try API key set first (long-lived); if not found, try token store (short-lived with expiry check); otherwise 401. The refresh endpoint takes a refresh token and issues a fresh access token — refresh tokens are themselves long-lived but usable only at /refresh (not for API access), which limits their leak blast radius. Logging credential type lets you spot if a user is somehow using a server API key from a browser context, which is the kind of misconfiguration you want to catch early.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.