---
title: "Python: Security"
slug: "python-security"
category: "coding"
tags: ["coding", "python", "security", "validation", "pydantic", "injection"]
status: "stable"
last_updated: 2026-05-14
summary: "Pydantic for input validation, parameterized queries for SQL, pickle avoidance, secrets via environment variables, dependency auditing, and SSRF prevention."
related: ["[[coding/python]]", "[[coding/python-dependency-management]]", "[[backend/fastapi]]", "[[coding/python-typing]]", "[[coding/general-principles]]"]
---

## Overview

Python security failures cluster around a few predictable patterns: trusting user input, constructing queries with string concatenation, deserializing untrusted bytes, and shipping secrets in code. This page names each pattern and the fix. For dependency vulnerability scanning, see [[coding/python-dependency-management]].

## Validate all external input with Pydantic

Never trust data from HTTP requests, environment variables, files, or message queues. Parse it at the boundary with Pydantic v2 before it enters business logic.

```python
from pydantic import BaseModel, Field, EmailStr
from typing import Annotated

PositiveInt = Annotated[int, Field(gt=0)]

class CreateUserRequest(BaseModel):
    username: Annotated[str, Field(min_length=3, max_length=50, pattern=r"^[a-z0-9_]+$")]
    email: EmailStr
    age: PositiveInt

def create_user(raw: dict) -> User:
    req = CreateUserRequest.model_validate(raw)  # raises ValidationError on bad input
    return User(username=req.username, email=req.email, age=req.age)
```

`model_validate` raises `ValidationError` with field-level details on bad input. Catch it at the API layer and return a 422 response. Never pass the raw dict further into the system. See [[backend/fastapi]] for how FastAPI handles this automatically via type annotations.

## Use parameterized queries; never format SQL strings

String formatting in SQL queries opens the door to SQL injection. Use parameterized queries unconditionally.

```python
import sqlite3

conn = sqlite3.connect("app.db")

# Bad: SQL injection possible
name = request_data["name"]
conn.execute(f"SELECT * FROM users WHERE name = '{name}'")

# Good: parameterized
conn.execute("SELECT * FROM users WHERE name = ?", (name,))
```

ORM layers (SQLAlchemy, Tortoise) parameterize by default when you use the query builder API. They are still vulnerable if you drop to `text()` with string formatting. See the SQLAlchemy docs on `bindparam` for safe raw SQL with variables.

## Never deserialize untrusted data with `pickle`

`pickle.loads` executes arbitrary Python code embedded in the pickled bytes. Any attacker who controls the bytes controls the process.

```python
# Bad: remote code execution if content is attacker-controlled
import pickle
obj = pickle.loads(untrusted_bytes)

# Good: use a safe format
import json
obj = json.loads(untrusted_string)

# Good: use Pydantic for structured data
from pydantic import BaseModel
result = MyModel.model_validate_json(untrusted_string)
```

Use `pickle` only for internal caches where you control both the writer and the reader, and the data never crosses a trust boundary. Document this constraint in the code with a comment.

## Load secrets from the environment, not from code

API keys, database passwords, and tokens must not appear in source files, committed config, or Docker image layers. Load them at runtime from environment variables or a secrets manager.

```python
import os
from dotenv import load_dotenv

load_dotenv()  # reads .env in dev; no-op in production where env vars are set externally

DATABASE_URL = os.environ["DATABASE_URL"]  # raises KeyError if missing: fail fast
API_KEY = os.environ.get("OPENAI_API_KEY")  # returns None if missing: acceptable for optional
```

Use `os.environ["KEY"]` (raises on missing) rather than `os.environ.get("KEY")` (returns `None`) for required secrets. A clear startup failure is better than a confusing runtime error ten calls later.

Add `.env` to `.gitignore`. Never commit it. Use `python-dotenv` in development; in production, set variables via the platform's secrets mechanism (GitHub Secrets, AWS Secrets Manager, Fly.io secrets).

## Prevent SSRF in outbound HTTP calls

Server-side request forgery (SSRF) occurs when user-supplied URLs are fetched by the server, allowing attackers to reach internal services. Validate URLs before fetching.

```python
from urllib.parse import urlparse
import httpx

ALLOWED_SCHEMES = {"https"}
BLOCKED_HOSTS = {"169.254.169.254", "metadata.google.internal"}

def safe_fetch(url: str) -> bytes:
    parsed = urlparse(url)
    if parsed.scheme not in ALLOWED_SCHEMES:
        raise ValueError(f"Disallowed scheme: {parsed.scheme}")
    if parsed.hostname in BLOCKED_HOSTS:
        raise ValueError(f"Blocked host: {parsed.hostname}")
    return httpx.get(url, follow_redirects=False).content
```

Disable automatic redirect following (`follow_redirects=False`) when fetching user-supplied URLs; redirects can bypass host checks. Consider a DNS-based allowlist for services that require external HTTP calls.

## Audit dependencies regularly

Transitive dependencies introduce vulnerabilities you did not write. Run `pip-audit` in CI to catch known CVEs before they reach production.

```bash
uv run pip-audit
```

When `pip-audit` reports a vulnerability, check whether the patched version exists and upgrade. If no patch is available, assess exploitability and, if the CVE is in a code path you use, consider replacing the library. See [[coding/python-dependency-management]] for the full lockfile workflow.

## Related

- [[coding/python]]
- [[coding/python-dependency-management]]
- [[backend/fastapi]]
- [[coding/python-typing]]
- [[coding/general-principles]]
