JWT vs API Keys for Python APIs
Every Python API has to answer one question before its first paying customer: how do callers prove who they are? The two credible answers are signed JWTs and stored API keys, and the choice shapes your revocation story, your billing model, your p99 latency and how much state you carry. This page compares them head to head, prices both verification paths in milliseconds and dollars, and gives you runnable code for each. It is part of the Handling API Authentication in Python guide, which has the full implementation; here we only resolve the decision.
How each credential earns trust
The two formats differ in one place that matters: where the proof lives. A JWT carries its own proof. Three base64url segments — a header naming the algorithm and key id, a payload of claims, and a signature over both — travel in the Authorization header, and your server decides the token is genuine by recomputing the signature with a secret only it holds. Nothing is looked up. The token is the record.
An API key carries no proof at all. It is a high-entropy random string that means nothing until it matches something you stored. Give it structure — a pk_live prefix so scanners can spot it in a public repository, a public key id so you can index the lookup, and a secret segment you only ever store hashed — and verification becomes a row fetch plus a constant-time comparison.
That difference cascades into everything else. Because a JWT needs no store, every replica verifies it independently and horizontal scaling costs nothing. Because an API key needs a store, you can delete the row and kill access on the very next request. You cannot have both properties from one credential, which is why almost every commercial API eventually ships both.
Side-by-side comparison
Read the table below as a list of consequences, not features. Every row is downstream of that one structural difference: signature versus stored row.
| Dimension | JWT | API key |
|---|---|---|
| Verification | Local signature check, no DB | Hash lookup against a store |
| Revocation | Hard — needs a denylist or short TTL | Instant — delete the row |
| Statelessness | Fully stateless | Requires a store |
| Expiry | Native exp claim | Manual; many keys never expire |
| Rotation | Rotate the signing secret | Regenerate each key independently |
| Carries claims | Yes — user id, scopes, tier inline | No — you look up metadata |
| Best for | User sessions, scoped access, SPAs | Server-to-server, metered billing, CLIs |
Both verification paths, and what each one costs
Here is the JWT path. Trust comes entirely from the signature and exp — note the algorithm is pinned, which is what stops the alg=none forgery attack.
# verify_jwt.py
import os
import jwt # PyJWT
from fastapi import HTTPException, status
JWT_SECRET = os.getenv("JWT_SECRET", "")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
JWT_AUDIENCE = os.getenv("JWT_AUDIENCE", "api")
JWT_LEEWAY_SECONDS = int(os.getenv("JWT_LEEWAY_SECONDS", "10"))
def verify_jwt(token: str) -> dict:
try:
return jwt.decode(
token,
JWT_SECRET,
algorithms=[JWT_ALGORITHM],
audience=JWT_AUDIENCE,
leeway=JWT_LEEWAY_SECONDS,
options={"require": ["exp", "sub"]},
)
except jwt.InvalidTokenError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid or expired token")
Three details there are load-bearing in production. algorithms=[JWT_ALGORITHM] is a whitelist, not a hint. audience stops a token minted for your billing service being replayed against your public API. leeway absorbs the second or two of clock drift between a container and your token issuer, which otherwise produces intermittent 401s that look impossible to reproduce.
And the API-key path. Trust comes from a hash match against the store; you never store the raw key, and the comparison runs in constant time so response timing cannot leak the secret.
# verify_apikey.py
import os
from passlib.hash import bcrypt
from fastapi import HTTPException, status
API_KEY_SALT = os.getenv("API_KEY_SALT", "")
# In production: a DB row indexed by a non-secret key id.
KEY_STORE: dict[str, str] = {} # {key_id: bcrypt_hash}
def verify_api_key(key_id: str, raw_key: str) -> str:
hashed = KEY_STORE.get(key_id)
if not hashed or not bcrypt.verify(raw_key + API_KEY_SALT, hashed):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid API key")
return key_id
That version is correct and it is what most tutorials show, but bcrypt on the hot path will hurt you. bcrypt is deliberately slow because passwords are low-entropy and need to resist offline cracking. An API key you generated with secrets.token_urlsafe(32) carries 256 bits of entropy — nobody is brute-forcing it, ever. At the default cost factor of 12 you are paying roughly 250 ms of pure CPU on every single request to defend against an attack that cannot happen, and on a 1 vCPU instance that caps you near four authenticated requests per second.
Swap it for a keyed HMAC. The pepper lives in your environment, so a stolen database dump still yields nothing, and verification drops into the microsecond range:
# verify_apikey_fast.py
import hmac
import os
from hashlib import sha256
from fastapi import HTTPException, status
API_KEY_PEPPER = os.getenv("API_KEY_PEPPER", "").encode()
def fingerprint(raw_secret: str) -> str:
return hmac.new(API_KEY_PEPPER, raw_secret.encode(), sha256).hexdigest()
async def verify_api_key(key_id: str, raw_secret: str, store) -> str:
row = await store.fetch_key(key_id) # indexed lookup, returns None or a row
match row:
case None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid API key")
case {"fingerprint": stored, "revoked_at": None} if hmac.compare_digest(
stored, fingerprint(raw_secret)
):
return key_id
case _:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid API key")
Now the remaining cost is the lookup, not the hash — which means a Redis read-through cache in front of the key table turns a 1.6 ms Postgres round trip into 0.45 ms. The same trade-offs covered in Redis vs in-memory caching for FastAPI apply directly here, with one extra rule: cap the cache TTL at 30 seconds so a revoked key cannot outlive its deletion by more than half a minute.
The dollar version of that chart decides real architectures. At one million authenticated requests a month, JWT verification burns about 20 seconds of CPU in total — a rounding error on any plan. HMAC plus a cached lookup burns roughly 8 minutes of CPU plus a million Redis reads, still comfortably inside a $10 instance. bcrypt at cost 12 burns about 69 CPU-hours, which is a tenth of everything a $25 single-vCPU instance can do in a month, and it does so in 250 ms blocking chunks that wreck your p99. Fold those numbers into your own model using the method in calculating cost per API request.
Failure modes that cost you customers
Four mistakes account for most authentication incidents on small commercial APIs, and three of them are JWT-specific.
Unpinned algorithms. Omit algorithms=[...] and a library may honour the token's own alg header, including none. An attacker then writes their own claims. Pin it, and pin it to one value.
TTLs measured in days. A 30-day access token is a 30-day breach. Keep access tokens at 5 to 15 minutes, hand out a refresh token for the sessions that outlive them, and accept that the OAuth2 authorization code flow exists precisely to make that refresh loop safe.
Credentials in URLs. A key in a query string lands in access logs, CDN caches, referrer headers and browser history. Headers only — and scrub the header in your logging pipeline, which is trivial with the processor chain from structured logging with structlog.
One shared secret across services. If your worker, your dashboard and your public API all sign with the same JWT_SECRET, a leak in the least-guarded one forges tokens for all three. Use a kid header and separate signing keys per audience.
The API-key failure mode is quieter and more expensive: keys that never expire. A key issued in year one is still live in year three, sitting in an ex-contractor's laptop config. Fix that with a scheduled overlap window rather than a flag day — the mechanics are in rotating API keys without downtime.
Choosing between them
Reach for API keys when the caller is another server, a cron job or a CLI holding one long-lived secret in its config; when you meter and bill per key, because a stable key id maps cleanly to a usage counter and a Stripe customer, which is exactly the pattern in how to charge for API access using Stripe; when you need instant revocation; and when there is no user to log in at all. Avoid them for browser sessions, where a long-lived secret in front-end storage is a standing liability.
Reach for JWTs when a human logs in, when routes need per-request scopes such as read, write and admin without a lookup, and when you want verification to scale across replicas for free. Avoid pure JWTs when you must forcibly log someone out right now and cannot tolerate even a few minutes of validity. Statelessness is the feature, and it is exactly what makes instant revocation hard.
Migration path
Moving from API keys to JWTs — common as you add a user dashboard on top of a developer API — does not mean ripping anything out. Run both. Keep key auth on the /v1/* machine routes, add /auth/token plus scope checks for the user-facing routes, and let one dependency try each in turn.
# hybrid.py
from fastapi import HTTPException, Request, status
from verify_jwt import verify_jwt
from verify_apikey import verify_api_key
async def authenticate(request: Request) -> dict:
if key := request.headers.get("X-API-Key"):
key_id, _, secret = key.partition(".") # pk_<id>.<secret>
return {"type": "key", "id": verify_api_key(key_id, secret)}
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
claims = verify_jwt(auth.removeprefix("Bearer "))
return {"type": "jwt", "id": claims["sub"], "scopes": claims.get("scopes", [])}
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No credentials provided")
That dependency migrates clients gradually instead of forcing a flag-day cutover, and it pairs cleanly with rate limiting keyed off whichever principal the request carries. Return the principal from one place and your quota counters, your usage event log and your billing rows all read from the same field.
Builder verdict
Ship both, but lead with API keys. Your first paying customers are almost always developers wiring your endpoint into their own backend, and a single long-lived, revocable, meterable key is exactly what they want — it maps one-to-one to a billing record, it survives their deploys, and it dies instantly when you delete the row. Store it as an HMAC fingerprint rather than a bcrypt hash so verification stays under a millisecond and your margin per request stays intact. Add JWTs the moment you build a user-facing dashboard or need per-request scopes, using a 15-minute TTL plus a small jti denylist to buy back the revocation you gave up. The hybrid dependency above is the production shape: keys on the machine routes, tokens on the human routes, one funnel into quota, usage analytics and billing.
FAQ
Which choice actually costs less to run at a million requests a month? JWTs, by roughly two orders of magnitude, but only against a badly built key path. HS256 verification burns about 20 seconds of CPU per million requests; an HMAC fingerprint plus a cached lookup costs about 8 minutes of CPU and a million Redis reads, which is still a few cents. bcrypt at cost 12 costs about 69 CPU-hours per million — real money and a wrecked p99. Fix the hashing, and the cost difference stops driving the decision.
Can I just use JWTs for everything and stay stateless? You can, and you will fight revocation forever. A developer whose credential leaks expects it dead immediately; with a long-lived token you cannot deliver that without a denylist, which reintroduces the exact state you were avoiding. Keys remain the better tool for revocable server-to-server access.
Are API keys less secure than JWTs? No — security depends on handling, not format. A raw, never-expiring key sitting in a log is dangerous; a peppered fingerprint with a rotation schedule is fine. A JWT with an unpinned algorithm is dangerous; a pinned, audience-checked, short-TTL token is fine. Both are safe when you hash storage, pin algorithms, set expiries and keep credentials out of URLs.
How risky is migrating an existing key-only API to tokens? Low, if you never cut over. Add the token path behind the same dependency, keep both live, watch your logs until key traffic on the dashboard routes hits zero, then retire that path. The one real risk is quota accounting drifting when the same customer arrives under two principal types — resolve both to a single account id before you count anything, and guard the free tier as described in preventing free-tier abuse.
How often should I force customers to rotate credentials? Annually for keys, with a 30-day overlap window and email warnings at 30, 7 and 1 day, so no integration breaks unattended. Tokens rotate themselves every few minutes by design, so the only thing you schedule there is signing-key rotation — twice a year, with the previous key still accepted for one TTL.
Related
Same track:
- Handling API Authentication in Python — the full implementation of both paths, end to end.
- Rotating API keys without downtime — the overlap window that makes long-lived keys safe.
- Implementing the OAuth2 authorization code flow in FastAPI — where short-lived tokens come from once you add human logins.
- Debugging 401 Unauthorized API errors — what to check first when either path starts rejecting valid callers.
Other tracks:
- Calculating cost per API request — turn the verification latency numbers above into margin per call.