Five FastAPI Security Patterns AI Coders Get Wrong

AI coding assistants generate the same five FastAPI security mistakes across codebases. SQL injection, hardcoded credentials, shell injection, hallucinated packages, O(N²) loops — and how to catch them.

Copper Sun Brass Team · · 8 min read
securityai-code-reviewoss-core

AI coding assistants generate working FastAPI endpoints in minutes. They also generate the same five security mistakes, reproducibly, across codebases. Not because FastAPI is insecure — FastAPI’s official security documentation covers parameterized queries and authentication patterns clearly. The mistakes come from training data. The AI learned from millions of tutorial examples and StackOverflow answers, many of which use insecure patterns for brevity. The AI predicts what comes next, and what comes next in a tutorial is often f"SELECT * FROM users WHERE id = {user_id}".

BrassCoders detects all five patterns below in a single pass using its OSS-core scanners: Bandit, Yelp’s detect-secrets, and three custom detectors for AI-introduced anti-patterns. No paid plan required.

Why AI Assistants Produce These Patterns

BrassCoders was built on a specific observation: AI coding assistants are pattern-completion engines, and the patterns they’ve seen most often are not always the patterns that are correct. Training data for Python web APIs skews toward tutorials and quick-start examples — code written to be readable, not to be secure. A StackOverflow answer that demonstrates a FastAPI route with an f-string SQL query gets upvoted for clarity. That same code, reproduced at scale by an AI assistant, ships a SQL injection vulnerability into production.

This is structural. Prompting your way around it helps at the margins. The more reliable fix is a scanner that runs after generation and flags the specific patterns that AI assistants get wrong.

Pattern 1 — SQL Injection via f-String

BrassCoders detects SQL queries constructed via string formatting using Bandit rule B608, which fires on any detected SQL statement where user-controlled values are interpolated directly into the query string.

The generated code looks like this:

from fastapi import FastAPI
import databases

app = FastAPI()
database = databases.Database("postgresql://localhost/mydb")

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    query = f"SELECT * FROM users WHERE id = {user_id}"
    return await database.fetch_one(query)

The problem: user_id is typed as int by FastAPI, which blocks the most obvious string-injection payloads — but it doesn’t block all of them. More critically, this pattern teaches the AI (and the developer reading it) that f-strings in SQL are acceptable. The same pattern, copy-pasted to a string-typed field, becomes a textbook SQL injection. OWASP API Security Top 10 lists injection as API8:2023, appearing across database queries, shell commands, and template engines.

The fix is parameterized queries:

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    query = "SELECT * FROM users WHERE id = :user_id"
    return await database.fetch_one(query, values={"user_id": user_id})

BrassCoders catches the f-string version via Bandit B608. The parameterized version produces no finding.

Pattern 2 — Hardcoded Credentials in Config

BrassCoders detects hardcoded credentials — API keys, database connection strings, signing secrets — using Yelp’s detect-secrets library, which covers 20+ credential formats including OpenAI keys, AWS access keys, GitHub PATs, Stripe live keys, and high-entropy strings.

The generated code looks like this:

# config.py
DATABASE_URL = "postgresql://admin:hunter2@localhost/mydb"
OPENAI_API_KEY = "sk-proj-abc123..."
SECRET_KEY = "my-super-secret-key-that-nobody-will-ever-guess"

# main.py
from config import DATABASE_URL, OPENAI_API_KEY, SECRET_KEY

This pattern appears constantly in AI-generated code. The AI is optimizing for “give the developer a working example” and hardcodes placeholder values it doesn’t expect to be real. Developers copy the config, swap in real values, and commit it. The credential is now in git history permanently, regardless of whether the file is later removed.

The fix moves all secrets to environment variables:

import os
from functools import lru_cache
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    openai_api_key: str
    secret_key: str

    class Config:
        env_file = ".env"

@lru_cache
def get_settings():
    return Settings()

FastAPI’s official security documentation recommends environment-based configuration for all secrets. BrassCoders catches the hardcoded version via detect-secrets. The BaseSettings pattern produces no finding.

Pattern 3 — Shell Injection in Report Runners

BrassCoders detects subprocess calls with shell=True via Bandit rules B602 and B603, which fire whenever user-controlled data flows into a shell command string.

The generated code looks like this:

from fastapi import FastAPI
import subprocess

app = FastAPI()

@app.post("/reports/generate")
async def generate_report(report_type: str, output_format: str):
    cmd = f"generate_report --type {report_type} --format {output_format}"
    result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    return {"output": result.stdout}

A request with report_type=pdf; rm -rf / executes both commands. The shell=True flag tells the OS to parse the string through /bin/sh, which interprets shell metacharacters. Any user-controlled value in that string becomes arbitrary code execution. Bandit’s B602 documentation flags this as HIGH severity.

The fix passes arguments as a list, bypassing the shell entirely:

import subprocess
import shlex

ALLOWED_TYPES = {"pdf", "csv", "html"}
ALLOWED_FORMATS = {"letter", "a4"}

@app.post("/reports/generate")
async def generate_report(report_type: str, output_format: str):
    if report_type not in ALLOWED_TYPES or output_format not in ALLOWED_FORMATS:
        raise HTTPException(status_code=400, detail="Invalid report parameters")
    result = subprocess.run(
        ["generate_report", "--type", report_type, "--format", output_format],
        capture_output=True,
        text=True
    )
    return {"output": result.stdout}

No shell=True. No string interpolation. BrassCoders catches the original via B602/B603 and flags it HIGH.

Pattern 4 — Hallucinated Middleware Package

BrassCoders’s AI-pattern scanner flags imports of packages that don’t exist on PyPI, catching a class of AI-generated bug where the model invents a plausible-sounding package name that has never been published.

The generated code looks like this:

from fastapi import FastAPI
from fastapi_auth_middleware import AuthMiddleware
from fastapi_rate_limiter import RateLimiterMiddleware
from fastapi_security_headers import SecurityHeadersMiddleware

app = FastAPI()
app.add_middleware(AuthMiddleware, secret_key="your-secret-key")
app.add_middleware(RateLimiterMiddleware, calls=100, period=60)
app.add_middleware(SecurityHeadersMiddleware)

Some of these packages exist; some don’t; some exist but with different import paths or class names. The AI generates what sounds like it should exist based on patterns it’s seen. The code passes a linter because Python import errors are runtime — not compile-time. The developer’s IDE may autocomplete based on stubs that don’t match the real package API.

The danger goes beyond a runtime ModuleNotFoundError. A package name that doesn’t exist on PyPI is an unclaimed namespace. An attacker can publish a malicious package under that name — a technique known as dependency confusion or slopsquatting. Any developer who runs pip install fastapi_auth_middleware expecting the AI-suggested package installs the attacker’s code instead.

The fix is to verify every package against PyPI before using it, use packages with established maintenance histories, and let a scanner flag unresolvable imports before they reach production. BrassCoders’s phantom-API detector cross-references imports against PyPI and flags packages that don’t resolve.

Pattern 5 — O(N²) Response Construction

BrassCoders’s performance scanner flags two specific O(N²) patterns in Python: results += [item] inside a loop and list.insert(0, item) inside a loop. Both appear consistently in AI-generated list-building code.

The generated code looks like this:

from fastapi import FastAPI
from sqlalchemy.orm import Session

app = FastAPI()

@app.get("/users")
async def list_users(db: Session):
    users = db.query(User).all()
    results = []
    for user in users:
        results += [{"id": user.id, "email": user.email}]
    return results

Or the reversed-list variant:

    ordered = []
    for item in db.query(Item).order_by(Item.created_at.desc()).all():
        ordered.insert(0, item.to_dict())
    return ordered

results += [item] creates a new list on every iteration. insert(0, ...) shifts every existing element right on every iteration. On a database returning 10 rows, neither matters. On a database returning 100,000 rows — not unusual for a users table or an events feed — both exhibit quadratic time and memory behavior. The insert(0, ...) variant is particularly slow: it’s O(N) per call, O(N²) total.

The fixes are straightforward:

# Fix for += pattern
results = [{"id": user.id, "email": user.email} for user in users]

# Fix for insert(0, ...) pattern — reverse at the query level
ordered = [item.to_dict() for item in db.query(Item).order_by(Item.created_at.asc()).all()]
# Or, if you must reverse in Python:
ordered = []
for item in items:
    ordered.append(item.to_dict())
ordered.reverse()  # O(N) single reversal, not O(N²) cumulative shifts

BrassCoders’s performance scanner catches both += [item] and insert(0, ...) inside loops and reports them as performance findings in the YAML output.

Running BrassCoders on a FastAPI Project

BrassCoders covers all five patterns above in a single offline scan, combining Bandit security rules, Yelp’s detect-secrets credential patterns, and three custom BrassCoders detectors for AI-introduced anti-patterns. The scan output is .brass/ai_instructions.yaml, a ranked findings list formatted for direct paste into Claude Code or Cursor.

Install and run:

pip install brasscoders
brasscoders --offline scan .

The --offline flag enforces zero outbound network calls. The OSS core is Apache 2.0 licensed — no account required, no telemetry.

For larger projects, BrassCoders Paid ($12/month) adds a semantic deduplication pass that drops near-duplicate findings across files, reducing the scan output further before triage. Activate it with brasscoders activate <license-key> after subscribing at coppersun.dev/pricing.

The five patterns above are AI-specific. They’re not FastAPI bugs and they’re not developer errors — they’re what happens when a model trained on tutorial data generates production code without a scanner in the loop.

Add the scanner.

Frequently Asked Questions

Does BrassCoders catch SQL injection in FastAPI endpoints?

Yes. BrassCoders runs Bandit's B608 rule, which flags any SQL query constructed via string formatting — including f-strings and %-style formatting. It fires whether the query is in a route handler, a dependency, or a utility function.

What hardcoded credential formats does BrassCoders detect?

BrassCoders uses Yelp's detect-secrets library, which covers 20+ credential formats: AWS access keys, GitHub PATs, OpenAI API keys (sk- prefix), Stripe live keys, Slack tokens, PEM-formatted private keys, JWTs, and high-entropy strings. Custom BrassCoders patterns extend coverage for additional formats.

Can BrassCoders detect hallucinated Python packages?

Yes. BrassCoders includes a custom AI-pattern scanner that flags imports of packages not found on PyPI. It cross-references import statements against PyPI's package index and flags packages that don't resolve — including the typosquatting attack surface that hallucinated package names create.

Does the OSS core require an account or network access?

No. The BrassCoders OSS core is Apache 2.0 licensed and makes zero outbound network calls by default. Run brasscoders --offline scan . to enforce offline mode explicitly. No account, no telemetry.

Which version of BrassCoders detects these patterns?

BrassCoders 2.0.8, available on PyPI as brasscoders, detects all five patterns described in this post using its OSS core scanners — no paid plan required. Install with: pip install brasscoders

Does BrassCoders detect missing rate limiting or JWT expiry misconfiguration?

No. BrassCoders catches patterns in source code: string-formatted SQL queries, literal credential strings, shell=True subprocess calls, unresolvable package imports, and O(N²) data-structure operations. Configuration-level issues like rate limiting absence or JWT expiry settings require infrastructure-level checks that fall outside static analysis.