API Keys and Secrets
22 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)Most APIs want to know who is calling, so they give each client a key. A key is a secret: anyone who holds it can act as you. This lesson shows how to send it, where to store it, and how to keep it out of places where it leaks.
What an API key is
API key is a secret string issued to a client program. The client sends it with each request, and the server uses it to identify the caller and decide what that caller may do.
An API key is a long random string that the provider issues to one client. The server looks it up on every request to find who the caller is and what they may do.
import secrets
key = "demo_" + secrets.token_urlsafe(24)print(len(key) > 20)Real providers generate the key for you. A key should be random and long, never a guessable word.
Send it in a header, not in the URL
URLs are saved in server logs, browser history and proxies. Headers are not. Put the key in a header such as Authorization: Bearer <key> or X-API-Key, as the provider documents.
import httpx
client = httpx.Client(headers={"X-API-Key": "demo-key-123"})print(client.headers["x-api-key"])Example 1 · Sending the key in a header
import httpx
def server(request): if request.headers.get("x-api-key") != "demo-key-123": return httpx.Response(401, json={"error": "missing or wrong key"}) return httpx.Response(200, json={"orders": 12})
transport = httpx.MockTransport(server)
anonymous = httpx.Client(transport=transport)print(anonymous.get("https://shop.example/orders").status_code)
with httpx.Client(transport=transport, headers={"X-API-Key": "demo-key-123"}) as client: r = client.get("https://shop.example/orders") print(r.status_code, r.json())401
200 {'orders': 12}What this code does
- Without the header the server answers 401.
- A client created with
headers=sends the key on every call.
Setting it on the client adds the header to every call. Keys here are made-up demo values.
Keep the key out of your code
Environment variable is a named text value that the operating system gives to a running program. It lets you change settings and secrets without changing the code.
A key written in the source ends up in Git, where many people can read it for ever. Store it in an environment variable and read it when the program starts. Fail early with a clear message when it is missing.
import os
base_url = os.environ.get("SHOP_API_URL", "https://api.shop.example")api_key = os.environ.get("SHOP_API_KEY")print(base_url)print(api_key is None)Example 2 · Read the key from the environment
import os
os.environ.pop("SHOP_API_KEY", None) # start clean for this demo
def load_settings(): base_url = os.environ.get("SHOP_API_URL", "https://api.shop.example") api_key = os.environ.get("SHOP_API_KEY") if not api_key: raise RuntimeError("SHOP_API_KEY is not set") return base_url, api_key
try: load_settings()except RuntimeError as err: print("start-up failed:", err)
os.environ["SHOP_API_KEY"] = "demo-key-123" # normally set outside the programbase_url, api_key = load_settings()print(base_url, len(api_key))start-up failed: SHOP_API_KEY is not set https://api.shop.example 12
What this code does
- The URL has a default, because it is not secret. The key has none.
- Setting the variable inside the program is only for the demo. In real use the shell or the server sets it.
os.environ.get(name, default) gives a safe default for settings. A secret should have no default: if it is missing, stop.
Handle keys with care
Never print a full key in logs or error messages. Show only the last four characters. Use one key per app, give it the smallest rights it needs, and rotate it at once if it leaks.
def mask(key): return "*" * (len(key) - 4) + key[-4:]
print(mask("demo-key-123456"))Example 3 · Keep keys out of logs
def mask(secret): if len(secret) <= 4: return "****" return "*" * (len(secret) - 4) + secret[-4:]
SECRET_HEADERS = {"authorization", "x-api-key", "cookie"}
def safe_headers(headers): return {name: (mask(value) if name.lower() in SECRET_HEADERS else value) for name, value in headers.items()}
headers = {"Accept": "application/json", "X-API-Key": "demo-key-123456", "Authorization": "Bearer abcdef123456"}print(safe_headers(headers)){'Accept': 'application/json', 'X-API-Key': '***********3456', 'Authorization': '***************3456'}What this code does
- Log the redacted copy, never the real headers.
- The last four characters are enough to tell keys apart.
Example 4 · Checking keys on the server
import hmacfrom fastapi import FastAPI, Header, HTTPExceptionfrom fastapi.testclient import TestClient
app = FastAPI()KEYS = {"demo-key-read": {"read"}, "demo-key-admin": {"read", "write"}}
def rights_for(candidate): for key, rights in KEYS.items(): if hmac.compare_digest(candidate.encode(), key.encode()): return rights return None
@app.delete("/orders/{oid}", status_code=204)def delete_order(oid: int, x_api_key: str = Header(default="")): rights = rights_for(x_api_key) if rights is None: raise HTTPException(status_code=401, detail="missing or invalid key") if "write" not in rights: raise HTTPException(status_code=403, detail="key may not delete")
client = TestClient(app)print(client.delete("/orders/1").status_code)print(client.delete("/orders/1", headers={"X-API-Key": "demo-key-read"}).status_code)print(client.delete("/orders/1", headers={"X-API-Key": "demo-key-admin"}).status_code)401 403 204
What this code does
- A missing or wrong key is 401. A known key without the right is 403.
compare_digestis used for each comparison. In production, store hashes of keys, not the keys.
Masked text still lets a human tell two keys apart. On the server, compare keys with hmac.compare_digest, not ==.
Next step
Check your understanding
12 quick questions