CWE-798: Use of Hard-coded Credentials - Python
Overview
A credential written into Python source is readable by everyone who can read the code, and cannot be changed without editing and redeploying the application. Never embed passwords, API keys, database credentials, or encryption keys in source code or in configuration files committed to version control.
Primary Defence: Use cloud or enterprise secrets managers for production credentials. Use environment variables with os.getenv() as deployment-time injection, and use python-dotenv only for local development with .env files excluded from version control. An environment variable is a reasonable way to inject a secret at process start and a poor place to store one - see CWE-526.
Rotate first, then refactor. A credential that has been committed is
compromised regardless of whether the repository is public: it is in git log,
in every clone, and in every wheel, sdist and container image built since -
including any .pyc that was packaged with it. Deleting the literal from HEAD
changes none of that. Revoke the value at the system that issued it before or
alongside the code change.
Common Vulnerable Patterns
Hard-coded Database Credentials
# VULNERABLE - Credentials in source code
import psycopg2
class DatabaseConnection:
DB_HOST = "localhost"
DB_NAME = "mydb"
DB_USER = "admin"
DB_PASSWORD = "P@ssw0rd123" # DANGEROUS!
def get_connection(self):
return psycopg2.connect(
host=self.DB_HOST,
database=self.DB_NAME,
user=self.DB_USER,
password=self.DB_PASSWORD
)
Why this is vulnerable: Hard-coded database passwords in Python source files are visible to anyone with repository access, persist in git history forever, are deployed to production servers where they can be extracted, and cannot be rotated without code changes and redeployment.
Hard-coded API Keys
# VULNERABLE - API key in code
import requests
class ApiClient:
API_KEY = "sk_live_51H7x8y9z10a11b12c" # DANGEROUS!
API_SECRET = "whsec_abcdef123456" # DANGEROUS!
def make_request(self):
headers = {
"Authorization": f"Bearer {self.API_KEY}"
}
response = requests.get("https://api.example.com/data", headers=headers)
return response.json()
Why this is vulnerable: API keys in Python code are exposed to all developers with repository access, remain in version control history permanently, enable unauthorized API usage and billing charges, and can be easily extracted from .pyc bytecode files or deployed applications.
Hard-coded Encryption Keys
# VULNERABLE - Encryption key in code
from cryptography.fernet import Fernet
class Encryptor:
SECRET_KEY = b'MySecretKey12345678901234567890=' # DANGEROUS!
def encrypt(self, data: str) -> bytes:
f = Fernet(self.SECRET_KEY)
return f.encrypt(data.encode())
Why this is vulnerable: Hard-coded encryption keys defeat encryption's purpose since anyone with code access can decrypt data, keys cannot be rotated without code changes, and compromised keys expose all historical encrypted data permanently.
Credentials in config.py (Committed to Git)
# VULNERABLE - config.py with real credentials
DATABASE_URL = "postgresql://admin:P@ssw0rd123@localhost/mydb"
AWS_ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE"
AWS_SECRET_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
STRIPE_API_KEY = "sk_live_51H7x8y9z10a11b12c"
Why this is vulnerable: Credentials in a config file committed to git are readable in every clone and fork, are often pushed to a public repository by accident, and stay in history after they are deleted from the current revision.
Default Login Credentials Compiled Into the Product
# VULNERABLE - the product accepts a credential that ships inside it
import hashlib
from flask import Flask, request
ADMIN_USERNAME = "admin"
ADMIN_PASSWORD = "admin123" # DANGEROUS - identical on every installation
# A fixed digest is still a fixed credential. The support password is the same
# everywhere, it is just written down less obviously.
SUPPORT_KEY_SHA256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
app = Flask(__name__)
@app.post("/login")
def login():
username = request.form["username"]
password = request.form["password"]
if username == ADMIN_USERNAME and password == ADMIN_PASSWORD:
return issue_session(username)
# Maintenance backdoor: any caller who knows the support key is an admin
if hashlib.sha256(password.encode()).hexdigest() == SUPPORT_KEY_SHA256:
return issue_session("support")
return {"error": "invalid credentials"}, 401
Why this is vulnerable: this is the inbound half of CWE-798, and it is a
different weakness from the patterns above. The value is not a secret the
application needs to hold, it is an authenticator the application should never
have accepted, so no secrets manager fixes it - reading admin123 from Vault
leaves the same credential working on every deployment. Anyone with a copy of
the product has the credential: ADMIN_PASSWORD is readable in the source, in
the wheel, and in the .pyc, and SUPPORT_KEY_SHA256 falls to a dictionary
attack in seconds because an unsalted SHA-256 of a memorable password is a
lookup, not a barrier. Grepping for assignment patterns will not find the
problem either: the defect is the == in the authentication path, not the
literal on its own.
Secure Patterns
Credentials the Product Accepts: Authenticate, Do Not Compare
Take this one first. It is the fix for Default Login Credentials Compiled Into
the Product above, and none of the secret-management patterns below address it -
a built-in administrator moved into AWS Secrets Manager is still a built-in
administrator.
# SECURE - no credential in this module, so none is shared between installations
import hashlib
import hmac
import os
import secrets
import sqlite3
from flask import Flask, request
SCRYPT_N = 2 ** 14
SCRYPT_R = 8
SCRYPT_P = 1
app = Flask(__name__)
def hash_password(password: str, salt: bytes | None = None) -> str:
# hashlib.scrypt takes the salt as an argument and neither generates nor
# embeds one, so the caller generates it and stores it with the digest.
if salt is None:
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(
password.encode(), salt=salt,
n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P, dklen=32,
)
return f"scrypt${SCRYPT_N}${SCRYPT_R}${SCRYPT_P}${salt.hex()}${digest.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
scheme, n, r, p, salt_hex, digest_hex = stored.split("$")
if scheme != "scrypt":
return False
expected = bytes.fromhex(digest_hex)
candidate = hashlib.scrypt(
password.encode(), salt=bytes.fromhex(salt_hex),
n=int(n), r=int(r), p=int(p), dklen=len(expected),
)
except ValueError:
return False
return hmac.compare_digest(candidate, expected)
# A hash of a value nobody knows, verified against when the account does not
# exist so the failure path costs roughly what a real verification costs.
DUMMY_HASH = hash_password(secrets.token_urlsafe(32))
def open_db() -> sqlite3.Connection:
return sqlite3.connect(os.environ["APP_DB"])
def enrolment_complete(conn: sqlite3.Connection) -> bool:
return conn.execute("SELECT count(*) FROM admin_users").fetchone()[0] > 0
def stored_hash(conn: sqlite3.Connection, username: str) -> str | None:
row = conn.execute(
"SELECT password_hash FROM users WHERE username = ?", (username,)
).fetchone()
return row[0] if row else None
@app.post("/login")
def login():
conn = open_db()
try:
# Refuse to serve until an administrator has been enrolled. A fresh
# install has no working credential rather than a well-known one.
if not enrolment_complete(conn):
return {"error": "setup incomplete"}, 503
username = request.form["username"]
password = request.form["password"]
stored = stored_hash(conn, username)
finally:
conn.close()
if stored is None:
verify_password(password, DUMMY_HASH) # same answer, comparable cost
return {"error": "invalid credentials"}, 401
if not verify_password(password, stored):
return {"error": "invalid credentials"}, 401
return issue_session(username)
# Where the product really must accept a fixed token - one generated at install
# time and held per deployment, never compiled in - compare it in constant time.
def valid_api_key(presented: str) -> bool:
expected = os.environ["DEPLOYMENT_API_KEY"]
return hmac.compare_digest(presented.encode(), expected.encode())
Why this works: there is no credential literal left in the module, so there
is nothing an attacker can read out of the source, the wheel or the .pyc and
replay against every other deployment. Each password is verified against a hash
stored per user rather than tested for equality against a constant, and
hash_password uses a fresh 16-byte salt per credential, so two installations
holding the same password hold different digests - the hashing rules are
CWE-916. hashlib.scrypt is the standard-library
option here and differs from the usual third-party wrappers in one way worth
knowing: it takes the salt as a parameter and returns a bare digest, so the salt
and parameters have to be stored by the caller, which is what the
scrypt$N$r$p$salt$digest string does. hmac.compare_digest compares the two
digests without an early exit; a plain == on a secret returns sooner the
earlier it finds a mismatched byte, which is CWE-208
territory, and the same call is what valid_api_key uses for a fixed token the
product genuinely must accept.
enrolment_complete is what stops the default coming back. A build that returns
503 until an administrator exists cannot ship with a working admin/admin123,
whereas a default that merely logs a warning at startup stays in place - the
installation keeps serving, so nobody has to act on the warning. Enrol the first
administrator through a setup flow that runs once and generates a credential
unique to the installation, and treat any instance already running the old build
as compromised: removing the literal in the next release does nothing for a
deployment running the current one.
Environment Variables with os.environ
# SECURE - Read from environment variables
import os
import psycopg2
class DatabaseConnection:
def __init__(self):
self.db_host = os.environ.get("DB_HOST")
self.db_name = os.environ.get("DB_NAME")
self.db_user = os.environ.get("DB_USER")
self.db_password = os.environ.get("DB_PASSWORD")
if not all([self.db_host, self.db_name, self.db_user, self.db_password]):
raise ValueError("Database credentials not configured")
def get_connection(self):
return psycopg2.connect(
host=self.db_host,
database=self.db_name,
user=self.db_user,
password=self.db_password
)
# Set environment variables:
# export DB_HOST=localhost
# export DB_NAME=mydb
# export DB_USER=admin
# export DB_PASSWORD=SecurePassword123
Why this works: os.environ.get() retrieves credentials from environment variables set at the OS/container/platform level, keeping them out of source code. The validation ensures the application fails fast if credentials are missing (raising ValueError). In production, inject environment variables from a managed secret source and protect them from process dumps, debug pages, platform metadata, and logs - see CWE-526. Rotation usually requires updating the provider and restarting or refreshing the application.
python-dotenv (.env files)
# SECURE - Use python-dotenv for environment variables
import os
from dotenv import load_dotenv
import requests
# Load environment variables from .env file
load_dotenv()
class ApiClient:
def __init__(self):
self.api_key = os.getenv("API_KEY")
self.api_secret = os.getenv("API_SECRET")
if not self.api_key or not self.api_secret:
raise ValueError("API credentials not configured")
def make_request(self):
headers = {
"Authorization": f"Bearer {self.api_key}"
}
response = requests.get("https://api.example.com/data", headers=headers)
return response.json()
# .env file (NOT committed to version control):
"""
API_KEY=sk_live_51H7x8y9z10a11b12c
API_SECRET=whsec_abcdef123456
DB_PASSWORD=SecurePassword123
"""
# Install: pip install python-dotenv
Why this works: python-dotenv loads environment variables from .env files for local development convenience. The .env file is excluded from version control via .gitignore, preventing secrets from entering Git history. load_dotenv() populates the process environment when the module runs, so os.getenv() returns a value that was never in the source. The raise ValueError fails fast when either credential is missing. Different .env files can be used per environment without modifying code.
AWS Secrets Manager
# SECURE - AWS Secrets Manager
import boto3
import json
from botocore.exceptions import ClientError
class SecretsManager:
def __init__(self):
self.client = boto3.client('secretsmanager')
def get_database_credentials(self):
secret_name = "prod/database/credentials"
try:
response = self.client.get_secret_value(SecretId=secret_name)
except ClientError as e:
raise Exception(f"Failed to retrieve secret: {e}")
# Parse the secret
secret = json.loads(response['SecretString'])
return {
'host': secret['host'],
'database': secret['database'],
'username': secret['username'],
'password': secret['password']
}
def get_connection(self):
creds = self.get_database_credentials()
import psycopg2
return psycopg2.connect(
host=creds['host'],
database=creds['database'],
user=creds['username'],
password=creds['password']
)
# Install: pip install boto3
# AWS credentials from IAM role, workload identity, profile, or environment
# Prefer IAM roles/workload identity in production instead of long-lived access keys
# export AWS_DEFAULT_REGION=us-east-1
Why this works: AWS Secrets Manager provides centralized secret storage with KMS encryption, rotation support, and IAM-based access control. The boto3 client uses credentials from the AWS provider chain, preferably IAM roles or workload identity in production, so application credentials are not embedded in code. Secrets are retrieved at runtime as needed. CloudTrail logs access for auditing. Secret versioning supports gradual rollout of rotated credentials. The JSON structure allows storing related credentials together.
HashiCorp Vault
# SECURE - HashiCorp Vault integration
import hvac
import os
class VaultClient:
def __init__(self):
vault_url = os.getenv("VAULT_ADDR", "https://vault.example.com:8200")
vault_token = os.getenv("VAULT_TOKEN")
if not vault_token:
raise ValueError("VAULT_TOKEN not configured")
self.client = hvac.Client(url=vault_url, token=vault_token)
if not self.client.is_authenticated():
raise Exception("Failed to authenticate with Vault")
def get_database_password(self):
# path is relative to the mount, not the HTTP path: hvac adds the
# "secret/" mount and the "data/" segment itself. Passing the full
# "secret/data/database" here reads secret/data/secret/data/database.
response = self.client.secrets.kv.v2.read_secret_version(
path="database", raise_on_deleted_version=True
)
# KV v2 nests the payload: response["data"]["data"] is the secret.
return response['data']['data']['password']
def get_api_key(self):
response = self.client.secrets.kv.v2.read_secret_version(
path="api", raise_on_deleted_version=True
)
return response['data']['data']['api_key']
# Install: pip install hvac
Why this works: HashiCorp Vault provides secret management with dynamic secrets, lease management, and fine-grained access policies. Prefer workload identity, Kubernetes auth, cloud IAM auth, or AppRole in production rather than a long-lived token in an environment variable. Vault supports secret versioning, rotation workflows, detailed audit logs, and encryption as a service. The KV v2 API retrieves secrets on demand, and Vault's dynamic secrets can generate database credentials that expire automatically.
Framework-Specific Guidance
Django
# SECURE - Django with environment variables
# settings.py
import os
from pathlib import Path
# Read from environment variables
SECRET_KEY = os.environ['DJANGO_SECRET_KEY'] # KeyError at import, not a None
# that Django reports much later
DEBUG = os.environ.get('DEBUG', 'False') == 'True'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME'),
'USER': os.environ.get('DB_USER'),
'PASSWORD': os.environ.get('DB_PASSWORD'),
'HOST': os.environ.get('DB_HOST', 'localhost'),
'PORT': os.environ.get('DB_PORT', '5432'),
}
}
# Email configuration
EMAIL_HOST_USER = os.environ.get('EMAIL_USER')
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_PASSWORD')
# AWS S3 settings
AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY')
AWS_STORAGE_BUCKET_NAME = os.environ.get('AWS_STORAGE_BUCKET_NAME')
# Alternative: Use django-environ
from environ import Env
env = Env()
env.read_env() # Reads .env file
SECRET_KEY = env('DJANGO_SECRET_KEY')
DEBUG = env.bool('DEBUG', default=False)
DATABASES = {
'default': env.db() # Reads DATABASE_URL
}
# Install: pip install django-environ
Flask
# SECURE - Flask with environment variables
# config.py
import os
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY')
if not SECRET_KEY:
raise RuntimeError('SECRET_KEY must be configured')
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
# API keys
STRIPE_API_KEY = os.environ.get('STRIPE_API_KEY')
SENDGRID_API_KEY = os.environ.get('SENDGRID_API_KEY')
# AWS
AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY')
class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL')
class ProductionConfig(Config):
DEBUG = False
# app.py
from flask import Flask
from dotenv import load_dotenv
load_dotenv()
app = Flask(__name__)
app.config.from_object('config.ProductionConfig')
# Access configuration
stripe_key = app.config['STRIPE_API_KEY']
FastAPI
# SECURE - FastAPI with Pydantic Settings
# config.py
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# Database
db_host: str = Field(..., validation_alias='DB_HOST')
db_name: str = Field(..., validation_alias='DB_NAME')
db_user: str = Field(..., validation_alias='DB_USER')
db_password: str = Field(..., validation_alias='DB_PASSWORD')
# API Keys
api_key: str = Field(..., validation_alias='API_KEY')
api_secret: str = Field(..., validation_alias='API_SECRET')
# JWT
jwt_secret: str = Field(..., validation_alias='JWT_SECRET')
model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8')
# main.py
from fastapi import FastAPI, Depends
from functools import lru_cache
app = FastAPI()
@lru_cache()
def get_settings():
return Settings()
@app.get("/")
async def root(settings: Settings = Depends(get_settings)):
# Use settings.api_key, etc.
return {"status": "ok"}
# Install: pip install pydantic-settings
SQLAlchemy
# SECURE - SQLAlchemy with environment variables
from sqlalchemy import create_engine
from sqlalchemy.engine import URL
from sqlalchemy.orm import sessionmaker
import os
class Database:
def __init__(self):
# Build connection string from environment variables
db_user = os.environ.get('DB_USER')
db_password = os.environ.get('DB_PASSWORD')
db_host = os.environ.get('DB_HOST', 'localhost')
db_port = int(os.environ.get('DB_PORT', '5432'))
db_name = os.environ.get('DB_NAME')
if not all([db_user, db_password, db_name]):
raise ValueError("Database credentials not configured")
# URL.create escapes each component. An f-string does not: a password
# containing @ / or : silently reparses into a different host and
# database - see the note below.
database_url = URL.create(
"postgresql",
username=db_user,
password=db_password,
host=db_host,
port=db_port,
database=db_name,
)
# Or use DATABASE_URL directly, already percent-encoded by whoever set it:
# database_url = os.environ.get('DATABASE_URL')
self.engine = create_engine(database_url, echo=False)
self.SessionLocal = sessionmaker(bind=self.engine)
def get_session(self):
return self.SessionLocal()
Why this works: the credentials arrive from the environment rather than from
a literal, and URL.create keeps each of them a separate field instead of
concatenating them into a string that has to be reparsed.
That second half is where this fix usually breaks, because a hard-coded password
was one somebody typed and a stored one is whatever the generator emitted.
Measured on SQLAlchemy 2.0, an f-string URL with the password p@ss/w0rd parses
to username app_user, password p, host ss and database
w0rd@db.example.com:5432/prod - it connects to the wrong place with the wrong
credential and raises nothing at the point of the mistake. a:b@c mis-parses
the same way. Both round-trip correctly through URL.create. Where a URL
string is unavoidable, the equivalent fix is urllib.parse.quote(value,
safe='') on each component - not quote_plus, which encodes a space as
+ while the URL parser unquotes %20 and leaves + alone, so a password
with a space in it arrives at the database with a literal + where the space
was. create_engine resolves the driver but does not connect, so the first
symptom of a mangled URL is a confusing connection error at the first query
rather than anything at startup.
Encryption Keys Management
# SECURE - Key management with AWS KMS
import boto3
import base64
class EncryptionService:
def __init__(self):
self.kms_client = boto3.client('kms')
self.key_id = os.environ.get('KMS_KEY_ID')
if not self.key_id:
raise ValueError("KMS_KEY_ID not configured")
def encrypt(self, plaintext: str) -> str:
response = self.kms_client.encrypt(
KeyId=self.key_id,
Plaintext=plaintext.encode()
)
# Return base64-encoded ciphertext
return base64.b64encode(response['CiphertextBlob']).decode()
def decrypt(self, ciphertext: str) -> str:
ciphertext_blob = base64.b64decode(ciphertext)
response = self.kms_client.decrypt(
CiphertextBlob=ciphertext_blob
)
return response['Plaintext'].decode()
# Alternative: Use Fernet with key from environment
from cryptography.fernet import Fernet
class FernetEncryption:
def __init__(self):
# Key should be in environment variable
key = os.environ.get('ENCRYPTION_KEY')
if not key:
raise ValueError("ENCRYPTION_KEY not configured")
self.cipher = Fernet(key.encode())
def encrypt(self, data: str) -> bytes:
return self.cipher.encrypt(data.encode())
def decrypt(self, token: bytes) -> str:
return self.cipher.decrypt(token).decode()
# Generate key once: Fernet.generate_key()
# Store in environment: export ENCRYPTION_KEY=generated_key
Testing with Test Credentials
# SECURE - Use test credentials for unit tests
import pytest
import os
from unittest.mock import patch
# The classes under test, from the Secure Patterns sections above
from config import ApiClient, DatabaseConnection
class TestDatabaseConnection:
@pytest.fixture
def mock_env_vars(self, monkeypatch):
"""Provide test credentials via environment variables"""
monkeypatch.setenv("DB_HOST", "localhost")
monkeypatch.setenv("DB_NAME", "test_db")
monkeypatch.setenv("DB_USER", "test_user")
monkeypatch.setenv("DB_PASSWORD", "test_password")
def test_connection(self, mock_env_vars):
"""Test database connection with test credentials"""
db = DatabaseConnection()
assert db.db_host == "localhost"
assert db.db_user == "test_user"
@patch.dict(os.environ, {
"API_KEY": "test_api_key",
"API_SECRET": "test_secret"
})
def test_api_client(self):
"""Test API client with mocked credentials"""
client = ApiClient()
assert client.api_key == "test_api_key"
# Using testcontainers for integration tests
# Note the module: testcontainers.postgres is deprecated in favour of
# testcontainers.community.postgres, and the container's credential
# attributes are username / password / dbname - the older
# POSTGRES_USER / POSTGRES_DB / POSTGRES_PASSWORD names were removed.
from testcontainers.community.postgres import PostgresContainer
import psycopg2
def test_with_postgres_container():
with PostgresContainer("postgres:16") as postgres:
# Container supplies its own throwaway credentials
conn = psycopg2.connect(
host=postgres.get_container_host_ip(),
port=postgres.get_exposed_port(5432),
dbname=postgres.dbname,
user=postgres.username,
password=postgres.password
)
# Test database operations
cursor = conn.cursor()
cursor.execute("SELECT 1")
assert cursor.fetchone()[0] == 1
# Or let the container build the URL for you, which is what SQLAlchemy wants:
# engine = create_engine(postgres.get_connection_url())
# Install: pip install pytest testcontainers
.gitignore Best Practices
# Add these patterns to .gitignore
# Environment files
.env
.env.local
.env.*.local
# Configuration files with secrets
config/secrets.py
config/local_settings.py
# Django
local_settings.py
db.sqlite3
# Flask
instance/
# Credentials
*.pem
*.key
credentials.json
service-account.json
# IDE
.vscode/
.idea/
*.swp
Creating .env.example Template
# .env.example (committed to version control)
# Copy this to .env and fill in real values
# Database
DB_HOST=localhost
DB_NAME=mydb
DB_USER=admin
DB_PASSWORD=your_password_here
# API Keys
API_KEY=your_api_key_here
API_SECRET=your_api_secret_here
# AWS
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
# Django
DJANGO_SECRET_KEY=your_secret_key_here
DEBUG=False
Detecting Hard-coded Secrets
Using detect-secrets
# Install detect-secrets
pip install detect-secrets
# Scan repository
detect-secrets scan > .secrets.baseline
# Audit findings
detect-secrets audit .secrets.baseline
# Add pre-commit hook
# .pre-commit-config.yaml:
repos:
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
Using TruffleHog
# Install TruffleHog. It is a Go binary - the PyPI "truffleHog" package is
# the abandoned v2 from 2018 and does not accept the flags below.
brew install trufflehog
# or: curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin
# Scan a working tree
trufflehog filesystem .
# Scan Git history, reporting only secrets it could verify are live
trufflehog git file:///path/to/repo --results=verified
Kubernetes Secrets
For applications running in Kubernetes, read secrets mounted as files or injected as environment variables rather than baking them into the container image:
import os
def get_secret_from_volume(secret_name: str, mount_path: str = "/etc/secrets") -> str:
"""Read a secret mounted as a volume (Kubernetes Secret -> volumeMount)."""
secret_file = os.path.join(mount_path, secret_name)
try:
with open(secret_file, 'r') as f:
return f.read().strip()
except FileNotFoundError:
raise Exception(f"Secret '{secret_name}' not found at {secret_file}")
# Usage - volume-mounted secrets (recommended for larger secrets)
db_password = get_secret_from_volume("password", mount_path="/etc/secrets/db")
# Environment-variable secrets (Kubernetes secretKeyRef) are simpler for small values
# and can be read with os.getenv() - see the Environment Variables pattern above.
For syncing secrets from AWS/Azure/GCP directly into Kubernetes Secrets, consider the External Secrets Operator rather than hand-rolling a sync process.
Best Practices for Cloud Secrets Management
- Use IAM roles/service accounts, not access keys: in AWS, attach an IAM role to the EC2/ECS/Lambda execution environment with
secretsmanager:GetSecretValue; in Kubernetes, use IRSA (IAM Roles for Service Accounts). Never hard-code AWS access keys to reach Secrets Manager. - Enable automatic rotation (AWS Secrets Manager supports Lambda-based rotation) and make sure the application's secret cache TTL is shorter than the rotation interval, so it picks up new values without a restart.
- Audit secret access: enable CloudTrail for Secrets Manager API calls and alert on
GetSecretValuefrom unexpected sources. - Separate secrets by environment (
prod/database/credentials,staging/database/credentials,dev/database/credentials) and use IAM policies to prevent cross-environment access. - Cache secrets briefly (15-60 minutes, depending on rotation frequency) using
functools.lru_cacheor theaws-secretsmanager-cachinglibrary to reduce API calls without holding stale credentials indefinitely.
Common Pitfalls
- Replacing a hard-coded password in
config.pywithos.environ.get('DB_PASSWORD'), but committing a.envfile with the real value becausepython-dotenvneeds it to run locally and the file was never added to.gitignore- the code change is correct but the secret is still in version control. - Setting the real production database password directly in a committed
docker-compose.ymlor Kubernetes manifestenv:block instead of asecretKeyRef/injected value -os.environ.get()reads it correctly at runtime, but the plaintext value is still checked into the repository that defines the deployment. - Using AWS Secrets Manager or Vault for the main Django/Flask app while a separate management command, Celery task, or one-off migration script in the same repository still imports a hard-coded
config.pywith real credentials - the migration to a secrets manager is incomplete if any code path still reads the old hard-coded source. - Caching a secret fetched from Secrets Manager in a module-level global with no TTL, so a rotated credential in the secrets manager never actually reaches the running process until it is restarted - the secret store supports rotation, but the caching layer silently defeats it.