Use environment variables for secrets
Learn to manage secrets securely using environment variables in this hands-on Secure development lesson. Step-by-step guidance, troubleshooting, and what to study next.
Focus: use environment variables for secrets management
Your database password is sitting in your source code, and you're not even thinking about it. It was just a quick hack to get the prototype running, but now that prototype has become a production system, and every developer who clones the repo gets a copy of your production credentials. The pain is real: credentials in Git history, keys rotating because of an accidental push, and a security audit that's about to shred your checklist. This lesson fixes that pain by showing you how to use environment variables for secrets management — the industry-standard way to keep secrets out of your codebase while keeping them accessible to your running application. By the end, you'll be able to extract every secret from your code, load it safely at runtime, and never hardcode another credential again.
The problem this lesson solves
Hardcoded secrets are a silent catastrophe. When you embed a password, API key, or database URL directly into your Python source file, you're not just writing a line of code — you're publishing a credential to everyone who has read access to your repository. Even if the repo is private, the damage multiplies when you share a screen during a pairing session, when you copy code to a forum for help, or when you accidentally make the repo public.
The consequences are concrete:
- Credential leakage through version control: Git keeps a history of every commit. Even if you remove the secret in a later commit, it remains in the history forever. An attacker with access to your repo can simply check out an old commit.
- No rotation path: When a hardcoded secret leaks, you must rotate it, but you also need to update the code, redeploy, and hope you didn't miss a place. This is tedious and error-prone.
- No environment separation: Hardcoded secrets are the same in development, staging, and production. That means you're running production credentials in a debugger, and test credentials in a live environment — a mess that's both insecure and confusing.
Right now, you're one git push away from exposing your database to the world. The fix is to remove secrets from your code entirely and supply them through the runtime environment via environment variables.
Core concept / mental model
Think of your application as a secret consumer, not a secret keeper. The environment is the delivery mechanism, and the code is the consumer. A common analogy is a chef who never writes the secret recipe down in the cookbook; instead, the restaurant's manager whispers the recipe to the chef every morning. The cookbook (your code) stays clean and shareable, while the secret recipe (your credentials) lives in the environment where only the chef can access it.
In practical terms, an environment variable is a key-value pair that exists in the shell or process where your application runs. You can set it before you start the app, and your code reads it at runtime.
Here's the key mental shift:
- Before: Your code has
db_password = "supersecret123". - After: Your code has
db_password = os.environ["DB_PASSWORD"].
The code no longer owns the secret — it requests it from the environment. The secret can be different in development, staging, and production because the environment in each case will set a different value.
This model scales beyond environment variables too: container orchestration platforms like Docker and Kubernetes let you inject environment variables into your containers, giving you the same pattern whether you run a laptop script or a microservice in the cloud.
How it works step by step
Using environment variables for secrets is a three-step process: define the secret in the environment, read it in your code, and keep the definitions out of your repository. Here's how each step works in practice.
Step 1: Define the secret in your environment
Before your code runs, you need to set the variable. On Linux and macOS, you use export in the shell:
export DB_PASSWORD='correct-horse-battery-staple'
On Windows (PowerShell), you use $env::
$env:DB_PASSWORD="correct-horse-battery-staple"
In cloud platforms, you'll set these in the service's configuration panel or in your deployment descriptor. The key is that this definition happens outside the code repository.
Step 2: Read the secret in your code
The standard Python library gives you os.environ, a dictionary-like object containing the current environment variables. You access it like a regular dictionary:
import os
db_password = os.environ["DB_PASSWORD"]
If the variable is missing, this raises a KeyError. To handle missing secrets gracefully, you can use os.getenv():
import os
db_password = os.getenv("DB_PASSWORD") # Returns None if missing
For fail-fast behavior, prefer os.environ["DB_PASSWORD"] because you want the app to crash loudly if a required secret is not set, rather than running with a None and mysterious errors later.
Step 3: Keep secret definitions out of the repo
Never commit a file that contains your actual secret values. The common pattern is to commit a .env.example file with placeholder keys (and empty values) so developers know which variables are needed. Then each developer copies it to .env and fills in their local values — and .env is listed in your .gitignore.
This separation ensures that your repository contains no secrets, only the instructions for where to find them.
Hands-on walkthrough
Let's put this into practice. We'll build a simple Python script that logs into an API using an API key stored in an environment variable. We'll also create the supporting files for a team project.
Example 1: Reading a secret from the environment
Create a file app.py:
import os
api_key = os.environ["API_KEY"]
print(f"Connecting to external API with key: {api_key[:4]}...")
Now run it in your shell:
export API_KEY='sk-1234abcd'
python app.py
Expected output:
Connecting to external API with key: sk-1...
Yes, we print a portion of the key for demonstration only — you should never print secrets in real applications. Notice how the value comes from the environment, not the code.
Example 2: Using .env files locally with python-dotenv
In a team setting, you don't want to type export every time. The python-dotenv package loads a .env file into your environment. To install it:
pip install python-dotenv
Then in your script:
from dotenv import load_dotenv
import os
load_dotenv() # Loads variables from the .env file in the current directory
database_url = os.environ["DATABASE_URL"]
print(f"Connecting to {database_url}")
And a local .env file (which you'll never commit):
DATABASE_URL=postgresql://user:supersecret@localhost:5432/mydb
When you run python app.py, load_dotenv() reads the .env file and populates os.environ.
Example 3: Failing fast when a secret is missing
A good pattern is to validate that all required secrets are present at startup. Here's a helper function:
import os
def require_env(var_name: str) -> str:
"""Return the value of an environment variable or raise a clear error."""
value = os.getenv(var_name)
if value is None or value == "":
raise EnvironmentError(f"Required environment variable '{var_name}' is not set.")
return value
# Use it for all secrets
DB_HOST = require_env("DB_HOST")
DB_USER = require_env("DB_USER")
DB_PASSWORD = require_env("DB_PASSWORD")
print("All secrets loaded successfully.")
If a variable is missing, you get a clear, immediate error instead of a crash deep in your code an hour later.
Sample .gitignore and .env.example
Here's what you'd commit to your repo:
# .gitignore
.env
And a template file you do commit — .env.example:
# .env.example
DB_HOST=localhost
DB_USER=app_user
DB_PASSWORD=change-me
API_KEY=change-me
Now your team knows exactly which secrets they need to set up locally without seeing your real values.
Pro tip: For security, never include real secrets even in
.env.example. Use placeholder values likechange-meso no one accidentally copies a live secret into a new environment.
Compare options / when to choose what
Environment variables are not the only way to manage secrets, and choosing the right tool depends on your deployment scenario. Here's a quick comparison of common options:
| Approach | Security level | Ease of use | Best for |
|---|---|---|---|
| Hardcoded in code | Very poor — scores in history, everyone sees it | High (but wrong) | Nothing. Don't do it. |
| Environment variables | Good — secrets live outside code, per-environment values | High for dev, medium for production | Small projects, containerized apps, most 12-factor apps |
| Secret vaults (HashiCorp Vault, AWS Secrets Manager) | Excellent — encrypted storage, rotation, access control | Medium — requires infrastructure | Production systems, large teams, compliance-heavy environments |
Configuration files (e.g., config.yaml with secrets) |
Poor — files can be committed accidentally, often plaintext | Medium | Not recommended for secrets; use env vars instead |
| Cloud secrets (e.g., AWS SSM Parameter Store) | Very good — integrates with cloud IAM | Medium | Cloud-native applications, easy audit logging |
When to choose environment variables:
- You're starting out or building a small/medium app.
- You deploy with Docker or Kubernetes (they natively support env vars).
- You follow the 12-factor app principles.
- You need simplicity and don't already have vault infrastructure.
When to move to a vault:
- You need automatic rotation of secrets.
- You need audit trails for who accessed which secret.
- You have compliance requirements (e.g., PCI-DSS, HIPAA).
- Your team is large and secrets change often.
Remember, environment variables are the baseline. Vaults add extra security but also extra complexity. Start with env vars, and if audits or scale demand it, upgrade later.
Troubleshooting & edge cases
Environment variables are simple, but they still trip people up. Here are the most common issues and how to fix them.
Secret is None or empty
You get a KeyError: 'DB_PASSWORD' or your code sees None. This means the variable isn't set in the current process. Causes:
- You set it in a different terminal than the one running your app. Environment variables are per-process — each terminal has its own.
- You forgot to run
exportin the same shell. - You're using a
.envfile but didn't callload_dotenv()in your code. - You're running the app under a different user (e.g., a systemd service) that doesn't inherit your shell variables.
Fix: Check with print(os.environ) in a test script, or run printenv DB_PASSWORD in the shell. Make sure the variable is set in the exact context your app runs.
.env file not being loaded
If you use python-dotenv, the path is relative to the current working directory. If you run python scripts/run.py from the project root, it looks for .env in the project root — which is correct. But if you run it from a different directory, it won't find it. Fix by giving an explicit path: load_dotenv("/path/to/.env").
Secrets ending up in Git history
You've already committed secrets in previous commits. Even if you remove them now, they're in history. You need to rewrite history with tools like git filter-branch or git-filter-repo, and then force-push — but that disrupts all collaborators. Also, you should rotate the leaked secret immediately.
Spaces or special characters in values
When using export in bash, values with spaces need quotes: export PASSWORD='my password'. For .env files, use double quotes and escape special characters. For example, a password with # inside needs to be quoted: PASSWORD="pa#ss" — otherwise # starts a comment.
Environment variables can still leak
Env vars are visible to any process running as the same user that can read /proc/<pid>/environ on Linux. They're not a perfect secret container, but they're far better than code. For extra protection in production, use a vault or platform-native secret injection.
What you learned & what's next
You've just closed a major security hole. You now understand why hardcoded secrets are dangerous, how to use environment variables to manage secrets, and how to implement this in a clean, team-friendly way. You can explain the core idea behind using environment variables for secrets management, and you've completed a practical exercise reading secrets from the environment. You also know when to move to more advanced secret management solutions.
Every future lesson in this track assumes your secrets are managed this way — because it's the foundation of secure application development. As a next step, consider learning about secure application configuration or input validation, where you'll build on this pattern to avoid other common vulnerabilities.
But before you move on, try this: go through your current projects and find at least one hardcoded secret. Extract it to an environment variable, update your .gitignore, and add a .env.example. That one act will make your codebase dramatically safer today.
Now go secure your code — your future self (and your security auditor) will thank you.
Practice recap
Practice exercise: Create a small Python script that connects to a mock API. Extract any hardcoded credentials into environment variables, add a .gitignore and a .env.example, and run your script with and without the variables set to see the fail-fast behavior. For an extra challenge, try injecting secrets via Docker's -e flag when running your script in a container.
Common mistakes
- Hardcoding secrets directly in Python files — it's the root of the problem.
- Committing
.envfiles to version control despite adding them to.gitignore— always double-check withgit statusbefore committing. - Forgetting to call
load_dotenv()in code when using a.envfile locally, causingKeyErrororNonevalues. - Setting environment variables in one shell and expecting them in another — each process has its own environment.
- Printing secret values to logs or console during debugging — logs often end up in shared systems.
Variations
- Use
os.getenvwith a default and fail-fast helper instead of direct dictionary access. - Use
python-dotenvfor local development but rely on native environment variables in production. - Integrate with platform-specific secret managers like AWS Secrets Manager and inject secrets as environment variables in your container.
Real-world use cases
- A Django app reads its
SECRET_KEY, database credentials, and API keys from environment variables set by Heroku or AWS Elastic Beanstalk — keeping secrets out of the codebase. - A CI/CD pipeline injects TUTORIAL_SUBMISSION_PIPELINE_SECRET_A and SECRET_B as environment variables so that automated tests and builds can authenticate without hardcoding credentials.
- A Kubernetes deployment uses a
Secretobject to mount database passwords as environment variables, so the container code never contains secrets.
Key takeaways
- Never hardcode secrets — always read them from environment variables at runtime.
- Use
os.environ["VAR"]for required secrets to fail fast, andos.getenvfor optional ones. - Set environment variables outside your code in the shell, container, or cloud platform.
- Commit a
.env.exampletemplate but never commit.env; use.gitignoreto block it. python-dotenvsimplifies local development but isn't needed in production.- Environment variables are a baseline; use secret vaults for advanced needs like rotation and audit.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.