Every source in Calling REST APIs with Python
needed an Authorization header, glossed over as f"Bearer {api_key}". That
header can mean three fairly different things depending on the source, and
mixing them up is a common source of confusing 401 errors.
API keys: a static secret, nothing more#
The simplest scheme — a long random string, issued once, sent on every request:
session.headers.update({"X-API-Key": api_key})An API key proves the caller knows a secret. It does not prove who the caller is beyond that, does not expire on its own in most implementations, and grants whatever scope was attached to it at creation time — usually everything the account can do. Simple to integrate, and the least safe to leak: a committed API key is valid until someone notices and revokes it.
Basic Auth: a username and password, base64-encoded#
Basic access authentication sends a username and password on every request, base64-encoded — not encrypted, just encoded, so this scheme is only acceptable over HTTPS:
from requests.auth import HTTPBasicAuth
session.auth = HTTPBasicAuth(username, password)requests handles the header construction; the credentials still travel on
every single request, which is the main practical difference from a
short-lived token.
OAuth 2.0 client credentials: a token that expires#
OAuth 2.0 — specified in RFC 6749 — defines several flows for different situations; the one that matters for machine-to-machine ingestion (no human clicking "allow") is the client credentials grant. A client ID and secret are exchanged for a short-lived access token, sent as a Bearer token on subsequent requests:
def get_access_token(token_url: str, client_id: str, client_secret: str) -> dict: response = requests.post( token_url, data={"grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret}, timeout=(3.05, 10), ) response.raise_for_status() return response.json()The grant_type field is what tells the authorization server which OAuth
flow this is — client_credentials specifically means "no human involved,
just prove you are who you say you are with this secret." The response
includes access_token and expires_in — the token's remaining lifetime in
seconds, typically under an hour. expires_in is relative ("3600 seconds
from now"), which is awkward to check against later; the cache below
converts it once, right after fetching, into an absolute expires_at
timestamp, so every later validity check becomes a plain comparison against
the current time instead of having to also remember when the token was
originally fetched. Fetching a new token before every single call works but
is wasteful and, on a source that rate limits its token endpoint
separately, can itself become the bottleneck.
Caching the token instead of fetching one every call#
The fix is the same "build once, reuse" idea used for the client itself in Calling REST APIs with Python — cache the token in memory and only fetch a new one once the cached one is close to expiring:
class OAuthTokenCache: def __init__(self, token_url: str, client_id: str, client_secret: str): self._token_url = token_url self._client_id = client_id self._client_secret = client_secret self._token: dict | None = None
def get_token(self) -> str: if self._token is None or time.time() > self._token["expires_at"] - 60: self._token = get_access_token(self._token_url, self._client_id, self._client_secret) self._token["expires_at"] = time.time() + self._token["expires_in"] return self._token["access_token"]The - 60 buffer refreshes the token a minute before it actually expires
rather than exactly on expiry — without it, a request that starts one
millisecond before expiry and completes after can fail with a 401 for a
token that was "valid" when the request began.
Credentials belong in configuration, not source
Client IDs, client secrets, and API keys are credentials, not constants. Connecting to PostgreSQL from Python makes the same point about database passwords — anywhere beyond a local, single-developer setup, these come from environment variables or a secrets manager, never literals committed alongside the code that uses them.
Which one am I actually looking at#
A quick way to tell them apart from a source's documentation:
| Scheme | Header shape | Expires? | Proves |
|---|---|---|---|
| API key | X-API-Key: <key> (header name varies) | Rarely | The caller has the key |
| Basic Auth | Authorization: Basic <base64> | No | A username/password pair |
| OAuth 2.0 bearer | Authorization: Bearer <token> | Yes, usually < 1 hour | The token was issued by the authorization server |
If a source's docs mention a "token endpoint," "client credentials," or "scopes," it is OAuth 2.0 and needs the fetch-and-cache pattern above, not a static header.
Common mistakes#
Fetching a new OAuth token on every request#
Works, but adds an extra round trip to every single call and can hit a separate rate limit on the token endpoint. Cache the token and only refresh it near expiry.
Refreshing exactly at the expiry timestamp#
A request already in flight when the token expires fails partway through. Refresh a little early — 30 to 60 seconds of buffer is usually enough.
Sending Basic Auth credentials over plain HTTP#
Base64 is encoding, not encryption — anyone who can see the request on the wire can decode it instantly. Basic Auth is only acceptable over HTTPS.
Hardcoding a client secret next to the code that uses it#
The same mistake as a hardcoded database password: fine for a local throwaway script, a liability the moment the repository is shared.
Further reading#
- RFC 6749: The OAuth 2.0 Authorization Framework — the full specification, including every grant type beyond client credentials.
- RFC 6750: Bearer Token Usage — how a bearer token is meant to be sent and validated.
- OAuth and Access token — background on the broader authorization model.
- Basic access authentication — the simplest scheme, and why it depends entirely on HTTPS.