API Authentication Patterns

API keys, Basic Auth, and the OAuth 2.0 client credentials flow compared — what each one actually proves, and how to cache a token instead of fetching one on every call.

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:

Python
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:

Python
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:

Python
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:

Python
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:

SchemeHeader shapeExpires?Proves
API keyX-API-Key: <key> (header name varies)RarelyThe caller has the key
Basic AuthAuthorization: Basic <base64>NoA username/password pair
OAuth 2.0 bearerAuthorization: Bearer <token>Yes, usually < 1 hourThe 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#

See also#