CoursesPython for security automationSecurity automation at scale

Secrets & environment handling

Out of source, out of argv, into env or a manager.

Intermediate18 min · lesson 10 of 14

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
# notify_bad.py -- DON'T DO THIS
import requests
TOKEN = "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.

~/secopslog — bash
$ # getenv returns None for a missing name: no crash, easy to miss python3 -c "import os; print(os.getenv('SLACK_BOT_TOKEN'))" # environ[...] raises the instant the value is absent python3 -c "import os; print(os.environ['SLACK_BOT_TOKEN'])"
None Traceback (most recent call last): ... KeyError: 'SLACK_BOT_TOKEN'

`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.py
# config.py
import os
class 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.py
# notify.py
from config import get_secret
token = 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.

~/secopslog — bash
$ python3 notify.py
Traceback (most recent call last): File "/home/analyst/notify.py", line 4, in <module> token = get_secret("SLACK_BOT_TOKEN") ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/analyst/config.py", line 12, in get_secret raise MissingSecret( MissingSecret: Required secret 'SLACK_BOT_TOKEN' is not set. Export it or add it to your .env file.

Set the value and the same tool runs clean, reporting only the length, never the secret itself.

~/secopslog — bash
$ export SLACK_BOT_TOKEN="xoxb-2417-88f2-9c1a-realish-token" python3 notify.py
loaded 33-char token, starts with xoxb-...

.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
# .env -- local development only, never committed
SLACK_BOT_TOKEN=xoxb-2417-88f2-9c1a-realish-token
VT_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.

~/secopslog — bash
$ pip install python-dotenv
Collecting python-dotenv Downloading python_dotenv-1.0.1-py3-none-any.whl (19 kB) Installing collected packages: python-dotenv Successfully installed python-dotenv-1.0.1
$ python3 - <<'PY' from dotenv import load_dotenv import os print("loaded .env:", load_dotenv()) print("token:", os.getenv("SLACK_BOT_TOKEN")) PY
loaded .env: True token: xoxb-2417-88f2-9c1a-realish-token

`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.

A committed .env is a leaked .env
The whole point of `.env` is that it stays on your machine. Add it to `.gitignore` (the file listing paths Git must never track) before you write a single secret into it, or one `git add .` will commit every key you own into history. Commit a `.env.example` with the names and empty values instead, so a new teammate knows what to fill in without you handing them live credentials.
.gitignore
# .gitignore
.env
*.env
.venv/
.env.example
# .env.example -- safe to commit; real values live in .env
SLACK_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.

~/secopslog — bash
$ # the risky pattern: secret handed in as a flag python3 upload.py --api-key=abc123def456 & ps -eo pid,cmd | grep upload.py
[1] 48213 48213 python3 upload.py --api-key=abc123def456 48291 grep upload.py

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.

~/secopslog — bash
$ history | tail -n 2
512 python3 upload.py --api-key=abc123def456 513 history | tail -n 2
Never print a secret, not even once
The reflex to debug with `print(token)` or `logging.info(f"using {token}")` writes the credential into log files, terminal scrollback, and any shipper that forwards logs to a central system where dozens of people can search them. Log the fact that a secret loaded and its length, never its value: `logging.info("token loaded (%d chars)", len(token))`.

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
# config.py -- production variant, secrets pulled from HashiCorp Vault
import os
import hvac # pip install hvac
class 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
Where should a credential come from?
Your tool needs a credential
token, API key, database password
Hardcoded in source
Never
Committed, forked, screenshotted, pasted into tickets. Burned the moment it is written; rotate it.
Command-line flag
Never
Visible in ps to other users and saved to ~/.bash_history for anyone who reads the file.
Environment variable
Good for servers and pipelines
Injected at start, not world-readable; read with os.environ so a missing value fails loudly.
.env file
Local development only
A convenient glovebox via python-dotenv; must sit in .gitignore, never in production.
Secret manager
Production
Vault or a cloud store: audit logs, central rotation, access rules, short-lived dynamic secrets.
The same secret can enter your tool five ways, and the safe choice depends on where the tool runs. Anything written into source or passed on the command line is exposed the moment it exists; environment variables, a gitignored .env, and a secret manager keep it out of history and out of other users' reach.
Quick check
01A teammate hardcoded an API key, committed it, then in the next commit deleted the line and pushed. Is the key safe now?
Incorrect — The line is gone from the current file, but `git log -p` still prints it from the earlier commit. History keeps the old value.
Correct — Removing the line does not remove it from history; the key is compromised and has to be revoked and reissued, not just deleted.
Incorrect — Forks, clones, tickets, and CI logs all carry it, and access to a private repo can change. Private is not a guarantee.
Incorrect — Even in a private repo the value is in history and readable by everyone with access. Treat any committed secret as burned.
02For a required secret, why does the lesson prefer `os.environ['NAME']` over `os.getenv('NAME')`?
Incorrect — both read the same environment, and the only difference is what happens when the value is missing.
Correct — failing loudly at the first missing input is the behavior you want from a security tool.
Incorrect — it is the reverse, since getenv returns None and os.environ[...] raises KeyError.
Incorrect — reading a .env file requires python-dotenv, and os.environ does not do it on its own.
03On a production server the platform has already exported API_KEY into the environment. A `.env` file on that box also defines API_KEY with a different value, and your program calls `load_dotenv()` at startup. Which value does your code get?
Incorrect — by design load_dotenv does not overwrite a variable that is already set.
Incorrect — precedence is not based on timing; an already-set variable simply wins.
Correct — a real value from the shell or platform beats the file, so the same code runs safely on a laptop and in production.
Incorrect — there is no conflict error, and load_dotenv just leaves the existing value in place.

Related