AI coding assistants produce plausible-looking Django code. It compiles. It handles the happy path. The problem is Django ships with a serious security layer — CSRF middleware, autoescape in templates, parameterized query wrappers — and that layer only works if you use it correctly. AI-generated Django code often skips the correct usage.
Four patterns show up repeatedly. They're all subtle enough that a code reviewer scanning for logic errors can miss them. BrassCoders, the bug scanner for AI coders, catches all four with its 12-scanner stack.
Raw SQL with String Formatting
BrassCoders flags Bandit rule B608 when it finds raw SQL constructed via string formatting — %s substitution, f-strings, or .format() calls passed to .raw(), cursor.execute(), or similar Django query escapes. This catches the most common path by which AI-generated Django code introduces SQL injection.
Here's the pattern:
# views.py — AI-generated search endpoint
def search_users(request):
user_id = request.GET.get("id")
# AI assistant generated this. Looks fine. It's not.
results = MyUser.objects.raw(
f"SELECT * FROM myapp_user WHERE id = {user_id}"
)
return JsonResponse({"users": list(results.values())})
user_id is attacker-controlled. Passing 1 OR 1=1 dumps the table. Passing 1; DROP TABLE myapp_user; -- on a database that allows stacked queries does worse. The Django ORM's .filter() method would have parameterized this automatically, but .raw() doesn't — it's a raw escape hatch, and string formatting into it is injection.
BrassCoders output for this pattern looks like this:
- rule_id: B608
severity: HIGH
confidence: MEDIUM
file: views.py
line: 7
message: >
Possible SQL injection via string-based query construction.
Use parameterized queries or the ORM's filter() instead.
scanner: bandit
The fix is to use .raw() the way Django intends it — with a params list that the database driver handles:
# Correct: parameterized .raw()
results = MyUser.objects.raw(
"SELECT * FROM myapp_user WHERE id = %s",
[user_id]
)
Or, for simple lookups, skip .raw() entirely and use the ORM:
results = MyUser.objects.filter(id=user_id)
AI assistants reach for .raw() with f-strings because the generated code reads naturally and the function signature doesn't make the danger obvious. BrassCoders doesn't have to reason about context — B608 fires whenever string formatting flows into a raw SQL call, regardless of how the variable was named or where it came from.
Missing CSRF_COOKIE_SECURE in Production Settings
BrassCoders's custom AI-pattern scanner flags Django settings.py files where DEBUG = False appears without the HTTPS and CSRF cookie security settings. This pattern catches the gap AI assistants leave when generating production settings: they set DEBUG = False (which looks correct) but omit the CSRF and cookie hardening that production requires.
A typical AI-generated production settings file looks like this:
# settings.py — AI-generated production config
DEBUG = False
ALLOWED_HOSTS = ["yourdomain.com"]
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["DB_NAME"],
# ...
}
}
# AI assistant stopped here.
# CSRF_COOKIE_SECURE, SESSION_COOKIE_SECURE, SECURE_SSL_REDIRECT — all absent.
DEBUG = False is necessary for production, but it doesn't activate HTTPS enforcement or secure cookie transmission. Without CSRF_COOKIE_SECURE = True, Django sends the CSRF cookie over plain HTTP. A network attacker on the same Wi-Fi can steal it. Session hijacking follows.
BrassCoders flags the absent security settings and points you to the three lines that need to exist in any production settings file:
# Required for HTTPS-served Django apps
CSRF_COOKIE_SECURE = True # CSRF cookie only sent over HTTPS
SESSION_COOKIE_SECURE = True # Session cookie only sent over HTTPS
SECURE_SSL_REDIRECT = True # Redirect all HTTP → HTTPS
Django's own deployment checklist (docs.djangoproject.com/en/stable/howto/deployment/checklist/) lists these as items to audit before going live. BrassCoders automates that audit and puts the result in your YAML output, where Claude Code or Cursor can read it and generate the fix directly.
DEBUG=True Left in Production Settings
BrassCoders catches DEBUG = True in settings files via Bandit's information exposure rules — a finding that ships with HIGH severity because the consequences of a misconfigured debug flag in production are immediate and broad.
The common path is unremarkable. An AI assistant generates a Django starter project. DEBUG = True is the default — correct for local development, catastrophic in production. The developer deploys without changing it:
# settings.py — starter generated by AI assistant
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "django-insecure-..." # <-- also flagged by BrassCoders
DEBUG = True # <-- flagged: B501, information exposure
ALLOWED_HOSTS = []
With DEBUG = True live in production, Django's exception handler exposes the full Python stack trace on any 500 error. That stack trace includes local variable values, the full settings module content, and a list of installed apps. An attacker who can trigger an error gets a map of your application internals, your database schema references, and potentially credentials that leaked into local scope.
OWASP lists information exposure via error messages as a distinct attack category (OWASP Top 10, A05:2021 Security Misconfiguration). The Django debug page is a textbook example. BrassCoders flags it before it ships.
The fix is two lines and a deployment habit:
DEBUG = False # Never True in production
# Use environment variable for safety
DEBUG = os.environ.get("DJANGO_DEBUG", "False") == "True"
Setting via environment variable prevents the literal True from ever appearing in a deployed settings file. BrassCoders won't flag the environment-variable form because there's no information-exposure risk at the source level.
Unsafe mark_safe() on User Content
BrassCoders flags mark_safe() calls where the argument comes from request data, a model field, or any source that could carry user input. This catches the XSS vector that AI assistants introduce when they assume a developer wants to render HTML stored in the database or submitted via a form.
The bad pattern appears in template views that handle user-generated content:
# views.py — AI-generated comment rendering
from django.utils.safestring import mark_safe
from django.shortcuts import render
from .models import Comment
def post_detail(request, post_id):
comments = Comment.objects.filter(post_id=post_id)
# AI assistant added mark_safe to "allow HTML in comments"
rendered_comments = [mark_safe(c.body) for c in comments]
return render(request, "post_detail.html", {
"comments": rendered_comments
})
mark_safe() tells Django's template engine to skip autoescape for this value. Django's autoescape is the default defense against XSS in templates — it converts `into<script>before rendering. Callingmark_safe()onc.bodyswitches that off. An attacker who can write a comment containing a
Top comments (0)