| name | python-security |
| description | Use when creating Python projects with Docker, handling environment variables, managing secrets, or setting up secure deployments. Trigger: when writing Dockerfiles, docker-compose files, .env files, or configuring database connections and API keys. |
Python Security Best Practices
Overview
Core Principle: Secrets NEVER go into git. Not in Dockerfile, not in docker-compose.yml, not in any committed file. Environment variables are for runtime, not build time.
This skill covers: environment variables (python-dotenv), UV package manager, Docker security, .gitignore, logging security, input validation, secrets rotation, and production secrets management.
Red Flags - STOP Immediately
- Hardcoding passwords, API keys, or tokens in ANY file that goes to git
- "It's just for development" excuses
- Putting secrets in Dockerfile
ENV or ARG
- Passwords in docker-compose.yml under
environment:
- Logging sensitive data "for debugging"
- "I'll fix it later" when it comes to secrets
All of these mean: STOP. Delete the secrets. Start over with proper patterns.
Quick Reference
| Category | DO | DON'T |
|---|
| Environment | .env in .gitignore, load with python-dotenv | Commit .env, hardcode in Python |
| Docker | Pass secrets at runtime via env vars | Put secrets in Dockerfile ENV/ARG |
| Compose | ${VAR:-default} syntax, .env file | Hardcode passwords in environment: |
| Git | Comprehensive .gitignore | Assume "I won't accidentally commit" |
| Logging | Mask/omit secrets, use structured logging | Log raw os.environ, passwords, tokens |
| UV | Commit uv.lock, use --frozen in CI | Ignore lock file, floating versions |
| Production | Secrets manager (Vault, AWS, etc.) | Files on disk, environment in images |
1. Environment Variables with python-dotenv
Basic Pattern
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
openai_api_key: str
secret_key: str
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
.env Files
DATABASE_URL=postgresql://<user>:<password>@<host>:<port>/<database>
OPENAI_API_KEY=<your-openai-api-key>
SECRET_KEY=<your-secret-key-min-32-chars>
DATABASE_URL=postgresql://realuser:realpass@prod-db.example.com/proddb
OPENAI_API_KEY=sk-prod-abc123xyz789
SECRET_KEY=super-secret-production-key-42
Critical: .env.example shows STRUCTURE, not real values. Use obvious placeholders.
2. UV Best Practices
Project Setup
uv init
uv add fastapi uvicorn pydantic-settings
uv lock
git add uv.lock
Docker with UV
FROM python:3.12-slim
# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
# Install dependencies FIRST (cached layer)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
# Copy and install project
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# Non-root user
RUN useradd --create-home --shell /bin/bash app
USER app
ENV PATH="/app/.venv/bin:$PATH"
CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]
CI/CD with UV
uv sync --frozen
uv run --frozen python -m pytest
Key: --locked fails if uv.lock needs update. --frozen prevents network access. Use in CI.
3. Docker Without Secrets
The Rules
- NO secrets in Dockerfile -
ENV API_KEY=xxx is forever in image layers
- NO secrets in build args -
ARG PASSWORD is visible in docker history
- NO secrets in docker-compose environment hardcoded - That file goes to git
Correct Pattern: Runtime Injection
services:
app:
build: .
environment:
- DATABASE_URL=${DATABASE_URL}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- SECRET_KEY=${SECRET_KEY}
env_file:
- .env
db:
image: postgres:15
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-appdb}
.dockerignore
# .dockerignore - Prevent secrets from entering build context
.env
.env.*
*.pem
*.key
credentials.json
secrets.*
.git
.gitignore
__pycache__
.venv
*.pyc
.pytest_cache
.ruff_cache
4. Robust .gitignore
# Secrets - NEVER commit these
.env
.env.local
.env.*.local
*.pem
*.key
*.crt
credentials.json
secrets.*
secrets.yaml
secrets.yml
service-account.json
*.p12
*.pfx
# Python
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
*.so
.Python
build/
dist/
*.egg-info/
# IDE
.vscode/
.idea/
*.swp
*.swo
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
# UV - IMPORTANT: uv.lock SHOULD be committed!
# Only ignore the cache
.uv/
# OS
.DS_Store
Thumbs.db
Note: uv.lock MUST be committed. It ensures reproducible builds.
5. Secure Logging
Wrong - Leaks Secrets
import logging
logging.info(f"Config: {settings.dict()}")
import os
logging.info(f"Environment: {dict(os.environ)}")
logging.info(f"Connecting to: {settings.database_url}")
Correct - Masked Logging
import logging
from pydantic_settings import BaseSettings
from pydantic import field_validator
class Settings(BaseSettings):
database_url: str
openai_api_key: str
secret_key: str
@field_validator('database_url')
@classmethod
def mask_password(cls, v: str) -> str:
import re
return re.sub(r'://([^:]+):([^@]+)@', r'://\1:****@', v)
def safe_dict(self) -> dict:
"""Returns dict with secrets masked"""
return {
"database_url": self.mask_password(self.database_url),
"openai_api_key": f"{self.openai_api_key[:8]}...",
"secret_key": "***MASKED***"
}
settings = Settings()
logging.info(f"Config loaded: {settings.safe_dict()}")
Structured Logging Pattern
import logging
import structlog
logger = structlog.get_logger()
logger.info("database_connected", host=db_host, port=db_port)
logger.info("api_request", endpoint="/users", method="GET")
6. Input Validation
Basic Validation with Pydantic
from pydantic import BaseModel, field_validator, HttpUrl
import re
class UserInput(BaseModel):
username: str
email: str
file_path: str | None = None
@field_validator('username')
@classmethod
def validate_username(cls, v: str) -> str:
if not re.match(r'^[a-zA-Z0-9_]{3,20}$', v):
raise ValueError('Username must be 3-20 alphanumeric chars')
return v
@field_validator('email')
@classmethod
def validate_email(cls, v: str) -> str:
if '@' not in v:
raise ValueError('Invalid email format')
return v.lower()
@field_validator('file_path')
@classmethod
def prevent_path_traversal(cls, v: str | None) -> str | None:
if v is None:
return v
if '..' in v or v.startswith('/'):
raise ValueError('Path traversal detected')
return v
SQL Injection Prevention
cursor.execute(f"SELECT * FROM users WHERE id = {user_input}")
cursor.execute("SELECT * FROM users WHERE id = %s", (user_input,))
from sqlalchemy import text
result = db.execute(text("SELECT * FROM users WHERE id = :id"), {"id": user_input})
Command Injection Prevention
import shlex
import subprocess
subprocess.run(f"convert {filename} output.png", shell=True)
subprocess.run(["convert", filename, "output.png"], check=True)
subprocess.run(f"convert {shlex.quote(filename)} output.png", shell=True, check=True)
7. Secrets Rotation Strategy
Design for Rotation
class Settings(BaseSettings):
openai_api_key: str
openai_api_key_backup: str | None = None
def get_api_key(self) -> str:
"""Returns active key, supports rotation"""
return self.openai_api_key
def rotate_key(self, new_key: str) -> None:
"""Rotate API key without downtime"""
self.openai_api_key_backup = self.openai_api_key
self.openai_api_key = new_key
Rotation Checklist
- Generate new secret in secrets manager
- Deploy with new secret (old still valid)
- Verify new secret works
- Revoke old secret after grace period
- Monitor for failures - catch services still using old secret
Database Password Rotation
database_url: postgresql://app:password@db/app
database_url_readonly: postgresql://app_ro:ro_password@db/app
8. Production Secrets Management
Never Use .env in Production
from dotenv import load_dotenv
load_dotenv()
import os
api_key = os.environ.get("OPENAI_API_KEY")
AWS Secrets Manager
import boto3
import json
def get_secret(secret_name: str, region: str = "us-east-1") -> dict:
client = boto3.client("secretsmanager", region_name=region)
response = client.get_secret_value(SecretId=secret_name)
return json.loads(response["SecretString"])
secrets = get_secret("prod/myapp/database")
database_url = secrets["database_url"]
HashiCorp Vault
import hvac
client = hvac.Client(url="https://vault.example.com")
client.auth.approle.login(
role_id=os.environ["VAULT_ROLE_ID"],
secret_id=os.environ["VAULT_SECRET_ID"]
)
secret = client.secrets.kv.v2.read_secret_version(path="myapp/database")
database_url = secret["data"]["data"]["database_url"]
Environment Variable Pattern (Kubernetes/GitLab CI/etc.)
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
stringData:
DATABASE_URL: "postgresql://..."
OPENAI_API_KEY: "sk-..."
---
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: app
envFrom:
- secretRef:
name: app-secrets
Common Rationalizations (And Why They're Wrong)
| Excuse | Reality |
|---|
| "It's just a demo" | Demos become prototypes become production. Start right. |
| "I'll fix it later" | Later never comes. The commit with the password is forever. |
| "It's only in docker-compose" | docker-compose.yml goes to git. Password is in history forever. |
| "The repo is private" | Private repos get cloned, leaked, sold. Treat all repos as public. |
| "It's for local dev only" | Dev environments have real data. Local databases get production dumps. |
| "The password is in .env" | Great! But you also put it in docker-compose environment section. |
| "I'll use .env.example" | .env.example with real password patterns teaches bad habits. |
| "It's just a test API key" | Test keys become prod keys. Habits compound. |
Checklist Before Any Commit
git diff --cached | grep -i -E "(password|secret|api_key|token)"
git status --porcelain | grep -E "\.env|\.pem|\.key|credentials"
grep -E "POSTGRES_PASSWORD|MYSQL_ROOT_PASSWORD|API_KEY" docker-compose.yml
grep -E "ENV.*=.*[" dockerfile | grep -v "ENV PATH\|ENV PYTHON"
TL;DR
- Never commit secrets -
.env in .gitignore, templates in .env.example
- Runtime injection only - Docker gets secrets from env vars, not from files
- UV lock file - Commit
uv.lock, use --frozen in CI
- Mask in logs - Never log raw secrets, passwords, tokens
- Validate all inputs - Pydantic, parameterized queries, no shell=True
- Design for rotation - Support multiple keys, grace periods
- Production: secrets manager - AWS, Vault, Kubernetes secrets - not files