Learning Goals
3 minBy the end of this lesson you can:
- Authenticate with an API key (header or query) and a bearer token.
- Explain the OAuth client-credentials flow and exchange credentials for a token.
- Refresh an expired access token automatically.
- Keep every secret in the environment — never hard-coded, never logged, never committed.
Warm-Up · Three Ways APIs Check You
5 minAPI key a single long secret string you send with each request
bearer token a token (often time-limited) sent in the Authorization header
OAuth you exchange client credentials for a short-lived access token,
then send THAT token — refreshing it when it expiresAll three boil down to "attach a secret to the request so the server knows it's you." The differences are how long the secret lasts and how you obtain it. The constant across all of them: that secret never appears in your source code — it comes from the environment (Lesson 8), and it never gets printed to a log. Leak a key, and someone runs up your bill or impersonates you.
New Concept · Authenticating Requests
14 minAPI key — load from the environment
import os, requests from dotenv import load_dotenv load_dotenv() API_KEY = os.environ["API_KEY"] # crashes loudly if missing — good # some APIs want it as a header: headers = {"X-API-Key": API_KEY} requests.get("https://api.example.com/data", headers=headers, timeout=10) # others as a query parameter: requests.get("https://api.example.com/data", params={"api_key": API_KEY}, timeout=10)
The key comes from os.environ (set via .env in dev, real env vars in prod) — exactly the pattern from Lesson 8. It is never written in the code that you commit.
Bearer token
token = os.environ["ACCESS_TOKEN"] headers = {"Authorization": f"Bearer {token}"} requests.get("https://api.example.com/me", headers=headers, timeout=10)
The Authorization: Bearer <token> header is the most common scheme. The word "Bearer" is literal; the token follows.
OAuth client-credentials flow
For machine-to-machine automation (no human logging in), OAuth's client-credentials flow is standard: you trade a client_id + client_secret for a short-lived access_token.
def get_token() -> dict: resp = requests.post( "https://auth.example.com/oauth/token", data={ "grant_type": "client_credentials", "client_id": os.environ["CLIENT_ID"], "client_secret": os.environ["CLIENT_SECRET"], "scope": "read:data", }, timeout=10, ) resp.raise_for_status() return resp.json() # {"access_token": "...", "expires_in": 3600, ...}
You get back a token and how many seconds it lasts (expires_in). Use the token in the Authorization header until it expires, then request a fresh one.
Auto-refresh: a token manager
import time class TokenManager: def __init__(self): self._token = None self._expires_at = 0.0 def token(self) -> str: # refresh slightly early (60s buffer) to avoid edge-of-expiry failures if not self._token or time.time() > self._expires_at - 60: data = get_token() self._token = data["access_token"] self._expires_at = time.time() + data["expires_in"] return self._token def headers(self) -> dict: return {"Authorization": f"Bearer {self.token()}"} auth = TokenManager() requests.get("https://api.example.com/data", headers=auth.headers(), timeout=10)
The manager caches the token and transparently refreshes it before expiry — so the rest of your code just calls auth.headers() and never worries about token lifecycle.
Don't print tokens or keys. Don't put them in URLs that get logged. Don't commit .env (git-ignore it; ship .env.example). If a key is ever exposed — even in a screenshot or a public repo — rotate it immediately (revoke and regenerate). For debugging, show only the last few characters: …{token[-4:]}.
Worked Example · An Authenticated API Client
12 minGoal: a small, reusable client that combines auth, auto-refresh, the resilient session from Lesson 24, and a 401-triggered re-auth — the way real API clients are built.
import os, time, logging import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from dotenv import load_dotenv load_dotenv() logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") log = logging.getLogger("client") class ApiClient: def __init__(self, base_url: str): self.base_url = base_url.rstrip("/") self._token = None self._expires_at = 0.0 self.session = self._make_session() def _make_session(self) -> requests.Session: retry = Retry(total=5, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504], respect_retry_after_header=True) s = requests.Session() s.mount("https://", HTTPAdapter(max_retries=retry)) return s def _refresh(self) -> None: resp = self.session.post( os.environ["TOKEN_URL"], data={"grant_type": "client_credentials", "client_id": os.environ["CLIENT_ID"], "client_secret": os.environ["CLIENT_SECRET"]}, timeout=10) resp.raise_for_status() data = resp.json() self._token = data["access_token"] self._expires_at = time.time() + data["expires_in"] log.info("token refreshed (…%s), valid %ds", self._token[-4:], data["expires_in"]) def _auth_headers(self) -> dict: if not self._token or time.time() > self._expires_at - 60: self._refresh() return {"Authorization": f"Bearer {self._token}"} def get(self, path: str, **kwargs) -> dict: url = f"{self.base_url}/{path.lstrip('/')}" resp = self.session.get(url, headers=self._auth_headers(), timeout=10, **kwargs) if resp.status_code == 401: # token rejected — force refresh once log.warning("401 — refreshing token and retrying") self._token = None resp = self.session.get(url, headers=self._auth_headers(), timeout=10, **kwargs) resp.raise_for_status() return resp.json() client = ApiClient("https://api.example.com/v1") me = client.get("/me") print("authenticated as", me.get("name"))
INFO token refreshed (…a1b2), valid 3600s authenticated as Acme Service Account
Read the code
This client layers everything: secrets pulled from the environment, OAuth token fetched and cached with a 60-second refresh buffer, the resilient retry session underneath, and a 401 handler that refreshes once and retries (covering the case where a token is revoked early). Notice the log shows only the last four characters of the token — never the whole thing. client.get("/anything") now just works, authenticated and resilient. This is the foundation you'd build any real integration on.
Try It Yourself
13 minUse a real free API that needs a key (e.g. OpenWeather, GitHub's token, or any you can sign up for) — store the key in .env, never in the code.
Sign up for a free API key, put it in .env, load it with os.environ, and make one authenticated request. Confirm the code contains no secret.
Write auth_headers() that reads a token from the environment and returns {"Authorization": "Bearer ..."}, raising a clear error if the token isn't set. Reuse it across several requests.
Hint
import os def auth_headers() -> dict: token = os.getenv("ACCESS_TOKEN") if not token: raise RuntimeError("Set ACCESS_TOKEN in your .env") return {"Authorization": f"Bearer {token}"}
Write masked(secret) that turns "sk-live-abcd1234" into "sk-live-…1234" for safe logging — showing enough to identify it but not enough to use it. Handle short secrets safely.
Hint
def masked(secret: str) -> str: if len(secret) <= 8: return "…" # too short to reveal anything return f"{secret[:7]}…{secret[-4:]}" print(masked("sk-live-abcd1234567890")) # sk-live…7890
Mini-Challenge · The Secrets Auditor
8 minWrite scan_for_secrets(folder) that walks a project's .py files (Lesson 6) and flags lines that look like hard-coded secrets — long random strings assigned to names like key, token, secret, password, or patterns like sk-. Report file and line number. This is a real pre-commit safety check.
Show a sample solution
import re from pathlib import Path PATTERNS = [ re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*=\s*['\"][^'\"]{12,}['\"]"), re.compile(r"sk-[A-Za-z0-9]{16,}"), re.compile(r"AKIA[0-9A-Z]{16}"), # AWS access key id ] def scan_for_secrets(folder: str) -> None: hits = 0 for py in Path(folder).rglob("*.py"): for n, line in enumerate(py.read_text(encoding="utf-8").splitlines(), 1): for pat in PATTERNS: if pat.search(line): print(f"{py}:{n}: possible secret → {line.strip()[:60]}") hits += 1 print(f"\n{hits} possible secret(s) found" if hits else "\nNo obvious secrets found ✅") scan_for_secrets("src")
Non-negotiables: scans .py files, flags key/token/secret assignments and known prefixes, reports file:line.
Recap
3 minAPIs verify you with an API key (header or query), a bearer token (Authorization: Bearer …), or OAuth — where you exchange client_id/client_secret for a short-lived access token and refresh it before it expires. A small token manager caches and auto-refreshes so the rest of your code stays simple, and a 401 handler re-authenticates if a token is revoked early. The non-negotiable rule across all of it: secrets come from the environment, never the source code; never print or log them; git-ignore .env; and rotate any key the moment it's exposed.
Vocabulary Card
- API key
- A long secret string identifying your account on each request.
- bearer token
- A token sent in the Authorization header to prove identity.
- client-credentials flow
- OAuth grant where an app swaps id+secret for an access token.
- token refresh
- Obtaining a new access token before the current one expires.
Homework
4 minPick a free API that requires a key, sign up, and build weather.py (or similar) that loads the key from .env, makes an authenticated request through a resilient session, parses the JSON, and prints a clean summary. Include a .env.example, a .gitignore with .env, and a masked-key debug line. Confirm with git status that no secret is staged.
Sample · weather.py
import os, sys, requests from dotenv import load_dotenv load_dotenv() key = os.getenv("OPENWEATHER_KEY") if not key: print("Set OPENWEATHER_KEY in .env (copy .env.example)") sys.exit(1) print(f"using key …{key[-4:]}") # masked debug city = sys.argv[1] if len(sys.argv) > 1 else "Kuala Lumpur" resp = requests.get("https://api.openweathermap.org/data/2.5/weather", params={"q": city, "appid": key, "units": "metric"}, timeout=10) resp.raise_for_status() data = resp.json() print(f"{city}: {data['main']['temp']}°C, " f"{data['weather'][0]['description']}")
# .env (git-ignored) OPENWEATHER_KEY=your-real-key # .env.example (committed) OPENWEATHER_KEY=your-key-here # .gitignore .env
Non-negotiables: key from env, masked debug, .env git-ignored, .env.example committed, clean git status.