Skip to content

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 GetSecretValue from 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_cache or the aws-secretsmanager-caching library to reduce API calls without holding stale credentials indefinitely.

Common Pitfalls

  • Replacing a hard-coded password in config.py with os.environ.get('DB_PASSWORD'), but committing a .env file with the real value because python-dotenv needs 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.yml or Kubernetes manifest env: block instead of a secretKeyRef/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.py with 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.

Additional Resources