API Keys, Secrets, and Safe Configuration

Prove who you are without giving the key away

Advanced 10 min

In this lesson

Most useful APIs want to know who is asking. They issue an API key, a long string you send with each request. This lesson covers where that key should live (not in your code), how to send it, and how to stay on the right side of a service’s rate limits.

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.

Example
# Never do this.
api_key = "sk_live_9f2b7c1e4a8d"

response = requests.get(url, headers={"Authorization": f"Bearer {api_key}"})
The key is now in your file, your history, and everyone's clone.

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.

Example
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,
)
The code says which variable it needs, never what the value is.

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] + "...")

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.

Example
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
Handle 429 explicitly, and pause between requests.

Common mistake: Committing a key and then deleting the line

Why it happens:

Removing it from the current file looks like removing it from the project.

How to fix it:

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

Why it happens:

Some API documentation shows it that way because it’s the shortest example.

How to fix it:

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

Why it happens:

Dumping everything is the fastest way to see what went wrong.

How to fix it:

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

Why it happens:

A loop with no pause is the natural first version.

How to fix it:

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?

You get back status 429. What does it mean?

You accidentally committed a key to a public repository. What is the right response?

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))

What to learn next

You now handle secrets properly: keys live in environment variables rather than in your code, the program fails early and clearly when one is missing, the key travels in an Authorization header, and a committed key is treated as burned and reissued. You also handled 429 by reading Retry-After and pausing.

One last look outward before the unit ends. Django and FastAPI maps the frameworks beyond Flask and gives you a way to choose between them.