Chapter 55: Security Best Practices
Security isn't a feature you add later. It's a habit you build from the start.
Most data breaches aren't caused by sophisticated hackers cracking encryption. They're caused by simple mistakes: hardcoded passwords, unsanitised inputs, weak hashing, outdated dependencies with known vulnerabilities. The good news — almost all of them are preventable with a handful of consistent practices.
This chapter covers the security mistakes Python developers make most often and exactly how to fix them.
Mistake 1: Hardcoding Secrets
This is the most common mistake. It shows up in code reviews, GitHub searches, and breach reports every day.
# BAD — never do this
API_KEY = "sk-abc123xyz987"
DB_URL = "postgresql://admin:hunter2@prod.db.example.com/mydb"
SECRET_KEY = "mysecretkey"
# BAD — even in comments
# Default password: admin123
The fix: environment variables and .env files (Chapter 52).
import os
from dotenv import load_dotenv
load_dotenv()
API_KEY = os.environ["API_KEY"] # raises KeyError if missing — good
DB_URL = os.environ["DATABASE_URL"]
SECRET_KEY = os.environ["SECRET_KEY"]
And add .env to .gitignore — always.
Scan your repo for accidental commits:
pip install detect-secrets
detect-secrets scan > .secrets.baseline
detect-secrets audit .secrets.baseline
Mistake 2: SQL Injection
SQL injection is the most damaging web vulnerability. It lets attackers read, modify, or delete your entire database — or run commands on your server.
import sqlite3
# CATASTROPHICALLY BAD — never do this
user_input = "' OR '1'='1" # attacker input
conn = sqlite3.connect("users.db")
query = f"SELECT * FROM users WHERE username = '{user_input}'"
# Actual query: SELECT * FROM users WHERE username = '' OR '1'='1'
# Returns ALL users
rows = conn.execute(query).fetchall()
The fix: always use parameterised queries:
# CORRECT — parameterised queries
def get_user(username: str) -> dict | None:
conn = sqlite3.connect("users.db")
row = conn.execute(
"SELECT * FROM users WHERE username = ?",
(username,) # the database driver escapes this safely
).fetchone()
return dict(row) if row else None
# SQLAlchemy ORM — also safe by default
from sqlalchemy.orm import Session
def get_user_orm(db: Session, username: str):
return db.query(User).filter(User.username == username).first()
# SQLAlchemy uses parameterised queries automatically
Never concatenate user input into SQL strings. Ever. The ? placeholder (SQLite) or %s (PostgreSQL/psycopg2) is always correct.
Mistake 3: Storing Passwords Incorrectly
Never store plain-text passwords. Never use MD5 or SHA-256 directly for passwords — they're too fast; attackers can try billions of passwords per second.
Use a password hashing algorithm specifically designed to be slow: bcrypt, Argon2, or scrypt.
from passlib.context import CryptContext
# Configure — bcrypt is the safe default
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
"""Hash a password. Store the result, never the plain text."""
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
"""Return True if plain matches the hashed password."""
return pwd_context.verify(plain, hashed)
# Usage
hashed = hash_password("MySecretPassword1!")
print(hashed) # $2b$12$... (bcrypt hash)
print(verify_password("MySecretPassword1!", hashed)) # True
print(verify_password("wrongpassword", hashed)) # False
bcrypt is intentionally slow (it takes ~100ms to hash). That makes brute-force attacks impractical — an attacker can only try ~10 passwords per second instead of billions.
Password strength requirements
import re
def check_password_strength(password: str) -> list[str]:
"""Return a list of unmet requirements (empty = strong password)."""
issues = []
if len(password) < 12:
issues.append("At least 12 characters required")
if not re.search(r"[A-Z]", password):
issues.append("At least one uppercase letter required")
if not re.search(r"[a-z]", password):
issues.append("At least one lowercase letter required")
if not re.search(r"\d", password):
issues.append("At least one digit required")
if not re.search(r"[!@#$%^&*()\-_=+\[\]{}|;:,.<>?]", password):
issues.append("At least one special character required")
return issues
issues = check_password_strength("abc")
if issues:
print("Password too weak:")
for issue in issues:
print(f" - {issue}")
Mistake 4: Not Validating Input
Never trust user input. Validate everything that comes from outside your system: HTTP requests, file uploads, command-line arguments, database reads from external sources.
from pydantic import BaseModel, Field, field_validator
import re
class UserRegistration(BaseModel):
username: str = Field(..., min_length=3, max_length=30)
email: str = Field(...)
password: str = Field(..., min_length=12)
age: int = Field(..., ge=13, le=120)
@field_validator("username")
@classmethod
def username_safe(cls, v: str) -> str:
if not re.match(r"^[a-zA-Z0-9_.-]+$", v):
raise ValueError("Username can only contain letters, numbers, _, ., -")
return v.lower()
@field_validator("email")
@classmethod
def email_valid(cls, v: str) -> str:
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", v):
raise ValueError("Invalid email address")
return v.lower()
@field_validator("password")
@classmethod
def password_strong(cls, v: str) -> str:
issues = check_password_strength(v)
if issues:
raise ValueError("; ".join(issues))
return v
FastAPI uses Pydantic automatically — invalid requests return a 422 with a clear error message before your code even runs.
Sanitising HTML output
If you render user input in HTML, you must escape it to prevent Cross-Site Scripting (XSS):
import html
user_input = '<script>alert("XSS")</script>'
# BAD — renders the script
template = f"<p>Welcome, {user_input}!</p>"
# GOOD — escapes HTML entities
safe = html.escape(user_input)
template = f"<p>Welcome, {safe}!</p>"
# -> <p>Welcome, <script>alert("XSS")</script>!</p>
In Jinja2 templates (Flask/FastAPI), {{ variable }} auto-escapes by default. Only {{ variable | safe }} disables escaping — never use | safe on user input.
Mistake 5: Weak or No Authentication
Use strong secret keys
import secrets
# Generate a cryptographically secure secret key
secret_key = secrets.token_hex(32) # 64-char hex string
# b3a2f1... <- use this as your SECRET_KEY
# Generate a secure random token (for password reset, email verification)
token = secrets.token_urlsafe(32) # URL-safe base64
# Secure comparison (prevents timing attacks)
if secrets.compare_digest(provided_token, stored_token):
grant_access()
Never use random for security. random is predictable — it's for games and simulations, not tokens or keys. Always use secrets.
Secure JWT configuration
from datetime import datetime, timedelta
from jose import jwt, JWTError
SECRET_KEY = "use-a-long-random-value-from-secrets.token_hex(32)"
ALGORITHM = "HS256"
def create_token(user_id: int, expires_in: timedelta = timedelta(hours=1)) -> str:
payload = {
"sub": str(user_id),
"iat": datetime.utcnow(),
"exp": datetime.utcnow() + expires_in,
"type": "access",
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> dict:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("type") != "access":
raise ValueError("Wrong token type")
return payload
except JWTError as e:
raise ValueError(f"Invalid token: {e}")
Set short expiry times for access tokens (15 minutes to 1 hour). Use refresh tokens for longer sessions.
Mistake 6: Path Traversal
A path traversal attack uses ../../../etc/passwd in a filename to read files outside your intended directory:
import os
from pathlib import Path
UPLOAD_DIR = Path("/app/uploads")
# BAD — user controls the path
def serve_file_bad(filename: str):
path = UPLOAD_DIR / filename
return open(path).read()
# filename = "../../etc/passwd" -> reads /etc/passwd
# GOOD — resolve and check the path
def serve_file_safe(filename: str):
# Resolve to absolute path (follows symlinks, resolves ..)
safe_path = (UPLOAD_DIR / filename).resolve()
# Ensure it's still inside UPLOAD_DIR
if not str(safe_path).startswith(str(UPLOAD_DIR.resolve())):
raise PermissionError(f"Access denied: {filename!r}")
return safe_path.read_text()
# Even better — use Path.is_relative_to() (Python 3.9+)
def serve_file_modern(filename: str):
upload_dir = UPLOAD_DIR.resolve()
requested = (upload_dir / filename).resolve()
if not requested.is_relative_to(upload_dir):
raise PermissionError(f"Access denied: {filename!r}")
return requested.read_text()
Mistake 7: Insecure File Uploads
import magic # pip install python-magic
from pathlib import Path
import hashlib
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
UPLOAD_DIR = Path("uploads")
def validate_and_save_upload(filename: str, data: bytes) -> str:
# 1. Check file size
if len(data) > MAX_FILE_SIZE:
raise ValueError(f"File too large: {len(data)} bytes (max {MAX_FILE_SIZE})")
# 2. Check MIME type from file content, not extension
# (extension can be faked: malware.exe renamed to image.jpg)
mime_type = magic.from_buffer(data, mime=True)
if mime_type not in ALLOWED_TYPES:
raise ValueError(f"File type not allowed: {mime_type}")
# 3. Sanitise filename — strip path components, keep only safe chars
import re
safe_name = re.sub(r"[^\w\-.]", "_", Path(filename).name)
# 4. Use a random name to prevent guessing
random_prefix = hashlib.sha256(data).hexdigest()[:16]
ext = Path(safe_name).suffix.lower()
new_name = f"{random_prefix}{ext}"
# 5. Save to upload directory
UPLOAD_DIR.mkdir(exist_ok=True)
dest = UPLOAD_DIR / new_name
dest.write_bytes(data)
return new_name
Mistake 8: Dependency Vulnerabilities
Your code is only as secure as your dependencies. Libraries have vulnerabilities.
# pip audit — check installed packages against vulnerability database
pip install pip-audit
pip-audit
# Output:
# Name Version ID Fix Versions
# ------- ------- ------------------ ------------
# requests 2.27.0 GHSA-j8r2-6x86-q33q 2.31.0
# safety — another vulnerability scanner
pip install safety
safety check
# Keep dependencies up to date
pip list --outdated
pip install --upgrade requests
# Pin versions in production to prevent surprise upgrades
# But review and update regularly
Set up automated dependency scanning in GitHub Actions:
# .github/workflows/security.yml
name: Security Scan
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pip-audit
- run: pip-audit -r requirements.txt
Mistake 9: Verbose Error Messages in Production
# BAD — leaks stack traces, file paths, and database info to users
@app.errorhandler(500)
def internal_error(e):
return str(e), 500 # attacker sees your database schema, file paths, etc.
# GOOD — log the full error, show a generic message to users
import logging
log = logging.getLogger(__name__)
@app.errorhandler(500)
def internal_error(e):
log.exception("Internal server error") # full traceback in your logs
return {"error": "An internal error occurred"}, 500
In FastAPI:
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
log.exception("Unhandled exception: %s %s", request.method, request.url)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
# Never include str(exc) or traceback in the response
)
Mistake 10: HTTPS and Headers
Always serve your application over HTTPS. In production, your hosting platform (Render, Heroku, AWS) handles this. For self-hosted:
# certbot generates free Let's Encrypt certificates
sudo certbot --nginx -d yourdomain.com
Add security headers to every response (Flask example):
from flask import Flask
app = Flask(__name__)
@app.after_request
def add_security_headers(response):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Content-Security-Policy"] = "default-src 'self'"
return response
In FastAPI, use the secure middleware:
pip install secure
import secure
from fastapi import FastAPI
from fastapi.middleware.trustedhost import TrustedHostMiddleware
app = FastAPI()
secure_headers = secure.Secure()
@app.middleware("http")
async def set_secure_headers(request, call_next):
response = await call_next(request)
secure_headers.framework.fastapi(response)
return response
A Security Checklist for Every Python Project
Secrets
□ No credentials hardcoded in source code
□ .env in .gitignore
□ Scanned repo with detect-secrets
Input Validation
□ All user input validated with Pydantic or explicit checks
□ File uploads: size, MIME type, and filename sanitised
□ Path traversal protection on file serving
Authentication
□ Passwords hashed with bcrypt (never MD5/SHA256 alone)
□ Tokens generated with secrets.token_hex() or token_urlsafe()
□ JWT access tokens have short expiry (<=1 hour)
□ No verbose error messages exposing internals
Database
□ Parameterised queries everywhere — no string concatenation
□ DB user has minimum required permissions
□ DB not exposed to the public internet
Dependencies
□ pip-audit passes clean
□ Dependencies pinned and regularly reviewed
□ Automated vulnerability scanning in CI
HTTP
□ HTTPS enforced in production
□ Security headers set (X-Frame-Options, HSTS, CSP)
□ CORS configured to allow only trusted origins
What You Learned in This Chapter
- Secrets belong in environment variables, never in source code. Use
detect-secretsto scan for accidental leaks. - SQL injection is prevented entirely by parameterised queries —
?placeholders, never string concatenation. - Passwords must be hashed with bcrypt or Argon2 (via
passlib). Never MD5, SHA-256 alone, or plain text. - Validate all input with Pydantic. Escape HTML output with
html.escape()to prevent XSS. - Use
secrets.token_hex()andsecrets.token_urlsafe()for tokens. Neverrandom. Usesecrets.compare_digest()for constant-time comparison. - Prevent path traversal by resolving the full path and checking it stays inside the expected directory.
- Validate file uploads by checking MIME type from file content (not extension), size, and using random filenames.
- Run
pip-auditin CI to catch known vulnerabilities in your dependencies. - Log full errors server-side. Return only generic messages to users.
- Serve over HTTPS. Add security headers to every response.
What's Next?
Chapter 56 covers Python Internals — How Python Really Works: CPython's execution model, bytecode, the GIL, memory management, and the import system. Understanding the engine makes you a better driver.