Secrets & environment handling
Out of source, out of argv, into env or a manager.
A secret is a spare key. A token, a password, an API key (application programming interface key): the long string that lets your automation sign in to Slack or your cloud provider with no human typing anything. There is one place you would never hide a real house key, and that is taped under the doormat where the first person who looks will find it. Writing a secret straight into your Python file is the doormat.
Keeping that key out of your code sounds like a small tidiness rule. It is a security control. Where a secret lives decides who can read it, whether it leaks the moment you push your work, and how fast you can replace it after a breach. In this lesson you will read secrets from the environment, fail loudly when one is missing, use a `.env` file for local work, keep credentials off the command line and out of your logs, and hand the whole job to a real secret manager in production.
A hardcoded token is already leaked
Here is the tempting version. It runs on your laptop, so it feels finished.
# notify_bad.py -- DON'T DO THISimport requestsTOKEN = "xoxb-2417-88f2-9c1a-realish-token"requests.post("https://slack.com/api/chat.postMessage",headers={"Authorization": f"Bearer {TOKEN}"},json={"channel": "#alerts", "text": "scan finished"},)
The moment you `git commit` this file, the token is written into your repository's history and stays there. Deleting the line later changes nothing; the old commit still holds it, and `git log -p` prints it straight back. From there it travels on its own. Someone forks the repo. A teammate pastes the file into a support ticket. A screenshot of your editor lands in a chat channel. Every copy carries a working key. Automated scanners crawl public commits and flag exposed tokens within seconds of a push, and attackers run the same scanners. Treat any credential that has touched a source file as burned. Deleting it is not enough; you have to rotate it, meaning revoke the old value and issue a fresh one.
Read the secret from the environment instead
An environment variable (a named value the operating system hands to every program it starts) works like a note the shift supervisor gives each worker at clock-in. The note is not printed in the company handbook, which is your code. It is passed in fresh at the door. Your program asks the operating system for the value by name at the exact moment it needs it, and different machines can hand over different values without you changing a line.
Python reads these through the `os` module, and there are two ways with an important difference between them.
`os.getenv('NAME')` returns `None` when the name is not set. Your tool keeps running with a hole where the credential should be and crashes later somewhere confusing, often with a message like `TypeError: expected str, got NoneType` that points nowhere near the real problem. `os.environ['NAME']` raises a `KeyError` the instant the value is missing. Failing at the first missing input, right next to the cause, is the behavior you want from a security tool. The next section wraps that idea in a helper with a message a tired on-call engineer can act on at three in the morning.
A get_secret() helper that fails loudly
A raw `KeyError` gives you the name and nothing else. Wrap the lookup once so every tool in your codebase reports a missing secret the same clear way. Notice the module is named `config.py`, not `secrets.py`, because the standard library already ships a module called `secrets` and shadowing it would break other code.
# config.pyimport osclass MissingSecret(RuntimeError):"""Raised when a required secret isn't set in the environment."""def get_secret(name: str) -> str:value = os.environ.get(name)if not value: # catches both unset and empty ""raise MissingSecret(f"Required secret {name!r} is not set. ""Export it or add it to your .env file.")return value
# notify.pyfrom config import get_secrettoken = get_secret("SLACK_BOT_TOKEN")print(f"loaded {len(token)}-char token, starts with {token[:5]}...")
Run it with nothing set and it stops immediately, telling you exactly which name to fix and where to put it.
Set the value and the same tool runs clean, reporting only the length, never the secret itself.
.env files for local development
Typing `export` for five variables every time you open a terminal gets old, and one day you forget one and chase a phantom bug. A `.env` file is your personal glovebox: a small text file holding the keys for this one project on this one machine, read automatically when the tool starts. It never leaves your laptop, and it never goes into production.
# .env -- local development only, never committedSLACK_BOT_TOKEN=xoxb-2417-88f2-9c1a-realish-tokenVT_API_KEY=abc123def456
The `python-dotenv` library reads that file into the environment. Install it, then load it as the first thing your program does.
`load_dotenv()` returns `True` when it found a file to read. By design it does not overwrite a variable that is already set, so a real value exported in your shell or injected by your deployment platform always wins over the file. The same code then runs unchanged on your laptop, where the `.env` supplies the values, and in production, where there is no `.env` and the platform provides them.
# .gitignore.env*.env.venv/
# .env.example -- safe to commit; real values live in .envSLACK_BOT_TOKEN=VT_API_KEY=
Keep secrets off the command line and out of logs
Passing a secret as a command-line flag feels handy, and it is a trap. The command line of a running program is close to public. The argument vector (argv, the list of words you typed after the command) of every process is readable by other processes on the same machine, and it gets saved to your shell history file on top of that.
There is the key in plain sight, readable through `ps` (the process status tool) by other accounts on that box, including low-privilege service users who should never see it. The same string then lands in `~/.bash_history`, so it outlives your session and hands a working credential to anyone who reads that file later.
Environment variables avoid both traps. They are not world-readable the way a command line is: `ps` hides them by default, and one unprivileged user cannot read another user's environment. They also stay out of your shell history. That is the practical reason security tools take credentials from the environment rather than from flags.
In production, hand the job to a secret manager
A `.env` file is a glovebox, fine for one car and one driver. A fleet needs a key depot with a logbook and a guard. That is what a secret manager is: a service whose entire job is to store credentials, control who gets them, and record every access.
In production you move from files to a dedicated service: HashiCorp Vault, or your cloud provider's store such as AWS Secrets Manager, Google Secret Manager, or Azure Key Vault. Instead of a static string sitting on disk, your program proves its identity to the service with a short-lived credential and asks for the secret at runtime. You gain an audit log of every read, central rotation so a leaked key can be revoked everywhere at once, per-tool access rules, and often dynamic secrets that are generated on demand and expire within minutes.
Your code barely changes. The `get_secret()` helper keeps the same shape; you swap its body to call the service's client library instead of reading `os.environ`, and every tool that already calls `get_secret('DB_PASSWORD')` keeps working untouched. That is the payoff of hiding the lookup behind one function from the very first day.
# config.py -- production variant, secrets pulled from HashiCorp Vaultimport osimport hvac # pip install hvacclass MissingSecret(RuntimeError):"""Raised when a required secret can't be found."""def get_secret(name: str) -> str:client = hvac.Client(url=os.environ["VAULT_ADDR"],token=os.environ["VAULT_TOKEN"], # short-lived, injected by the platform)resp = client.secrets.kv.v2.read_secret_version(path="soc-tools",raise_on_deleted_version=True,)value = resp["data"]["data"].get(name)if not value:raise MissingSecret(f"Secret {name!r} not found in Vault path 'soc-tools'.")return value