15.12. Secrets Management
Table of Contents
Why Secrets Management Matters
In backend development you constantly work with sensitive values such as database passwords, API keys, encryption keys, and tokens. Collectively these are called secrets.
If secrets leak, attackers can often bypass your authentication and authorization, or directly connect to your database or third‑party services.
Never hard‑code secrets in your source code or commit them to version control.
This chapter focuses on how to store, access, and rotate secrets safely, not on authentication or encryption theory which are covered in other chapters.
What Counts as a Secret?
A secret is any value that must be kept confidential in order to keep your system secure.
Typical examples in backend systems:
| Type | Example value | Why it is sensitive |
|---|---|---|
| Database credentials | postgres://user:pass@db:5432/app | Lets an attacker access or destroy your data |
| API keys | sk_live_51N9... (Stripe secret key) | Lets attacker charge cards, refund payments, etc. |
| OAuth client secrets | GOOGLE_CLIENT_SECRET=abc123 | Lets attacker impersonate your app with Google |
| JWT signing keys | JWT_SECRET=super-secret-key | Lets attacker create valid tokens and impersonate users |
| Encryption keys | MASTER_KEY=base64:... | Lets attacker decrypt stored data |
| SSH keys | id_rsa private key | Lets attacker log into servers |
| SMTP credentials | SMTP_PASSWORD=mailpass | Lets attacker send email as your domain |
| Third‑party tokens | SLACK_BOT_TOKEN=xoxb-... | Lets attacker access external services |
| Cloud credentials | AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | Lets attacker control your cloud resources |
Non‑examples:
- Public API URLs, like
https://api.stripe.com - Public keys (paired with private keys)
- Feature flags that are not security sensitive
As a simple rule:
If exposing a value would let an attacker do something harmful, treat it as a secret.
Common Mistakes with Secrets
Understanding what not to do makes good practices clearer.
Hard‑Coding Secrets in Source Code
Example of what you should avoid:
# config.py
DB_URL = "postgresql://user:mysecretpassword@localhost:5432/app_db"
JWT_SECRET = "super-secret-key-123"
STRIPE_SECRET_KEY = "sk_live_..."Problems:
- The secrets end up in Git history forever.
- Everyone with repository access gets full access to production systems.
- If you share your code publicly you automatically leak your secrets.
Committing Secret Files to Git
Example:
# .env
DATABASE_URL=postgresql://user:password@localhost:5432/app_db
JWT_SECRET=super-secret-key
If .env is tracked by Git:
- It is stored in the repository history and backups.
- It might end up in forks, mirrors, or public repos.
Always use a .gitignore entry for local secret files:
# .gitignore
.env
.env.*
secrets.jsonSharing Secrets Over Insecure Channels
Risky patterns:
- Sending database passwords in plain text chat.
- Emailing API keys without any protection.
- Copying secrets to random sticky notes or shared documents.
Better patterns:
- Use dedicated secret sharing tools or encrypted messaging.
- Use password managers with secure sharing features.
- Use one‑time secret URLs that expire after first read.
Principles of Good Secrets Management
These principles guide the techniques and tools you use.
Principle 1: Externalize Configuration
Configuration values, especially secrets, should come from outside your code.
Typical sources:
- Environment variables
- Configuration files that are not committed to Git
- Secrets management services (Vault, cloud secret managers)
Example pattern:
import os
DATABASE_URL = os.environ["DATABASE_URL"]
JWT_SECRET = os.environ["JWT_SECRET"]Your code knows how to read configuration, but not the actual values.
Principle 2: Least Privilege
Each component should have only the secrets it actually needs, and each secret should grant only the minimal permissions.
Examples:
- A read‑only database user for analytics instead of the full admin user.
- Per‑service API keys instead of a global key for all apps.
- Separate keys for development, staging, and production.
Principle 3: Separation of Environments
Never reuse the same secret across different environments.
Better structure:
| Environment | Database password | JWT secret | Stripe key |
|---|---|---|---|
| local | local_db_pass | local_jwt_secret | Test key sk_test_... |
| staging | staging_pass | staging_jwt | Test key sk_test_... |
| production | prod_pass | prod_jwt | Live key sk_live_... |
This keeps testing and experimentation from harming real users.
Principle 4: Rotate and Revoke
Secrets should be changeable and revocable without changing your code.
You should be able to:
- Replace a leaked secret with a new one.
- Roll keys regularly for high‑value secrets like JWT signing keys.
- Disable a token or API key that you no longer use.
Principle 5: Audit and Visibility
You should:
- Know who can access which secrets.
- Log secret access where possible.
- Monitor for unexpected access patterns.
This is mainly handled through secret management tools and cloud platforms.
Environment Variables for Secrets
Environment variables are often the simplest, first step to get secrets out of code.
Basics
In a Unix shell:
export DATABASE_URL="postgresql://user:password@localhost:5432/app_db"
export JWT_SECRET="super-secret-key"In Python:
import os
DATABASE_URL = os.environ["DATABASE_URL"]
JWT_SECRET = os.environ["JWT_SECRET"]
If a variable is missing, os.environ["KEY"] raises a KeyError. You can use .get with a fallback, but for secrets you usually require them.
JWT_SECRET = os.environ.get("JWT_SECRET")
if not JWT_SECRET:
raise RuntimeError("JWT_SECRET not set")Using .env Files Locally
Often you create a local .env file that defines environment variables:
# .env (do NOT commit this)
DATABASE_URL=postgresql://user:password@localhost:5432/app_db
JWT_SECRET=local-dev-secret
Then you use a library like python-dotenv to load it:
pip install python-dotenv# main.py
from dotenv import load_dotenv
import os
load_dotenv() # reads from .env
DATABASE_URL = os.environ["DATABASE_URL"]
JWT_SECRET = os.environ["JWT_SECRET"]
Important: Ensure .env is ignored by Git:
.envYou can keep a non secret example file:
# .env.example (commit this)
DATABASE_URL=postgresql://user:password@localhost:5432/app_db
JWT_SECRET=change-meThis shows other developers which variables are required without exposing your real secrets.
Using Environment Variables in Docker
With Docker, you can pass secrets as environment variables.
Example docker-compose.yml:
services:
api:
image: my-api
environment:
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
The ${VAR} values are taken from your shell environment or a .env file next to docker-compose.yml.
Example .env file for Docker:
DATABASE_URL=postgresql://user:password@db:5432/prod_db
JWT_SECRET=my-super-secretAgain, do not commit the real secrets to version control.
Storing Secrets in Configuration Files
Sometimes you store secrets in files that are not committed to Git and are loaded at runtime.
Example: secrets.json
{
"database_url": "postgresql://user:password@localhost:5432/app_db",
"jwt_secret": "super-secret-key"
}Python code:
import json
from pathlib import Path
config_path = Path("secrets.json")
config = json.loads(config_path.read_text())
DATABASE_URL = config["database_url"]
JWT_SECRET = config["jwt_secret"]Security notes:
- Add
secrets.jsonto.gitignore. - Limit file permissions so that only the application user can read it:
- On Linux:
chmod 600 secrets.json.
This pattern is simple but best used for local development or small deployments. For production you usually want a dedicated secret management system.
Dedicated Secret Management Systems
As your application grows, you want centralized tools designed to handle secrets safely and at scale.
Common systems:
| Tool | Typical usage |
|---|---|
| HashiCorp Vault | Self‑hosted or managed, very flexible |
| AWS Secrets Manager | Secrets in AWS environments |
| AWS Systems Manager (SSM) | Parameter store, including encrypted parameters |
| GCP Secret Manager | Secrets in Google Cloud |
| Azure Key Vault | Secrets, keys, and certificates in Azure |
Typical Features
Most secret managers provide:
- Encrypted storage of secrets at rest.
- Access control who or what can read a secret.
- Audit logging of secret access.
- Automatic rotation for some types of secrets.
- Versioning of secrets.
- Integration with cloud IAM roles and services.
Example: Conceptual Flow with a Secret Manager
High level example using a generic secret manager:
- You store a secret with a name and value:
- Name:
prod/database/url - Value:
postgresql://user:pass@db:5432/prod_db - Your application authenticates to the secret manager, usually with a non secret identity, for example:
- Cloud instance role.
- Kubernetes service account.
- Short‑lived credentials.
- Your application fetches the secret at startup:
from my_secret_client import get_secret
DATABASE_URL = get_secret("prod/database/url")- The secret manager logs the access and returns the decrypted value.
The exact client library and configuration depend on the platform, but the core idea is always similar.
Example: Secrets with AWS (Conceptual)
You do not need AWS experience to understand the pattern, it is similar on other clouds.
Using AWS Secrets Manager (High Level)
- In AWS Secrets Manager you create a secret:
- Name:
prod/myapp/db-credentials - Value (JSON):
{
"username": "myapp",
"password": "very-secret",
"host": "db.example.com",
"port": 5432
}- You give your EC2 instance or ECS task a role that allows reading this secret.
- In your app code you use the AWS SDK:
import boto3
import json
client = boto3.client("secretsmanager")
response = client.get_secret_value(SecretId="prod/myapp/db-credentials")
secret_dict = json.loads(response["SecretString"])
DATABASE_URL = (
f"postgresql://{secret_dict['username']}:{secret_dict['password']}"
f"@{secret_dict['host']}:{secret_dict['port']}/myapp_db"
)- No passwords are stored in source code or environment variables. The instance role identifies your application to AWS.
This pattern is similar with other providers: you create a secret, attach permissions to your compute resources, and fetch the secret in code at runtime.
Managing Different Environments
You should manage secrets per environment in a consistent way.
Naming Conventions
Conventions make your setup easier to understand.
For environment variables:
# local .env
APP_ENV=local
DATABASE_URL=postgresql://user:pass@localhost:5432/local_db
JWT_SECRET=local-secretFor staging:
# staging secrets
APP_ENV=staging
DATABASE_URL=postgresql://user:pass@staging-db:5432/staging_db
JWT_SECRET=staging-secretIn code:
import os
APP_ENV = os.environ.get("APP_ENV", "local")
DATABASE_URL = os.environ["DATABASE_URL"]
For secret managers you can prefix names, for example staging/myapp/db-url and prod/myapp/db-url.
Secret Value Differences
Across environments you may:
- Use test API keys in local and staging and live API keys in production.
- Use different JWT secrets, so a token from staging cannot be used in production.
- Use separate databases.
This reduces the blast radius of mistakes.
Secret Rotation and Revocation
You need to be able to change secrets safely.
When to Rotate
You should rotate secrets when:
- You suspect a leak.
- A team member with access leaves the company.
- A secret is accidentally printed in logs.
- You are updating security policies.
- Periodically, for high sensitivity secrets.
Basic Manual Rotation Pattern
Example: Rotating a database password.
- Create a new password for the same database user.
- Update the secret storage (env var, secret manager, config file) with the new password.
- Restart or reload the application so it uses the new password.
- Test that the app can connect with the new password.
- Revoke the old password or remove any old users.
If you cannot change the password for the same user, you can:
- Create a new user with a new password.
- Configure your app to support multiple connection strings for a transition period.
Rotating JWT Signing Keys (Conceptual)
JWT signing keys are critical. If a JWT secret leaks, an attacker can create valid tokens.
Basic approach:
- Introduce a key ID
kidin your JWT header. - Keep a map of
kid→ signing key in your backend. - Add a new key with a new
kidand start signing new tokens with it. - Keep old keys for some time to verify existing tokens.
- After all old tokens have expired, remove the old keys.
The detailed implementation belongs to the authentication chapters, but you should see how secret rotation affects token handling.
Handling Secrets in Development
Developers still need secrets to run the app locally, but you must avoid leaks.
Using .env Files Per Developer
Typical pattern:
- Each developer has their own
.envfile with local settings. - There is a
.env.examplewith placeholder values.
Example .env.example:
APP_ENV=local
DATABASE_URL=postgresql://user:password@localhost:5432/app_db
JWT_SECRET=change-me
STRIPE_SECRET_KEY=sk_test_...A new developer:
cp .env.example .env
# then edit .env with real local valuesThis keeps the structure visible while not sharing real secrets in Git.
Using Safer Sharing Methods
If multiple developers need the same non production secrets, you can:
- Use a shared secret manager configured for development.
- Use an encrypted password manager shared vault.
- Use encrypted files under version control, for example:
secrets.encin Git, and a separate key out of band.
These are more advanced setups, but the idea is to avoid clear text secrets in your repository.
Avoiding Secret Leaks in Logs and Errors
Even if you store secrets correctly, you can leak them at runtime.
Do Not Log Secrets
Risky code:
print(f"Connecting to database: {DATABASE_URL}")
If DATABASE_URL contains the password, your logs now have it.
Better:
from urllib.parse import urlparse
def safe_db_url(url):
parsed = urlparse(url)
return f"{parsed.scheme}://{parsed.hostname}:{parsed.port}/{parsed.path.lstrip('/')}"
print(f"Connecting to database: {safe_db_url(DATABASE_URL)}")Or log only the database host or name, not credentials.
Be Careful with Error Messages
Avoid revealing secrets in stack traces or error pages.
Example of a bad pattern:
try:
connect_to_db(DATABASE_URL)
except Exception as exc:
raise RuntimeError(f"Failed to connect to {DATABASE_URL}") from excBetter:
try:
connect_to_db(DATABASE_URL)
except Exception as exc:
raise RuntimeError("Failed to connect to database") from excYour logs may still contain internal connection details, so avoid including secret values.
Scanning for Leaked Secrets
Even with good practices, accidents happen. Tools can help you detect secrets in your codebase.
Types of Secret Scanning
- Local pre‑commit hooks that scan your changes before you commit.
- CI tools that scan commits or pull requests.
- Full repository scans to find historic leaks.
Popular tools (conceptual, no need to learn them now):
git-secretstruffleHog- GitHub secret scanning
If you ever discover a secret that has been committed, act as if it is leaked:
- Remove it from the code.
- Rotate the secret.
- Invalidate the old value.
- Consider scanning your entire repository.
Practical Patterns for Small Projects
For small projects or early learning, use patterns that are simple but not careless.
Minimal Reasonable Setup
For a simple FastAPI project:
- Do not put secrets in
.pyfiles. - Create
.envand.env.example:
.env.example:
APP_ENV=local
DATABASE_URL=postgresql://user:password@localhost:5432/app_db
JWT_SECRET=change-me
.env (ignored by Git):
APP_ENV=local
DATABASE_URL=postgresql://user:localpass@localhost:5432/app_db
JWT_SECRET=really-long-random-string- Load
.envin your app:
from dotenv import load_dotenv
import os
load_dotenv()
APP_ENV = os.getenv("APP_ENV", "local")
DATABASE_URL = os.environ["DATABASE_URL"]
JWT_SECRET = os.environ["JWT_SECRET"]- When you deploy, set environment variables through:
- Your hosting provider dashboard.
- Docker Compose environment.
- Kubernetes secrets.
This gives you a clean separation between code and secrets, even on your first backend project.
Summary
- A secret is any value that must be kept confidential, for example passwords, API keys, tokens, and encryption keys.
- Never hard‑code secrets or commit them to version control.
Always externalize secrets, limit their scope, separate environments, and be ready to rotate and revoke them quickly.
- Use environment variables and non tracked files like
.envin development. - For production, prefer a secrets manager or secure environment variable management.
- Manage secrets separately for local, staging, and production.
- Avoid leaking secrets through logs and error messages.
- Use secret scanning tools to catch mistakes.
With these habits your backend will be far more resilient to common security failures that come from poor secrets management.
Views: 7
KAHIBARO