API Keys, Secrets, and Safe Configuration
Prove who you are without giving the key away
In this lesson
Explain it like I’m 5
An API key is a library card for software. It proves who is borrowing. Don’t tape it to the front door, and don’t photocopy it into a public notebook.
A key identifies you, so it must stay secret
The service uses your key to know who is calling, count your usage, and bill or throttle accordingly. Anyone holding it can act as you: exhaust your quota, run up your bill, or reach data meant for you.
Which is why the single most common and most costly mistake is putting the key straight in the code, and then committing it. Once a key reaches a public repository, treat it as burned: revoke and reissue it. Deleting the line does not help, version control remembers every previous commit.
# Never do this.
api_key = "sk_live_9f2b7c1e4a8d"
response = requests.get(url, headers={"Authorization": f"Bearer {api_key}"})
Keep secrets in the environment
The standard fix is an environment variable: a value set outside your program that the program reads at startup. The key lives in your shell, in a .env file that is never committed, or in your host’s secret manager, so the code stays shareable.
Read it with os.environ.get(), and fail loudly and early if it is missing. A clear ‘this variable is not set’ message beats a confusing 401 twenty lines later.
import os
import requests
api_key = os.environ.get("WEATHER_API_KEY")
if not api_key:
raise RuntimeError("WEATHER_API_KEY is not set. Export it before running.")
response = requests.get(
"https://api.example.com/current",
params={"city": "London"},
headers={"Authorization": f"Bearer {api_key}"},
timeout=10,
)
Refactor a hardcoded key into an environment lookup. The first line fakes what your shell would normally provide. Read the key with os.environ.get, then print its length and the first four characters as shown.
import os
os.environ["WEATHER_API_KEY"] = "demo-key-12345" # normally set outside your code
api_key = "demo-key-12345" # TODO: read this from the environment instead
print("Key loaded, length:", len(api_key))
print("Sends header: Authorization: Bearer", api_key[:4] + "...")
Replace the hardcoded string with os.environ.get("WEATHER_API_KEY"). The output stays identical, but the value now comes from outside the file.
import os
os.environ["WEATHER_API_KEY"] = "demo-key-12345" # normally set outside your code
api_key = os.environ.get("WEATHER_API_KEY")
if not api_key:
raise RuntimeError("WEATHER_API_KEY is not set.")
print("Key loaded, length:", len(api_key))
print("Sends header: Authorization: Bearer", api_key[:4] + "...")
Key loaded, length: 14
Sends header: Authorization: Bearer demo...
Rate limits, and errors that don’t leak
Services cap how often you may call them. Go over and you get 429 Too Many Requests, often with a Retry-After header saying how many seconds to wait. Respect it: sleep between calls in a loop, and back off when told to.
One last habit. When a request fails, print what happened, never the key. It is startlingly easy to dump an entire request, headers included, into a log that someone else can read.
import time
for city in ["London", "Paris", "Berlin"]:
response = requests.get(url, params={"city": city}, headers=headers, timeout=10)
if response.status_code == 429:
wait = int(response.headers.get("Retry-After", 5))
print(f"Rate limited, waiting {wait}s")
time.sleep(wait)
continue
response.raise_for_status()
print(city, response.json()["main"]["temp"])
time.sleep(1) # be polite between calls
Common mistake: Committing a key and then deleting the line
Removing it from the current file looks like removing it from the project.
Git keeps every past commit, so the key is still there. Revoke and reissue it at the provider, then move the new one to an environment variable.
Common mistake: Putting the key in the URL as a query parameter
Some API documentation shows it that way because it’s the shortest example.
Prefer an Authorization header when the service supports one. Query strings show up in browser history, proxy logs, and server access logs.
Common mistake: Printing the whole request or response when debugging
Dumping everything is the fastest way to see what went wrong.
Print the status code, the URL, and the error message. Never the headers. A log file is a text file, and text files get shared.
Common mistake: Hammering an API until it returns 429
A loop with no pause is the natural first version.
Sleep between calls, and handle 429 by waiting for Retry-After seconds. Being throttled or blocked costs far more time than the pause did.
Why should an API key live in an environment variable rather than in your code?
Keeping the value outside the source means the file, and its whole history, stays safe to share.
You get back status 429. What does it mean?
429 Too Many Requests is a rate limit. Check Retry-After and pause before trying again.
You accidentally committed a key to a public repository. What is the right response?
History keeps the old commit, so the key must be treated as exposed and replaced.
Mini exercise (medium)
Write load_key(name) that reads an API key from the environment. Return the value if it’s set, and raise a RuntimeError naming the missing variable if it isn’t. Then write auth_header(key) returning {"Authorization": "Bearer <key>"}.
Your turn. Fill in the code below and press Run to test it right here, nothing to install.
import os
os.environ["DEMO_API_KEY"] = "abcd-1234"
def load_key(name):
return "" # TODO: read from the environment, raise if missing
def auth_header(key):
return {} # TODO: {"Authorization": "Bearer <key>"}
key = load_key("DEMO_API_KEY")
print(auth_header(key))
os.environ.get(name) returns None when unset. Put the variable name into the error message with an f-string so the fix is obvious.
import os
os.environ["DEMO_API_KEY"] = "abcd-1234"
def load_key(name):
value = os.environ.get(name)
if not value:
raise RuntimeError(f"{name} is not set")
return value
def auth_header(key):
return {"Authorization": f"Bearer {key}"}
key = load_key("DEMO_API_KEY")
print(auth_header(key))
{'Authorization': 'Bearer abcd-1234'}
try:
load_key("DEFINITELY_NOT_SET_12345")
except RuntimeError as err:
assert "DEFINITELY_NOT_SET_12345" in str(err), "the error should name the missing variable"
else:
raise AssertionError("a missing variable should raise RuntimeError")
print("✓ Looks good!")