Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 052 / 100

Security checklist for Python APIs: a step-by-step guide

The issues that show up in real audits.

Expona Admin8 min read

We've all seen production incidents that could have been prevented with a basic security checklist. In this guide, we'll walk through the real issues that show up in audits and penetration tests—the ones that matter most when you're running Python APIs at scale.

This security checklist for Python APIs: a step-by-step guide covers four critical areas that protect both your data and your users. By the end, you'll have a framework to defend against the most common vulnerabilities we encounter in real deployments.

Never trust input — Validate everything with Pydantic or serializers

The single largest class of vulnerabilities we see in audits stems from insufficient input validation. Developers often assume that HTTP clients send what the API contract promises. They don't. Attackers send malformed data, oversized payloads, deeply nested objects, and fields that don't match declared types.

Pydantic is your first line of defense. It forces you to declare exactly what shape your data should take, and it validates against that shape before your business logic ever runs.

python

from pydantic import BaseModel, Field, validator
from typing import Optional
import re

class UserCreate(BaseModel):
    username: str = Field(..., min_length=3, max_length=50)
    email: str = Field(..., regex=r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
    age: Optional[int] = Field(None, ge=0, le=150)
    bio: Optional[str] = Field(None, max_length=500)
    
    @validator('username')
    def username_alphanumeric(cls, v):
        if not re.match(r'^[a-zA-Z0-9_-]+$', v):
            raise ValueError('must be alphanumeric with underscores or hyphens only')
        return v

    class Config:
        extra = 'forbid'  # Reject unknown fields

Notice the extra = 'forbid' setting. This prevents attackers from injecting unexpected fields that might bypass validation logic downstream. We've seen real cases where attackers added admin flags to user creation payloads simply because the API didn't reject unknown fields.

For nested structures, set reasonable depth limits. A common attack is sending infinitely nested JSON to exhaust memory or CPU:

python

from pydantic import BaseModel, validator

class Comment(BaseModel):
    text: str = Field(..., max_length=1000)
    replies: list['Comment'] = []
    
    @validator('replies', pre=True)
    def limit_nesting(cls, v):
        if len(v) > 10:
            raise ValueError('too many nested replies')
        return v

In Django REST framework, use the same principle with serializers:

python

from rest_framework import serializers

class ProductSerializer(serializers.Serializer):
    name = serializers.CharField(max_length=200, required=True)
    price = serializers.DecimalField(
        max_digits=10, 
        decimal_places=2, 
        min_value=0
    )
    quantity = serializers.IntegerField(min_value=0, max_value=10000)
    
    def validate_name(self, value):
        if any(char in value for char in ['<', '>', '"', "'"]):
            raise serializers.ValidationError('special characters not allowed')
        return value

The principle is the same: declare the shape, validate the bounds, reject the unexpected.

SQL injection — Use ORM or parameterized queries — never f-strings in SQL

SQL injection remains one of the most dangerous vulnerabilities in production systems. We still find it regularly, often in legacy code or in rarely-touched admin queries.

The rule is absolute: never construct SQL with string interpolation, f-strings, or string concatenation. Not even for "trusted" input. Not even if you think you've sanitized it.

Use an ORM like SQLAlchemy, or use parameterized queries with bound parameters:

python

# ✓ SAFE: Using SQLAlchemy ORM
from sqlalchemy import select

user = session.execute(
    select(User).where(User.username == username)
).scalar_one_or_none()
python

# ✓ SAFE: Using parameterized queries with psycopg2
import psycopg2

cursor = conn.cursor()
cursor.execute(
    "SELECT * FROM users WHERE username = %s AND active = %s",
    (username, True)
)
result = cursor.fetchone()
python

# ✗ DANGEROUS: Never do this
query = f"SELECT * FROM users WHERE username = '{username}'"
cursor.execute(query)

Even with escaping libraries, you're still at risk if the escape logic has bugs or evolves. The parameterized query approach is the database driver's responsibility, and it's been hardened for decades.

If you're building dynamic queries (e.g., conditional WHERE clauses), compose them with the ORM, not with strings:

python

# ✓ SAFE: Build the query conditionally
query = select(Product)

if category:
    query = query.where(Product.category == category)

if min_price is not None:
    query = query.where(Product.price >= min_price)

if max_price is not None:
    query = query.where(Product.price <= max_price)

results = session.execute(query).scalars().all()

This approach keeps SQL construction in the ORM layer, where it's safe by design.

Secrets — Load from environment or a vault, never commit them

API keys, database passwords, encryption keys, and OAuth tokens must never be in your repository. Not in a separate secrets file, not in a .env file that gets checked in, and certainly not hardcoded.

Use environment variables or a dedicated secrets management system:

python

import os
from dotenv import load_dotenv

# For development, load from .env (which is .gitignored)
load_dotenv()

# Production: Kubernetes secrets, AWS Secrets Manager, HashiCorp Vault, etc.
DATABASE_URL = os.getenv('DATABASE_URL')
STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY')
JWT_SECRET = os.getenv('JWT_SECRET')

if not DATABASE_URL or not STRIPE_SECRET_KEY:
    raise ValueError('Required secrets not found in environment')

In your .gitignore:

python

.env
.env.local
*.key
*.pem
secrets/

For production, use a dedicated vault. Here's an example with AWS Secrets Manager:

python

import boto3
import json

def get_secret(secret_name):
    client = boto3.client('secretsmanager')
    try:
        response = client.get_secret_value(SecretId=secret_name)
        if 'SecretString' in response:
            return json.loads(response['SecretString'])
    except Exception as e:
        raise ValueError(f'Failed to retrieve secret: {e}')

db_credentials = get_secret('prod/database')
SQLALCHEMY_DATABASE_URI = (
    f"postgresql://{db_credentials['username']}:"
    f"{db_credentials['password']}@{db_credentials['host']}/"
    f"{db_credentials['dbname']}"
)

Rotate secrets regularly. If a developer leaves your team, rotate every credential they had access to. If a secret leaks (and assume one will), rotate immediately and audit logs to see what was accessed.

Never log secrets. Configure your logging framework to filter sensitive fields:

python

import logging

class SensitiveDataFilter(logging.Filter):
    SENSITIVE_FIELDS = ['password', 'token', 'secret', 'api_key']
    
    def filter(self, record):
        message = record.getMessage()
        for field in self.SENSITIVE_FIELDS:
            message = message.replace(
                message[message.find(field):],
                field + '=***REDACTED***'
            )
        record.msg = message
        return True

logger = logging.getLogger(__name__)
logger.addFilter(SensitiveDataFilter())

Dependencies — Run pip-audit in CI to catch known vulnerabilities

Your direct dependencies have dependencies, and those have dependencies. A vulnerability in a transitive dependency can compromise your entire application. This is not hypothetical—we've responded to incidents where a vulnerability in a popular utility library affected thousands of Python projects.

Use pip-audit to scan your dependencies for known vulnerabilities and run it in CI on every commit:

python

# Install pip-audit
# pip install pip-audit

# Scan your environment
# pip-audit

# Output example:
# Found 3 vulnerabilities in 2 packages
#   CVE-2021-12345 in requests [1.2.1]
#   CVE-2022-67890 in django [3.1.0]

Add it to your CI pipeline (GitHub Actions example):

python

name: Security Scan

on: [push, pull_request]

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-python@v2
        with:
          python-version: '3.11'
      - run: pip install pip-audit
      - run: pip-audit
        # Optionally: pip-audit --desc (shows descriptions)
        # Optionally: pip-audit --fix (patches automatically)

Keep your dependencies updated. This isn't just about security—newer versions often include performance improvements and bug fixes. Use tools like dependabot or renovate to automate pull requests for dependency updates.

Maintain a list of approved versions for critical packages. Not every update is safe to deploy immediately, especially in production systems. Review the changelog before updating:

Package Current Latest Status
sqlalchemy 2.0.15 2.0.20 Safe to upgrade
fastapi 0.95.0 0.100.0 Breaking changes—review first
requests 2.31.0 2.31.0 Up to date

Common mistakes

  • Validating only at the API boundary: Validate again in domain logic. API validation is for client errors; business logic validation is for data integrity.
  • Trusting user IDs from the request: Always fetch the current user from the session or JWT token, never from request parameters. GET /users/123/posts could be an attacker requesting another user's posts.
  • Storing passwords in plaintext or with weak hashing: Use bcrypt, Argon2, or scrypt. Never use MD5 or SHA-1.
  • Running pip-audit once and calling it done: Run it on every commit. Vulnerabilities are disclosed regularly.
  • Assuming HTTPS is enough: It isn't. Validate input, sanitize output, use CSRF tokens for state-changing operations, implement rate limiting, and log security events.

Key takeaways

  • Validate all input with Pydantic or DRF serializers, setting bounds and rejecting unknown fields.
  • Use ORMs or parameterized queries for all database operations. Never build SQL with string interpolation.
  • Load secrets from environment variables or a vault, never commit them to source control.
  • Run pip-audit in CI on every commit to catch vulnerabilities in dependencies.
  • Validate at multiple layers: API boundary, business logic, and database constraints.
  • Rotate secrets regularly and audit logs when a secret is exposed.
  • Keep dependencies updated and review changelogs before upgrading production systems.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles