Python: Zero to Hero
Home/The Professional Python Developer
Share

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, &lt;script&gt;alert("XSS")&lt;/script&gt;!</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-secrets to 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() and secrets.token_urlsafe() for tokens. Never random. Use secrets.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-audit in 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.

© 2026 Abhilash Sahoo. Python: Zero to Hero.