Handling API Authentication in Python
Authentication is the gate between your API and revenue. Get it wrong and you leak customer data, hand out free quota, or lock out paying users with confusing 401s. This guide walks through the four jobs every commercial Python API has to do: authenticate the requests your customers send you, issue and verify your own tokens, keep the verification itself cheap enough that it does not eat your margin, and safely supply credentials to the third-party APIs you consume. Part of the Getting Started with Python APIs for Builders guide.
We use FastAPI throughout because its dependency-injection model turns auth into a single reusable function you attach per route, and because the same dependency gives you the principal object your billing and quota code needs. If you are still deciding which credential format to issue, read JWT vs API keys for Python APIs first — the short version is API keys for server-to-server and metered billing, JWTs for user sessions and scopes. This page implements both, plus the operational parts nobody writes down: the lookup index that stops a full table scan on every request, the cache that keeps bcrypt off the hot path, and the failure modes that generate support tickets.
Prerequisites
You need Python 3.11+ and a FastAPI project. Install the auth dependencies:
pip install "fastapi>=0.110" "uvicorn[standard]" "pyjwt>=2.8" "passlib[bcrypt]>=1.7" \
"redis>=5.0" "httpx>=0.27" python-multipart
python-multipart is required for the OAuth2 password form. We use PyJWT for tokens; python-jose is a drop-in alternative if you need JWE encryption, but PyJWT has a smaller dependency tree and a faster release cadence on security fixes, so start there. Set these environment variables before running — never hardcode them:
export JWT_SECRET="$(openssl rand -hex 32)" # 256-bit signing key
export API_KEY_PEPPER="$(openssl rand -hex 16)" # server-side pepper for key hashing
export JWT_ALGORITHM="HS256"
export JWT_TTL_SECONDS="900" # 15-minute access tokens
export JWT_ISSUER="https://api.example.com"
export JWT_AUDIENCE="api"
export API_KEY_PREFIX="pk_live"
export AUTH_CACHE_TTL_SECONDS="300"
Two of those deserve a word now. API_KEY_PEPPER is a secret that lives only in your application environment, never in the database — it means a stolen database dump alone cannot be brute-forced offline. JWT_ISSUER and JWT_AUDIENCE look like ceremony on a single-service API, but the moment you add a second service, or a staging environment that shares a secret manager, they are what stops a token minted for one audience being replayed against another.
This builds on the project layout in Setting Up FastAPI, and it assumes your app already talks to a database — the async patterns in async database access with SQLAlchemy apply directly to the key table below. Auth pairs naturally with throttling, so plan to add API rate limiting to the same dependency chain: the principal you resolve here is exactly the identity your limiter should key on.
Choose the credential model before you write code
Most auth rewrites happen because someone picked a credential type for the wrong reason. Decide on three axes only: how fast you need revocation to take effect, how much server state you are willing to carry per request, and who the caller actually is. A backend cron job hitting your API from a customer's server is a completely different customer from a browser session in your dashboard, and they should not share a credential type.
Issue API keys when the caller is a machine you bill by usage. Keys are long-lived, easy to paste into a config file or a CI secret, trivially attributable to one account, and revocable in one DELETE. Issue JWTs when the caller is a human session that needs fine-grained permissions and you want zero database round-trips per request. Use the OAuth2 authorization code flow only when you are acting on behalf of a customer's account on someone else's platform — that is a client-side job, covered end to end in implementing the OAuth2 authorization code flow in FastAPI.
The commercial reading of that matrix matters more than the technical one. Keys map cleanly to invoices because one key equals one billable identity, which is why every usage-metering design in tracking API usage and analytics starts from a key id. JWTs cost you nothing per request but cost you a revocation story, and "we cannot cut off that leaked token for another fifteen minutes" is a sentence you will one day say to a customer. Pick your defaults with that conversation in mind.
Step 1: Hashed API keys behind a prefixed lookup id
Customers integrating server-to-server want a long-lived secret they paste into a config file. The cardinal rule: store only a hash of the key, never the key itself. If your database leaks, raw keys are live credentials; hashes are not. We hash with bcrypt plus a pepper from API_KEY_PEPPER, and we generate the secret from a cryptographically secure source.
The part most first implementations get wrong is the lookup. Hashes are not searchable, so a naive implementation loops over every stored hash and runs bcrypt against each one — a full table scan multiplied by a deliberately slow hash function. At a hundred customers that is a two-second request. Fix it structurally: give every key a non-secret, indexed id and embed it in the key string itself, so verification touches exactly one row and runs bcrypt exactly once.
The prefix is not decoration either. A recognisable, product-specific prefix like pk_live_ lets GitHub's secret scanning and every commercial leak-detection service spot your credentials in a public repository and tell you about it. Ship a prefix from day one; retrofitting one across a live customer base is a migration you do not want.
# apikeys.py
import os
import secrets
from dataclasses import dataclass
from fastapi import Depends, HTTPException, status
from fastapi.security import APIKeyHeader
from passlib.hash import bcrypt
API_KEY_PEPPER = os.getenv("API_KEY_PEPPER", "")
API_KEY_PREFIX = os.getenv("API_KEY_PREFIX", "pk_test")
BCRYPT_ROUNDS = int(os.getenv("BCRYPT_ROUNDS", "12"))
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
# A hash of a throwaway secret, used to burn equal time on unknown key ids.
_DUMMY_HASH = bcrypt.using(rounds=BCRYPT_ROUNDS).hash("decoy" + API_KEY_PEPPER)
@dataclass(frozen=True)
class Principal:
key_id: str
account_id: str
plan: str
# In production this is one indexed table: primary key = key_id.
KEY_ROWS: dict[str, dict] = {}
def issue_api_key(account_id: str, plan: str = "free") -> str:
"""Return the raw key ONCE; persist only its hash."""
key_id = secrets.token_hex(6)
secret = secrets.token_urlsafe(32)
KEY_ROWS[key_id] = {
"hash": bcrypt.using(rounds=BCRYPT_ROUNDS).hash(secret + API_KEY_PEPPER),
"account_id": account_id,
"plan": plan,
"revoked": False,
}
return f"{API_KEY_PREFIX}_{key_id}_{secret}"
def parse_api_key(raw: str) -> tuple[str, str] | None:
body = raw.removeprefix(f"{API_KEY_PREFIX}_")
if body == raw:
return None
key_id, _, secret = body.partition("_")
return (key_id, secret) if key_id and secret else None
def verify_api_key(raw: str) -> Principal:
parsed = parse_api_key(raw)
if parsed is None:
bcrypt.verify("decoy" + API_KEY_PEPPER, _DUMMY_HASH)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Malformed API key")
key_id, secret = parsed
row = KEY_ROWS.get(key_id)
if row is None or row["revoked"]:
bcrypt.verify("decoy" + API_KEY_PEPPER, _DUMMY_HASH)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid API key")
if not bcrypt.verify(secret + API_KEY_PEPPER, row["hash"]):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid API key")
return Principal(key_id=key_id, account_id=row["account_id"], plan=row["plan"])
async def require_api_key(raw: str | None = Depends(api_key_header)) -> Principal:
if not raw:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing X-API-Key header")
return verify_api_key(raw)
The decoy hash is worth understanding. Without it, an unknown key id returns in microseconds while a known one takes tens of milliseconds, and an attacker can enumerate valid key ids purely by timing the response. Burning one equivalent bcrypt call on the failure path collapses that signal. It is three lines and it closes a real oracle.
Attach the dependency to a route and you have plan-aware access in one line. The returned plan is what you feed your quota check — exactly the hook a monetization tier needs, covered in how to charge for API access using Stripe.
# main.py (excerpt)
from fastapi import Depends, FastAPI
from apikeys import Principal, require_api_key
app = FastAPI()
@app.get("/v1/data")
async def get_data(principal: Principal = Depends(require_api_key)) -> dict:
return {"plan": principal.plan, "account": principal.account_id, "data": [1, 2, 3]}
Two schema details save you pain later. Store last_used_at on the key row and update it lazily — a daily batch, not a write per request — because the first question in every rotation conversation is "is this key still in use?". And store a label the customer sets themselves ("prod worker", "Zapier"), because a customer with four unlabelled keys will never revoke any of them. The full dual-key overlap procedure lives in rotating API keys without downtime.
Step 2: JWTs with expiry, scopes and clock skew
User sessions need short-lived, self-describing tokens. A JWT carries the subject, an expiry, and a list of scopes — your API verifies the signature locally with no database round-trip, which is the entire reason to use one. Always set an expiry, always pin the algorithm on decode, and always require the claims you depend on rather than trusting that they are present.
# jwttoken.py
import os
import time
import uuid
import jwt # PyJWT
from fastapi import HTTPException, status
JWT_SECRET = os.getenv("JWT_SECRET", "")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
JWT_TTL_SECONDS = int(os.getenv("JWT_TTL_SECONDS", "900"))
JWT_ISSUER = os.getenv("JWT_ISSUER", "https://api.localhost")
JWT_AUDIENCE = os.getenv("JWT_AUDIENCE", "api")
JWT_LEEWAY_SECONDS = int(os.getenv("JWT_LEEWAY_SECONDS", "30"))
_UNAUTHORIZED_HEADERS = {"WWW-Authenticate": "Bearer"}
def create_access_token(sub: str, scopes: list[str]) -> str:
now = int(time.time())
payload = {
"sub": sub,
"scopes": scopes,
"iss": JWT_ISSUER,
"aud": JWT_AUDIENCE,
"jti": uuid.uuid4().hex,
"iat": now,
"exp": now + JWT_TTL_SECONDS,
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def decode_access_token(token: str) -> dict:
try:
# Pinning algorithms=[...] is what blocks the alg=none attack.
return jwt.decode(
token,
JWT_SECRET,
algorithms=[JWT_ALGORITHM],
audience=JWT_AUDIENCE,
issuer=JWT_ISSUER,
leeway=JWT_LEEWAY_SECONDS,
options={"require": ["exp", "iat", "sub"]},
)
except jwt.ExpiredSignatureError:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, "Token expired", _UNAUTHORIZED_HEADERS
)
except jwt.InvalidTokenError:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, "Invalid token", _UNAUTHORIZED_HEADERS
)
The leeway argument is the one people discover through support tickets. Container clocks drift, and a client whose clock runs forty seconds ahead of your server will present a token your server considers not-yet-valid. Thirty seconds of leeway absorbs ordinary drift without meaningfully extending a token's life. The jti claim earns its place too: it is the handle you put on a denylist when you need to kill one specific session before it expires.
Now the lifetime question, which is a product decision disguised as a config value. Short access tokens plus a long refresh token give you a revocation window measured in minutes while keeping users logged in for weeks. Fifteen minutes is the sweet spot for a dashboard; go to five if you handle payment data, and never go past an hour on a token you cannot revoke.
One algorithm note before moving on. HS256 uses one shared secret to sign and verify, which is perfect while a single service does both. The moment a second service needs to verify tokens you signed, switch to RS256: you hand out the public key freely and keep the private key in one place, so a compromised read-only service cannot mint tokens. Migrating is a rolling deploy — publish the public key, accept both algorithms for one release, then drop HS256 — and it is far easier before you have downstream consumers than after.
Step 3: The OAuth2 password and bearer flow
FastAPI's OAuth2PasswordBearer wires the login form, the Authorization: Bearer header, and the Swagger "Authorize" button together, which is why your interactive docs suddenly become usable for authenticated endpoints — a detail worth pairing with documenting APIs with OpenAPI. A /auth/token endpoint verifies the user's password hash and returns a JWT; a require_scopes dependency then enforces fine-grained permissions per route.
# auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import (
OAuth2PasswordBearer,
OAuth2PasswordRequestForm,
SecurityScopes,
)
from passlib.hash import bcrypt
from jwttoken import create_access_token, decode_access_token
router = APIRouter()
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="/auth/token",
scopes={"read": "Read data", "write": "Modify data"},
)
# Demo user; replace with a DB lookup of hashed passwords.
USERS = {"alice": {"hash": bcrypt.hash("s3cret"), "scopes": ["read", "write"]}}
@router.post("/auth/token")
async def login(form: OAuth2PasswordRequestForm = Depends()) -> dict:
user = USERS.get(form.username)
if not user or not bcrypt.verify(form.password, user["hash"]):
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
"Incorrect username or password",
{"WWW-Authenticate": "Bearer"},
)
granted = [s for s in form.scopes if s in user["scopes"]]
return {
"access_token": create_access_token(form.username, granted),
"token_type": "bearer",
}
async def require_scopes(
security: SecurityScopes, token: str = Depends(oauth2_scheme)
) -> dict:
payload = decode_access_token(token)
token_scopes = set(payload.get("scopes", []))
for scope in security.scopes:
if scope not in token_scopes:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
f"Missing scope: {scope}",
{"WWW-Authenticate": f'Bearer scope="{scope}"'},
)
return payload
Guard a route by declaring the scopes it needs. Note the status codes: an unknown or expired token is 401 because the caller has not proved who they are, while a valid token missing a scope is 403 because they have — and are not allowed. Conflating the two is the single most common cause of clients retrying forever on an error a refresh will never fix.
from fastapi import Security
@app.post("/v1/items")
async def create_item(user=Security(require_scopes, scopes=["write"])) -> dict:
return {"created_by": user["sub"]}
Most real APIs end up accepting both credential types on the same routes: a key for the customer's backend, a bearer token for their dashboard session. Resolve them in one dependency and normalise the result, so every downstream concern — quota, logging, billing — sees a single principal shape regardless of how the caller authenticated.
# dual.py
import os
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from apikeys import Principal, verify_api_key
from jwttoken import decode_access_token
DEFAULT_PLAN = os.getenv("JWT_DEFAULT_PLAN", "pro")
async def current_principal(
authorization: Annotated[str | None, Header()] = None,
x_api_key: Annotated[str | None, Header()] = None,
) -> Principal:
match (authorization or "").split(" ", 1):
case ["Bearer", token] if token.strip():
claims = decode_access_token(token.strip())
return Principal(
key_id=claims.get("jti", ""),
account_id=claims["sub"],
plan=DEFAULT_PLAN,
)
case _ if x_api_key:
return verify_api_key(x_api_key)
case _:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
"Provide X-API-Key or a Bearer token",
{"WWW-Authenticate": "Bearer"},
)
Step 4: Keep bcrypt off the hot path
Here is where auth quietly becomes an infrastructure bill. Bcrypt is slow on purpose — that is its whole value against offline cracking — but running it on every single request means you are paying for a deliberately expensive computation millions of times a month to answer a question whose answer almost never changes.
The fix is a short-lived verification cache. Hash the presented key with HMAC-SHA256 (fast, keyed, not reversible) to derive a cache key, look up the resolved principal in Redis, and only fall back to bcrypt on a miss. A five-minute TTL means a revoked key stops working within five minutes at worst — set it shorter if your security posture demands it, and delete the cache entry explicitly at revocation time so the common case is instant.
# authcache.py
import hashlib
import hmac
import json
import os
import redis.asyncio as redis
from fastapi import Depends, HTTPException, status
from fastapi.security import APIKeyHeader
from apikeys import Principal, verify_api_key
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
AUTH_CACHE_TTL = int(os.getenv("AUTH_CACHE_TTL_SECONDS", "300"))
_PEPPER = os.getenv("API_KEY_PEPPER", "").encode()
_redis = redis.from_url(REDIS_URL, decode_responses=True)
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
def cache_slot(raw_key: str) -> str:
digest = hmac.new(_PEPPER, raw_key.encode(), hashlib.sha256).hexdigest()
return f"auth:principal:{digest}"
async def cached_principal(raw: str | None = Depends(api_key_header)) -> Principal:
if not raw:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing X-API-Key header")
slot = cache_slot(raw)
if hit := await _redis.get(slot):
return Principal(**json.loads(hit))
principal = verify_api_key(raw) # bcrypt runs here, on a miss only
await _redis.set(slot, json.dumps(principal.__dict__), ex=AUTH_CACHE_TTL)
return principal
async def invalidate_key(raw_key: str) -> None:
"""Call from your revoke endpoint so the cut-off is immediate."""
await _redis.delete(cache_slot(raw_key))
Never cache the raw key as the Redis value or the Redis key — the HMAC digest is what goes over the wire to Redis, so a compromised cache yields nothing usable. The general caching patterns, including connection pooling and stampede control, are covered in caching Python API responses with Redis, and if you run a single instance and are wondering whether Redis is worth the hop at all, Redis vs in-memory caching for FastAPI answers exactly that.
Read the chart as ratios, not absolutes: a bcrypt verification costs roughly seventy times a Redis round trip and more than a thousand times a JWT signature check. That is the difference between auth being a rounding error on your infrastructure bill and auth being the reason you upgrade your instance.
Step 5: Credentials you consume
You also use APIs — Stripe, an LLM provider, a shipping carrier — and those credentials need the same discipline in the other direction. Load them from the environment, verify them at startup so a bad key fails loudly during deploy instead of at 3am, and support overlapping keys so rotating one never causes downtime.
# thirdparty.py
import os
import httpx
UPSTREAM_URL = os.getenv("UPSTREAM_API_URL", "https://api.example.com")
UPSTREAM_TIMEOUT = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "10"))
# Comma-separated allows old+new keys live at once during rotation.
UPSTREAM_KEYS = [k for k in os.getenv("UPSTREAM_API_KEYS", "").split(",") if k]
async def verify_upstream_credentials() -> str:
"""Call from the lifespan handler; returns the first key that works."""
if not UPSTREAM_KEYS:
raise RuntimeError("UPSTREAM_API_KEYS is not set")
async with httpx.AsyncClient(timeout=UPSTREAM_TIMEOUT) as client:
for key in UPSTREAM_KEYS:
resp = await client.get(
f"{UPSTREAM_URL}/v1/ping",
headers={"Authorization": f"Bearer {key}"},
)
match resp.status_code:
case 200:
return key
case 401 | 403:
continue
case _:
resp.raise_for_status()
raise RuntimeError("No usable upstream API key")
Wire that into your FastAPI lifespan and a deploy with a dead key fails the health check instead of silently degrading every customer request into a 502. Rotation then becomes three boring deploys: add the new key alongside the old, swap the primary at the provider, drop the old key. That is the same overlap principle you offer your own customers, and it is why zero-downtime deploys for Python APIs and credential rotation belong in the same runbook.
Two related cases deserve their own handling. Tokens you hold on a customer's behalf must be encrypted at rest and refreshed on a schedule, not lazily on first failure. And inbound credentials that are not really credentials — webhook signatures — need constant-time comparison and a replay window rather than a lookup; see verifying Stripe webhook signatures for that pattern done properly.
Configuration reference
| Env var | Default | Production recommendation |
|---|---|---|
JWT_SECRET | (none) | 256-bit random hex from a secret manager, unique per environment |
JWT_ALGORITHM | HS256 | HS256 for one service; RS256 when several verify and one signs |
JWT_TTL_SECONDS | 900 | 5–15 min, paired with a longer refresh token |
JWT_LEEWAY_SECONDS | 30 | 30 s absorbs clock drift without extending token life |
API_KEY_PEPPER | (none) | Random pepper in the app env only, never in the database |
BCRYPT_ROUNDS | 12 | 12 in production, 4 in tests to keep the suite fast |
AUTH_CACHE_TTL_SECONDS | 300 | 60–300 s; delete the slot on revoke for instant cut-off |
UPSTREAM_API_KEYS | (none) | Comma-separated for overlap rotation; never logged |
Set BCRYPT_ROUNDS=4 in your test environment specifically. A suite with two hundred auth tests at cost factor 12 spends over twenty seconds purely hashing, and a slow suite is a suite people stop running.
Gotchas and failure modes
- Storing raw keys. A leaked database of plaintext keys is an instant breach and a disclosure email to every customer. Hash with bcrypt plus a pepper; show the raw key exactly once, at issue time.
- Scanning the key table. Verifying by looping bcrypt over every stored hash turns a 60 ms operation into 60 ms times your customer count. Embed an indexed key id in the key so exactly one hash is checked.
- Tokens with no expiry. A JWT without
expis valid forever and cannot be recalled. Always setexp, keep access tokens short, and use refresh tokens for longevity. - Unpinned algorithms. Calling
jwt.decodewithoutalgorithms=[...]lets an attacker submit an unsignedalg: nonetoken, or downgrade an RS256 deployment by signing with the public key as an HMAC secret. Always pass the allowed list. - Returning 403 where you mean 401. Clients treat 401 as "refresh and retry" and 403 as "give up". Swap them and well-behaved clients either hammer you forever or drop a recoverable session.
- Leaking keys in logs and URLs. Never put secrets in query strings — they land in access logs, browser history and proxy caches. Scrub
AuthorizationandX-API-Keyin your logging middleware; structured logging with structlog makes that a single processor rather than a grep-and-pray. - Non-constant-time comparison. Comparing secrets with
==leaks length and content through timing. Usebcrypt.verifyfor hashes andhmac.compare_digestfor any raw-string compare, and burn equivalent time on the not-found path. - One key per customer. If a customer cannot hold two valid keys at once, every rotation is an outage, and they will simply never rotate. Support multiple active keys per account from the start.
Verification
Get a token, then call a protected route. The happy path:
TOKEN=$(curl -s -X POST "$API_BASE_URL/auth/token" \
-d "username=alice&password=s3cret&scope=read write" \
| python -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
curl -s -X POST -H "Authorization: Bearer $TOKEN" "$API_BASE_URL/v1/items"
# -> {"created_by":"alice"}
Now confirm the rejection paths. An invalid token must return 401 with a WWW-Authenticate header; a valid token lacking a scope must return 403:
curl -i -X POST -H "Authorization: Bearer not-a-real-token" "$API_BASE_URL/v1/items"
# HTTP/1.1 401 Unauthorized
# www-authenticate: Bearer
# {"detail":"Invalid token"}
Manual curl proves it once; a test proves it on every deploy. Pin both branches with an async test that drives the app in-process:
# test_auth.py
import os
import pytest
from httpx import ASGITransport, AsyncClient
from main import app
BASE = os.getenv("TEST_BASE_URL", "http://test")
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
@pytest.mark.anyio
async def test_missing_key_is_401() -> None:
async with AsyncClient(transport=ASGITransport(app=app), base_url=BASE) as client:
resp = await client.get("/v1/data")
assert resp.status_code == 401
assert "X-API-Key" in resp.json()["detail"]
That pattern — ASGITransport plus AsyncClient, no live server — is the fastest way to test auth, and it is expanded in testing async FastAPI endpoints with httpx. If you get an unexpected 401 in production, walk through debugging 401 Unauthorized API errors — the usual culprits are a proxy stripping the header, clock skew on expiry, or a mismatched secret between environments.
Cost and performance at scale
JWT verification is pure CPU: a signature check plus a JSON decode, roughly 50 microseconds with no network call, so it adds nothing measurable to your bill. Hashed API-key verification is a different animal. At cost factor 12, one bcrypt check costs about 62 ms of CPU on a typical shared vCPU, and CPU is the thing you rent.
Do the arithmetic at burst, not at monthly totals — that is where the trap hides. One million requests a month averages 0.4 requests per second, which sounds harmless. But a customer's nightly batch job that fires 100 requests per second needs 6.2 CPU-seconds of hashing for every wall-clock second, meaning seven vCPUs dedicated to checking passwords. On typical managed-platform pricing, that is the difference between a roughly $25/month two-vCPU instance and a $175/month eight-vCPU one: about $150 a month of pure margin, burned. Add a Redis instance for ten dollars, cache the verified principal for five minutes, and the same burst is handled by the original box with capacity to spare.
Expressed per request, uncached bcrypt auth costs you around $0.00015 at that traffic shape. If you sell at a tenth of a cent per call, authentication alone eats 15% of your gross margin before your actual product does any work. Cached, it rounds to zero. This is the kind of line item worth modelling explicitly with the method in calculating cost per API request, and it is also why the worker configuration matters: a blocking bcrypt call inside an async route pins an event loop, so size workers against vCPUs the way Uvicorn vs Gunicorn worker configuration recommends, and push hashing into a thread pool if you cannot cache it.
FAQ
What does authentication actually cost me per million requests? Roughly nothing if you verify JWTs or cache verified keys — under fifty microseconds of CPU per call, invisible on any hosting bill. Uncached bcrypt on every request is the expensive path: at 62 ms per check, a burst of 100 requests per second needs about seven vCPUs, which on managed platforms is around $150 a month more than the two-vCPU box you would otherwise run. A ten-dollar Redis cache removes that entirely.
How do I rotate a customer's key without breaking their integration?
Never overwrite a key in place. Issue a second key, let both work during an overlap window of a week or more, watch last_used_at on the old row drop to zero, then revoke it. Customers rotate on their own schedule and nothing fails at 2am. The full schema and endpoint design is in rotating API keys without downtime.
Should I build this or pay for Auth0, Clerk or WorkOS? Build it if your callers are machines. Issuing hashed API keys is about two hundred lines you fully control, and hosted identity products charge per monthly active user — a model that maps badly onto server-to-server traffic. Buy it the moment you need social login, SAML for enterprise deals, or a compliance auditor asking who owns your password reset flow; that is months of work you would rather spend on the product.
How do I stop one leaked key from draining my free tier? Rate limit and quota on the key id you resolve during authentication, not on IP — the principal object from this guide is exactly that hook. Cap free plans hard, alert on a step change in per-key volume, and expose a self-serve revoke button so a customer can kill a leaked key at 2am without you. The specific defences are in preventing free tier abuse.
What is the migration risk if I start with API keys and add JWTs later?
Low, as long as your routes depend on a normalised principal object rather than reading headers directly. Adding a second credential type then means one new branch in one dependency, as in the current_principal example above — no route changes, no client-visible break. The expensive migration is the reverse: retiring a credential type your customers already embedded in their code, which follows the same slow choreography as deprecating an API endpoint without breaking customers.
Related
Same topic area:
- JWT vs API keys for Python APIs — the decision behind the code on this page, with the revocation trade-off spelled out.
- Rotating API keys without downtime — the dual-key overlap pattern, customer-facing rotation endpoint and instant revocation.
- Implementing the OAuth2 authorization code flow in FastAPI — for when you act on a customer's account on another platform.
Adjacent areas:
- Best practices for API rate limiting — throttle on the principal this guide resolves.
- Tracking API usage and analytics — turn the verified key id into billable usage events.