You can't truly hide an API key inside code you ship — Python has to reconstruct it at runtime, so it's always recoverable. The fix is to keep the key out of the shipped artifact: read it from an environment variable (or a git-ignored .env via python-dotenv) in development, and from a secrets manager or your own backend in production. Hard-coding, base64, a separate config.py, or XOR all leave the key trivially recoverable, and obfuscation only removes it from a text search — a running program still exposes it.
Every week someone ships a Python script or app with an API key baked into it, and every week keys get scraped off public repos and packaged apps. I wanted to show — with real output, not hand-waving — exactly where a hard-coded key leaks, why the popular "hide it" tricks don't, and what genuinely keeps a key out of attackers' hands.
The uncomfortable rule up front: if a secret ships inside code that runs on someone else's machine, it is not secret. The program has to turn it back into the real key to use it, so anyone who controls that machine can too. Everything below follows from that. All tests ran on Python 3.14.
Where a hard-coded key actually leaks
Start with the obvious version — a key sitting in the source:
import requestsAPI_KEY = "sk-live-9f83a1c47e5b2d60"def fetch(u):return requests.get(u, headers={"Authorization": f"Bearer {API_KEY}"})
"But I'll ship the compiled .pyc, not the .py." That changes nothing. I compiled it, then searched the bytecode. The key is stored verbatim in the code object's constants and shows up in a plain byte search of the file:
Key sits in the compiled code object's co_consts: TrueKey found by a raw byte search of the .pyc blob: True
Shipping .pyc instead of .py does not hide a string literal. Keys, tokens, and URLs are stored as plaintext constants — a strings command or grep finds them in seconds. (More on why in Python bytecode explained.)
The "fixes" that don't work
These are the three suggestions that come up most often. I tested each by reversing it:
| "Fix" | What it does | How it reverses |
|---|---|---|
base64 the key | Encodes to c2stbGl2ZS05Zj… | One base64.b64decode() call → original key |
Move it to config.py | Key lives in a separate file | That file still ships — open it and read it |
| XOR "encryption" | Scrambles the bytes with a key | The XOR key ships too, so it decrypts right back |
a) base64-encoded key -> reversed with one call: Trueb) moved to config.py -> key still readable in the file: Truec) XOR-'encrypted' key + the XOR key both ship, decrypts: True
The pattern is the same every time: encoding is not encryption, and any "encryption" whose key also ships is just encoding with extra steps. If your program can decode it without asking anyone, so can the person holding your program.
What obfuscation does — and does not — do here
Obfuscation is the honest middle case, so let me be precise about it. I ran the key-bearing script through our own Python Obfuscator (AST rename + string encryption) and checked the result:
plaintext key visible in obfuscated source: Falsekey found by a naive substring search: Falseruns; printed value equals the original key: True
So obfuscation does do something real: the key is no longer sitting in the file as plaintext, so a grep, a strings dump, or a quick decompile of the constants turns up nothing. That raises the cost of a casual grab. But look at the last line — the program still runs and reconstructs the exact key in memory, because it has to in order to use it. A determined attacker with a debugger or a memory dump gets it back.
Obfuscation is worth it for protecting your logic and for stopping drive-by key scraping. It is not a way to safely ship a secret. For an actual secret, the answer isn't "hide it better" — it's "don't ship it."
What actually works, in order
The whole game is keeping the key out of the artifact you distribute. Here's the ladder, from the baseline every project should do, to what a distributed app needs.
1. Environment variables (the baseline). Read the key from the environment at runtime; it never appears in your code. Same script, key removed:
import os, requestsAPI_KEY = os.environ["APP_API_KEY"] # read at runtime, never in the codedef fetch(u):return requests.get(u, headers={"Authorization": f"Bearer {API_KEY}"})
Key present in app.py source: FalseKey present in the compiled .pyc bytes: FalseRun with APP_API_KEY set in env -> resolved at runtime, len: 24Run WITHOUT the env var -> KeyError: 'APP_API_KEY'
The key is absent from both the source and the bytecode, and the program only works when the environment supplies it. That's the whole idea. Use os.environ["NAME"] when the key is required (fail loudly), or os.getenv("NAME", default) when it's optional.
2. A `.env` file for local development (with python-dotenv). Typing export on every shell gets old, so keep local secrets in a .env file and load them — but the .env itself must be git-ignored, because it holds the real key in plaintext:
APP_API_KEY=sk-live-9f83a1c47e5b2d60
from dotenv import load_dotenvimport osload_dotenv() # reads .env into the environmentAPI_KEY = os.environ["APP_API_KEY"]
I ran this with python-dotenv 1.2.2 and it loaded the value from .env into the environment as expected. The .env pattern gives you the convenience without ever committing the secret — as long as .gitignore contains .env from day one.
Commit a .env.example with the names but not the values (APP_API_KEY=), so teammates know what to set. Never commit the real .env.
3. A secrets manager (production). On a server, load secrets at boot from a dedicated store — AWS Secrets Manager, Google Secret Manager, HashiCorp Vault, Doppler, or your platform's built-in "environment secrets." Your code asks for the secret by name; the value lives outside the repo and the image, is access-controlled, and is auditable and rotatable.
4. Distributed apps: don't ship the key at all. Environment variables and secrets managers solve the *server* case, where you control the machine. If you're shipping a desktop tool, a script to customers, or anything that runs on a user's machine, no amount of hiding is safe — see every test above. The real fix is to put the key on your own backend and proxy the calls: your app calls *your* server, your server holds the key and talks to the third-party API. The secret never leaves a machine you control.
- Client app → your backend (authenticated as the user)
- Your backend (holds the API key) → third-party API
- Response flows back through your backend to the client
If a key has already been committed
Removing a key in a new commit does not remove it from git history — it's still there in every clone and on GitHub. Two steps, in this order:
- Rotate the key immediately. Revoke the exposed one and issue a new one. Assume the old key is compromised the moment it hit a remote — scanners find committed keys within minutes.
- Then scrub history with
git filter-repo(or the BFG Repo-Cleaner) and force-push, so the value is gone from past commits. Tools liketrufflehogandgitleaksscan a repo (and its history) for secrets — run one in CI so this never ships again.
Rotation is the part people skip. Scrubbing history without rotating leaves a live key that was already scraped. Rotate first, always.
The checklist
- Never hard-code a key — not in the source, not in a
config.py, not base64'd. - Read secrets from the environment (
os.environ/os.getenv). - Use a git-ignored `.env` locally (python-dotenv); commit a
.env.examplewith names only. - Use a secrets manager in production; rotate keys on a schedule.
- For anything running on a user's machine, proxy through your backend — don't ship the key.
- Scope keys to least privilege, and scan your repo + history with
gitleaks/trufflehog. - Use obfuscation to protect logic, not to hide secrets — those are different jobs.
Related reading: protecting Python source code (where obfuscation genuinely helps), Python string encryption tested, and is Python obfuscation secure?.
Protect your code — the right way
Obfuscate your Python logic at the AST level (and keep real secrets on your backend). Free, in your browser, no signup.
Open the Python ObfuscatorFree tools mentioned here
Frequently asked questions
How do I protect an API key in a Python script?
Keep it out of the code entirely. Read it from an environment variable at runtime (os.environ["NAME"]), use a git-ignored .env file with python-dotenv for local development, and a secrets manager in production. Never hard-code it, base64 it, or move it to a separate config.py — all of those ship the key in a recoverable form.
Is it safe to hard-code an API key in Python?
No. A hard-coded key is stored as a plaintext constant in both the .py source and the compiled .pyc bytecode, so a text search or a strings command finds it instantly. Shipping the .pyc instead of the .py does not hide it.
Does obfuscation hide an API key?
Only partially. Obfuscation (AST renaming + string encryption) removes the key from a plaintext search of the shipped file, which stops casual scraping. But the program still reconstructs the exact key in memory at runtime to use it, so a determined attacker with a debugger recovers it. Obfuscation protects logic; it is not a safe way to ship a secret.
Where should I store API keys in a Python project?
In development, in a git-ignored .env file loaded with python-dotenv (commit a .env.example with names only). In production, in a secrets manager such as AWS Secrets Manager, Google Secret Manager, HashiCorp Vault, or Doppler, loaded into the environment at boot. Never in the repository.
How do I use environment variables for API keys in Python?
Read them at runtime: API_KEY = os.environ["APP_API_KEY"] (raises if missing) or os.getenv("APP_API_KEY", default) for an optional value. Set the variable in your shell, your deployment platform, or via a .env file. The key never appears in your source or your compiled bytecode.
I already committed an API key to git — what do I do?
Rotate it first: revoke the exposed key and issue a new one, because it was scrapeable the moment it hit a remote. Then remove it from history with git filter-repo or the BFG Repo-Cleaner and force-push. Add a scanner like gitleaks or trufflehog to CI so it can't happen again.
How do I protect an API key in a desktop app or a script I give to customers?
You can't safely embed it — anything on the user's machine can be extracted. Route the calls through your own backend instead: the client app calls your server (authenticated), and your server holds the key and talks to the third-party API. The secret never leaves infrastructure you control.