Authentication and Secrets
Bearer tokens and API keys: which header to send, where the secret lives, and how to keep it out of your logs.
headers = {"Authorization": f"Bearer {os.environ['API_KEY']}"}Most APIs authenticate with a single long-lived string. You prove who you are by putting it in the Authorization header, and your remaining job is making sure that string never ends up anywhere it can be read.
The header
A token API wants Authorization: Bearer <token>. HTTP Basic wants Authorization: Basic <base64 of user:password> — requests spells the shorthand auth=("user", "pass") and builds exactly that header, which you can also write longhand as base64.b64encode(b"user:pass").decode(). Some APIs use a custom header such as X-API-Key instead; read their reference rather than assuming. What you must not do is put the key in the query string: it is recorded by every proxy and access log on the path, and leaks through Referer.
Where the secret lives
In the environment, read at startup. os.environ["API_KEY"] raises KeyError immediately when it is missing, which is what you want; os.getenv(key, default=None) hands back None and lets the failure surface later as a confusing 401. Never in the repository — a committed key is public from the moment it is pushed, and deleting it in a later commit does not remove it from history.
Keeping it out of tracebacks
A traceback is shipped to an error tracker, written to a log, and often shown in a response. If the key is in a local variable, a header dict, or a URL, it goes with it. Log a redacted prefix instead. When you are the one checking an inbound key, compare with hmac.compare_digest(a, b) rather than ==, so response timing does not leak the correct prefix one character at a time. secrets.compare_digest is the same function under another name, and secrets.token_urlsafe(nbytes=None) generates keys to hand out.
Example
When to use it
- Calling a paid API from a container without baking the key into the image.
- Rotating a leaked key and knowing which logs still contain the old one.
- Checking the signature on an incoming webhook before you act on its payload.
- Reviewing a pull request for a secret that has crept into a URL or a log line.
More examples
A client that cannot forget the header
Putting the header on the client rather than on each call removes a whole class of bug — the one endpoint someone added last week that quietly calls out unauthenticated. Use it for any service you talk to more than once.
import os
import httpx
# Read once, at import time, so a missing key fails deployment and not a user.
API_KEY = os.environ["PAYMENTS_API_KEY"]
client = httpx.Client(
base_url="https://api.example.com/v1",
headers={"Authorization": f"Bearer {API_KEY}"}, # sent on every request
timeout=10.0,
)
def charge(cents):
r = client.post("/charges", json={"amount": cents})
r.raise_for_status()
return r.json()["id"]
# requests: s = requests.Session(); s.headers["Authorization"] = f"Bearer {API_KEY}"
# Most vendor SDKs do this for you and read the variable their docs name,
# so you often only need the export, not the header.Basic auth: shorthand and longhand
Useful when you meet the long form in someone else's code, or when a client library has no auth= argument and you have to build the header yourself. It also makes the point that Basic auth hides nothing on its own.
import base64
import requests
# Shorthand.
r = requests.get("https://api.example.com/v1/me", auth=("svc", "s3cret"), timeout=10)
# Exactly the same request, written out. Basic auth is base64, not encryption:
# anyone who sees the header has the password, so it is only safe over HTTPS.
token = base64.b64encode(b"svc:s3cret").decode()
r = requests.get("https://api.example.com/v1/me",
headers={"Authorization": f"Basic {token}"}, timeout=10)Verifying an inbound webhook
The other half of the job: trusting a request that arrives at your endpoint. hmac.new(key, msg=None, digestmod='') needs the digestmod argument, and the raw-bytes point is the mistake most often made here — frameworks give you a parsed body by default, so you have to ask for the untouched one.
import hmac
import hashlib
import os
SIGNING_SECRET = os.environ["WEBHOOK_SECRET"].encode()
def signature_ok(raw_body: bytes, header_value: str) -> bool:
# Sign the RAW bytes. Re-serialising the parsed JSON changes whitespace
# and key order, and the digest no longer matches.
expected = hmac.new(SIGNING_SECRET, raw_body, hashlib.sha256).hexdigest()
# Constant time: a plain == returns early on the first wrong byte, and the
# timing difference is enough to let an attacker guess the digest.
return hmac.compare_digest(expected, header_value)
print(signature_ok(b'{"event":"paid"}', "deadbeef")) # False
Discussion