FastAPI API key authentication that survives real customers
Every FastAPI API key tutorial ends at a hardcoded list and a warning not to ship it. This is the next paragraph: storage, lookup, revocation, and the FastAPI 0.122.0 change that breaks the tests those tutorials teach you to write.

Sorower
Co-founder

In this article
- The FastAPI API key authentication snippet everyone ships
- FastAPI 0.122.0 changed the status code your tests assert on
- Should a missing key and a wrong key return the same thing?
- What breaks when the second customer arrives
- Do not hash API keys the way you hash passwords
- Then how do you look up a key you only stored the hash of?
- Is constant-time comparison actually necessary here?
- A key without an owner cannot be billed, quota'd, or revoked
- Revocation is a caching problem in disguise
- Build it, or hand it to something that already did
- Errors you will actually hit
- Key takeaways
- Where to go from here
Every tutorial for FastAPI API key authentication ends at the same place: a Python set of valid keys, an APIKeyHeader dependency, and a cheerful note that you should not do this in production. Then it stops. I went back through the top-ranking results while writing this and all of them stop there, several of them saying so out loud.
They are not wrong. The snippet is correct for what it is. The problem is that the sentence "don't do this in production" is doing an enormous amount of unpaid labour, and nobody writes the next paragraph.
So this is the next paragraph. What actually breaks when a second customer shows up, what to do about each thing, and one change in FastAPI 0.122.0 that quietly flipped the status code half those tutorials teach you to assert on.

The FastAPI API key authentication snippet everyone ships
Here is the canonical version, more or less identical across every result on page one:
from fastapi import FastAPI, HTTPException, Security, status
from fastapi.security import APIKeyHeader
API_KEYS = {"sk_live_9f2c04a1", "sk_live_41ab77de"}
api_key_header = APIKeyHeader(name="X-API-Key")
app = FastAPI()
def require_api_key(key: str = Security(api_key_header)) -> str:
if key not in API_KEYS:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
return key
@app.get("/reports", dependencies=[Security(require_api_key)])
def reports():
return {"rows": []}
It works:
$ curl -s -H 'X-API-Key: sk_live_9f2c04a1' localhost:8000/reports
{"rows":[]}
$ curl -s -H 'X-API-Key: nope' localhost:8000/reports
{"detail":"Invalid API key"}
$ curl -s localhost:8000/reports
{"detail":"Not authenticated"}
Look closely at those last two. They come from completely different places. The "Invalid API key" is yours. The "Not authenticated" is FastAPI's, raised inside APIKeyHeader before your function ever runs, because auto_error defaults to True.
That distinction is about to matter more than it looks.
FastAPI 0.122.0 changed the status code your tests assert on
Until FastAPI 0.122.0, a missing credential from any of the built-in security classes produced 403 Forbidden with the detail "Not authenticated". This annoyed people for years, because 403 means "I know who you are and the answer is still no," which is precisely not what is happening when you sent no credential at all.
It was fixed in November 2025 and shipped in 0.122.0. Missing credentials now return 401 Unauthorized with a WWW-Authenticate header, which is what RFC 9110 asked for all along.
Here is the part that bites. On an older FastAPI, that endpoint above returns 403 for a missing key and 401 for a wrong key. Two status codes for what your customer experiences as one problem. If you wrote a test asserting 403, upgrading FastAPI breaks it, and the failure looks like it is in your auth code rather than in a dependency bump.
The escape hatch is a subclass. The official how-to spells it out: override make_not_authenticated_error, and return the exception rather than raising it.
from fastapi import HTTPException, status
from fastapi.security import APIKeyHeader
class APIKeyHeader403(APIKeyHeader):
"""Restore the pre-0.122.0 behaviour for clients that depend on it."""
def make_not_authenticated_error(self) -> HTTPException:
return HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authenticated",
)
My advice: don't. Take the 401, fix the test, and let the two failure modes converge on one honest status code. But know the change exists, because pinning FastAPI below 0.122.0 forever is not a strategy.
Should a missing key and a wrong key return the same thing?
Yes. From the caller's side they are the same event: "the credential you presented is not going to work." Splitting them into 401 and 403 tells an attacker which of the two they got, and tells your legitimate customer nothing useful. Return 401 for both, put the difference in your logs, and give the human a message that names the header you expected.
What breaks when the second customer arrives
The hardcoded set has exactly one property going for it: the lookup is in memory and instant. Everything else about it is a placeholder. Four things fail at once, and they fail in an order that makes them easy to misdiagnose.
Storage. Those keys are sitting in your source tree in cleartext, which means they are in your git history, your CI logs, and every developer's laptop. A leaked repo is now a leaked customer.
Lookup. A set works until keys live in a database. Then "is this key valid" becomes a query, and if you store only hashes you cannot query by hash without scanning every row.
Revocation. Right now revoking a key is a deploy. If a customer emails you at 2am saying their key is in a public repo, "we'll ship a fix in the morning" is not an answer.
Identity. The function returns a string. A string cannot have a quota, an invoice, or an owner. Every commercial thing you will eventually want to do needs to know whose key this was.
Fix them in that order, because each one constrains the next.
Do not hash API keys the way you hash passwords
This is the step where well-intentioned advice does real damage. Someone reads "never store credentials in plaintext," reaches for bcrypt or Argon2 because that is what you use for passwords, and drops it into the request path.

Password hashes are deliberately slow. That is the entire feature. Humans pick passwords from a space small enough to enumerate, so the defence is to make each guess expensive. A well-tuned Argon2 costs a meaningful fraction of a second on purpose.
API keys are not passwords. A key you generated is 256 bits of randomness from a CSPRNG. There is no dictionary. There is no "common keys" list. Nobody is enumerating that space no matter how cheap you make each attempt. Meanwhile, unlike a password, the key arrives on every single request.
So a password KDF on the hot path buys you protection against an attack that cannot happen, and charges you a slow hash per request to do it. Under load that is not a security control, it is a denial-of-service you built yourself and pointed at your own API.
Use a single SHA-256. It is fast, and against 256 bits of entropy it is entirely sufficient.
Then how do you look up a key you only stored the hash of?
You give the key a public prefix and index that. This is why real keys look like sk_live_3f9a1c04_… and ghp_… rather than an undifferentiated blob: the leading segment is a routing label, not a secret.

import hashlib
import hmac
import secrets
def issue_key() -> tuple[str, str, str]:
"""Return (full_key, prefix, key_hash).
Show full_key to the customer exactly once. Persist the other two.
"""
prefix = secrets.token_hex(4) # 8 chars, stored in the clear and indexed
secret = secrets.token_hex(32) # 256 bits; hex, so it never contains "_"
full_key = f"sk_live_{prefix}_{secret}"
return full_key, prefix, hashlib.sha256(full_key.encode()).hexdigest()
>>> full_key, prefix, key_hash = issue_key()
>>> full_key
'sk_live_3f9a1c04_7b2e…c11d'
>>> prefix
'3f9a1c04'
>>> key_hash
'9d5f…a3e8'
The hex encoding is not an aesthetic choice. secrets.token_urlsafe emits base64url, whose alphabet includes underscores, which would happily corrupt the very delimiter you are about to split on. Ask me how I know.
Verification becomes one indexed row read and a comparison:
async def resolve_key(presented: str) -> Customer | None:
parts = presented.split("_")
if len(parts) != 4:
return None
row = await db.fetch_one(
"SELECT customer_id, key_hash, revoked_at "
"FROM api_keys WHERE prefix = :prefix",
{"prefix": parts[2]},
)
if row is None or row["revoked_at"] is not None:
return None
digest = hashlib.sha256(presented.encode()).hexdigest()
if not hmac.compare_digest(digest, row["key_hash"]):
return None
return await load_customer(row["customer_id"])
A short public prefix has a second benefit that has nothing to do with your database: GitHub's secret scanning and most commercial leak detectors work by matching recognisable token patterns. A key that announces its own shape is a key a scanner can find in someone's public repo before an attacker does.
Is constant-time comparison actually necessary here?
Honestly? The remote timing attack on a high-entropy key hash is close to theoretical. You are measuring nanosecond differences across a network that jitters by milliseconds, to recover a value you would still need to guess 256 bits of.
Use hmac.compare_digest anyway. It costs one import and zero performance, and "close to theoretical" is a worse argument in three years than it is today. Just don't let it be the part you feel good about while the keys are still in a cleartext column. The ordering of risk matters more than any individual control.
A key without an owner cannot be billed, quota'd, or revoked
Notice what resolve_key returns. Not the key. Not a boolean. A customer.
That is the seam the tutorials skip, and it is the one that determines how much of your product you can build later. Once the dependency resolves to an account, quotas, plan tiers, per-customer analytics, and invoices all have something to attach to. While it resolves to a string, none of them do.
from fastapi import Depends, FastAPI, HTTPException, Security, status
from fastapi.security import APIKeyHeader
api_key_header = APIKeyHeader(name="X-API-Key")
async def current_customer(
presented: str = Security(api_key_header),
) -> Customer:
customer = await resolve_key(presented)
if customer is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
headers={"WWW-Authenticate": "APIKey"},
)
return customer
@app.get("/reports")
async def reports(customer: Customer = Depends(current_customer)):
return {"customer_id": customer.id, "rows": []}
Where the limit itself belongs is its own decision, and getting it wrong is expensive to undo. We wrote about that seam in multi-tenant API quotas: where the limit actually belongs.
Revocation is a caching problem in disguise
You now do a database round trip on every authenticated request. That will show up in your latency graphs, and the obvious fix is to cache the lookup in-process.
Do that and you have quietly made a security decision. Your cache TTL is now your revocation SLA. A 60-second TTL means a compromised key keeps working for up to a minute after you revoke it, on every worker that has it cached. That may be completely fine. It is not fine to discover it during the incident.
The related question is what your API does when the store backing that lookup is unreachable. Serving everything unauthenticated and refusing everything are both defensible, and the wrong time to pick is while it is happening. We went through that trade-off properly in fail open or fail closed, and the companion problem of swapping a key without dropping live traffic in API key rotation without downtime.
If you want the framework-level tour of FastAPI's security tooling before going further, this walkthrough covers the built-in classes well:
Build it, or hand it to something that already did
Everything above is buildable. It is maybe two days of focused work, and then it is yours forever, including the admin UI your support team will ask for by month three.
Here is the honest comparison, ReqKey's own weaknesses included:
| Approach | Setup effort | Cost | What you still own | Where it falls short |
|---|---|---|---|---|
| Hardcoded set | Ten minutes | Free | Nothing yet | No revocation, no owner, keys in git. Demos only. |
| Your own table | Days, then ongoing | Your engineering time, forever | Schema, cache, rotation, quotas, admin UI, uptime | Full control, and every incident is yours at 2am. |
| Managed layer (ReqKey) | One middleware | $0 up to 100k requests/mo; $20/mo for 2M | Your business logic | A network hop on the auth path, and no per-key last-used timestamp today. |
ReqKey's free tier is $0 with 100,000 requests a month, Pro is $20/month with 2,000,000 included and $25 per additional million. One request means one key validation or one logged call.
The FastAPI integration is one middleware, via pip install "reqkey[fastapi]":
import os
from fastapi import FastAPI, Request
from reqkey.fastapi import ReqKeyMiddleware
app = FastAPI()
app.add_middleware(
ReqKeyMiddleware,
project_key=os.environ["REQKEY_PROJECT_KEY"],
api_id="reports-api",
mode="both",
key_name="X-API-Key",
failure_mode="closed",
exclude_paths=("/health", "/docs", "/openapi.json", "/redoc"),
)
@app.get("/reports")
async def reports(request: Request):
return {
"rows": [],
"credits_remaining": request.state.reqkey.credits_remaining,
}
mode="both" validates the key before your handler runs and records the request afterwards, so authentication and analytics come from one pass. failure_mode is the fail-open/fail-closed decision from earlier, made explicit at config time instead of by accident; it defaults to "closed".
Denials map onto distinct status codes, which is the thing a hand-rolled layer usually collapses into a single 401:
| What happened | Status | error in the body |
|---|---|---|
| No key on the request | 401 | missing_api_key |
| Key unknown or inactive | 401 | invalid_api_key |
| Key not allowed for this API | 403 | access_denied |
| Out of credits | 402 | insufficient_credits |
| Too fast | 429 (+ Retry-After) | rate_limited |
| Verification unavailable, failing closed | 503 | reqkey_unavailable |
That 402/429 split is worth internalising even if you build your own. "You are out of quota" and "you are going too fast" need different client behaviour: one is fixed by waiting, the other is not fixed by waiting at all. The full status table is in the error reference, and we made the case for getting the throttle response right in your 429 is probably wrong.
Because different endpoints cost different amounts, credits accepts a callable rather than a fixed integer:
def credit_cost(request: Request) -> int:
if request.url.path.startswith("/exports"):
return 25
return 1
app.add_middleware(ReqKeyMiddleware, ..., credits=credit_cost)
Responses carry X-ReqKey-Credits-Limit, X-ReqKey-Credits-Remaining and X-ReqKey-Request-ID. There is also an X-ReqKey-Validation-Time-Ms header, which exists so you can measure the verification cost in your own environment and on your own network rather than taking a vendor's number on faith. I would rather you check it than believe me. Full reference on the Python SDK docs.
Errors you will actually hit
Your docs disappear. Add auth as middleware and /docs, /redoc and /openapi.json start returning 401, because middleware does not care that those routes came from the framework. Put them in exclude_paths. If you are hand-rolling, prefer a route dependency over middleware and this never comes up.
Browsers fail preflight. A CORS preflight OPTIONS request never carries your custom header, so a strict key check rejects it and the real request is never sent. The ReqKey middleware skips OPTIONS by default; if you wrote your own, exclude it yourself.
Keys end up in your logs. Accepting the key as a query parameter is convenient and puts credentials into access logs, browser history, and Referer headers. Header only. If you must support a query parameter for a legacy client, treat every key that ever arrived that way as compromised on rotation day.
Your test suite breaks on a FastAPI bump. See 0.122.0 above. Assert on 401.
Key takeaways
- The hardcoded set is a demo, not a starting point. It has no revocation path and no owner record, and both are structural rather than something you bolt on later.
- Never put a password KDF on the API key path. bcrypt and Argon2 are slow by design to defend low-entropy secrets. A 256-bit random key does not need that, and paying for it on every request is self-inflicted load.
- Store a public prefix alongside the hash. It is what makes an indexed lookup possible when you keep only hashes, and it is what leak scanners match on in public repositories.
- Resolve to a customer, not a string. Quotas, billing and analytics all need an owner. Retrofitting that seam later means touching every authenticated endpoint.
- Write down your cache TTL as a revocation SLA. The moment you cache key lookups, that number is how long a leaked key keeps working. Decide it deliberately.
Where to go from here
If you are at the "it works, but there are two customers now" stage, do the storage fix first. Prefix plus SHA-256 plus an owner column is an afternoon, and it unblocks everything else.
If you would rather not own that path at all, the middleware above is the whole integration, and ReqKey's free tier covers 100,000 requests a month, which is more than enough to run both versions side by side and see which one you would rather maintain in a year.



