Checklists are cheap to write and easy to ignore. Everyone's seen a "Django production checklist" blog post before — DEBUG = False, use HTTPS, don't be silly with |safe. Good advice, sure. But advice you haven't tested against your own stack is just a to-do list you're hoping is complete.
So instead of just handing you another checklist, I ran mine. This post walks through the Django production security checklist I use for Sandbox99 Chronicles itself — settings, CSRF, XSS, SQL injection, uploads, the works — and then I'll show you what happened when I ran an AI-assisted penetration test against both my local dev copy (sandbox99.local) and the live site (sandbox99.cc) to see if the checklist actually held up. Spoiler: most of it did. Two things didn't, and fixing them taught me more than the checklist itself did.
Where This Checklist Comes From
My day-to-day AI harness for infra and pentest work is OpenCode, running from the terminal — regulars here already know that. For this specific job, though, I pulled in a purpose-built skill rather than winging it: django-security, sourced from a Claude Code skills marketplace and folded into my OpenCode workflow for the review. It's part of the affaan-m/everything-claude-code project — a large, actively maintained, community-run collection of Claude Code skills, agents, and rules covering everything from Django and Spring Boot patterns to TDD workflows. Worth noting it's community-maintained, not an Anthropic-published skill — good enough reason to double-check its recommendations against the official Django docs, which I did throughout this post.
The skill itself packages production settings, auth, CSRF/XSS prevention, SQL injection avoidance, and upload validation into one reference an agent (or a human) can work through systematically.
I like the checklist approach here for the same reason I like runbooks for infra work: most high-severity Django mistakes live in a small, predictable set of places. If you methodically walk through settings, auth, CSRF, XSS, SQL, and uploads, you eliminate the majority of what actually gets exploited in the wild. It's not glamorous. It's just disciplined.
Let's go through it.
1. Lock Down Core Production Settings First
Everything else sits on top of your base settings. If these are wrong, nothing downstream saves you.
DEBUG = False
ALLOWED_HOSTS = ["example.com", "www.example.com"]
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
X_FRAME_OPTIONS = "DENY"
The reasoning is straightforward:
DEBUG = Falsestops stack traces and internal config from leaking to the world.ALLOWED_HOSTScuts down on host header shenanigans.- Secure cookie flags keep session and CSRF cookies from ever traveling over plain HTTP.
- HSTS tells browsers "always use HTTPS with me" after the first trusted visit.
X_FRAME_OPTIONS = "DENY"blocks clickjacking via iframe embedding.
If you only touch one section of your settings.py today, make it this one. Full reference: the Django security docs.
2. Keep Secrets Out of Code and Repos
Hardcoded secrets are still one of the most common ways production apps leak. SECRET_KEY, database credentials, email provider keys, cloud storage keys, third-party tokens — all of it belongs in environment variables, not in your repo.
The pattern: SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] — bracket access, not .get() with a fallback. If the variable is missing, the app should refuse to start. A silent default in production is a landmine you've buried for future-you. If you want cleaner config management around this, django-environ is a solid, well-established option.
3. Strengthen Authentication and Password Storage
Django's auth system is good out of the box, but production deserves stricter defaults than local dev.
Put Argon2PasswordHasher first in PASSWORD_HASHERS, keep PBKDF2PasswordHasher as a fallback, and bump MinimumLengthValidator up to at least 12 characters in AUTH_PASSWORD_VALIDATORS. If your app touches staff accounts, billing, or internal tooling, weak password policy stops being a nitpick and starts being a real business risk.
4. Authorization Is Not the Same Thing as Authentication
A logged-in user isn't automatically an authorized user. This is the gap that lets User A edit User B's record just because they guessed the ID in the URL.
Pair LoginRequiredMixin and PermissionRequiredMixin with a get_queryset() override that filters to Post.objects.filter(author=self.request.user) — require login, require explicit permission, and filter the queryset down to what the user should actually be able to touch. On the DRF side, a custom permission like IsOwnerOrReadOnly enforces the same rule for APIs. For every update, delete, export, or admin-style endpoint, ask yourself: can a user reach this data only because they guessed an ID? If the answer might be yes, authorization is still weak.
5. Don't Break CSRF Protection for Convenience — and Here's What Happens When You Harden It Wrong
CSRF protection is on by default in Django. Leave it on. Use {% csrf_token %} in forms, send X-CSRFToken on AJAX calls, keep CSRF_COOKIE_SECURE = True, and treat @csrf_exempt as a last resort that needs a documented, compensating reason.
Here's the part the checklist doesn't warn you about: hardening CSRF can quietly break your own frontend. When I flipped CSRF_COOKIE_HTTPONLY = True on sandbox99.local, six of the small security-tool widgets on my landing page — the VirusTotal scanner, password breach checker, email breach checker, domain checker, WHOIS lookup, DNS lookup — all stopped working. Their JavaScript was reading csrftoken straight out of document.cookie, which obviously fails once that cookie is HttpOnly.
The fix: keep the cookie HttpOnly (don't compromise on that), and expose the token through a <meta name="csrf-token" content="{{ csrf_token }}"> tag instead, read by JS via document.querySelector.
Lesson: apply the hardening, but don't assume your own JS is written for the world you're hardening into. Test it.
6. Let Django Escape Output — Stop Reaching for safe
XSS almost always enters through rendering, not storage. Django templates auto-escape by default, and that default is doing real work. Don't fight it.
{{ comment.body }}
<script>
const username = "{{ user.username|escapejs }}";
</script>
Avoid this:
{{ comment.body|safe }}
safe and mark_safe() should be reserved for content you fully control — not user input. On sandbox99.cc, stored content runs through Bleach for sanitization before it's ever rendered, and a Content Security Policy sits on top as a second layer in case something slips through the first.
7. Use the ORM by Default, Parameterize Every Raw Query
The Django ORM protects you from most SQL injection mistakes automatically:
User.objects.get(username=username)
User.objects.filter(email__iexact=email)
If you need raw SQL, parameterize it:
User.objects.raw("SELECT * FROM users WHERE email = %s", [email])
Never do this:
User.objects.raw(f"SELECT * FROM users WHERE email = '{email}'")
Prefer the ORM, parameterize raw queries without exception, and give search/filter/report/admin endpoints a dedicated review pass if raw SQL shows up in more than one place.
8. Validate Uploads by Content, Not Just Extension
Uploads are a favorite attack path precisely because so many apps trust a .jpg or .pdf extension at face value.
import os
import magic
from django.core.exceptions import ValidationError
def validate_file_type(value):
mime = magic.from_buffer(value.read(2048), mime=True)
value.seek(0)
allowed = {"image/jpeg", "image/png", "application/pdf"}
if mime not in allowed:
raise ValidationError("Unsupported file type.")
ext = os.path.splitext(value.name)[1].lower()
if mime == "application/pdf" and ext != ".pdf":
raise ValidationError("File extension does not match content.")
Check the MIME type from the actual bytes, cross-check it against the claimed extension, cap file size, keep uploads out of your static asset path, and never execute an uploaded file. If python-magic is a pain to package in your environment, filetype is a pure-Python alternative worth knowing about.
9. Protect APIs With Auth, Permissions, and Throttling
Your checklist needs to cover the API surface as much as the browser-facing views.
REST_FRAMEWORK = {
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": "100/day",
"user": "1000/day",
},
}
Set default authentication and permission classes, add object-level permissions where needed, and throttle both anonymous and authenticated traffic. Throttling won't stop a determined attacker outright, but it slows credential stuffing and blunts accidental overload — cheap insurance for the effort involved.
10. Security Headers and Logging — Round Two of "It Broke My Own Widgets"
Baseline headers: HSTS, X-Frame-Options, X-Content-Type-Options: nosniff, and a Content Security Policy. On the logging side: authentication failures, permission failures, upload validation failures, centralized logs — and never log secrets or full sensitive payloads.
I hit the same "checklist vs. reality" wall here that I hit with CSRF. Adding a strict Content-Security-Policy with connect-src 'self' in Caddy correctly blocked a bunch of things — but it also blocked my own "Detect IP Address" widget, which fetches from external IP-lookup APIs client-side. The fix wasn't to loosen the policy generally; it was to extend connect-src with exactly the domains that widget needs:
connect-src 'self' https://api4.ipify.org https://api6.ipify.org https://api4.my-ip.io https://api6.my-ip.io;
Minimal, explicit allow-listing beats a broad exception every time.
Case Study: Running an AI-Assisted Pentest Against This Checklist
Talk is cheap, so I ran an AI-assisted penetration test rather than just eyeballing the settings. Setup: OpenCode as the harness, the django-security skill driving the test methodology, and GPT-5.4 mini as the underlying model via BYOK, working through sandbox99.local (my laptop) first and then verifying live against sandbox99.cc. Human-in-the-loop the whole way — I approved each test category before it ran, same as I do for any agent work that touches a live target. I'm attaching the full report below — here's the short version.
Phase 1 — local, pre-hardening: 41 tests across CSRF, stored XSS, SQL injection, path traversal, open redirect, auth bypass, file upload, rate limiting, security headers, cookies, and session management. Result: 39 pass, 2 findings — both around security headers and cookie flags (missing CSP, CSRF cookie missing HttpOnly/Secure, X-Frame-Options getting overridden, and an uploaded-page model that wasn't re-sanitizing content on update).
Phase 2 — live verification on sandbox99.cc: 29 tests across the same categories plus host header and SSRF checks. Result: 29 pass, 0 findings. Whatever got fixed locally held up in production.
Phase 3 — the part the checklist doesn't cover: browser-level verification of the landing page's security tool widgets (VirusTotal scanner, password/email breach checkers, domain and WHOIS/DNS lookups, IP detector). This is where the CSRF and CSP hardening broke six widgets, fixed as described above.
Current state: zero open critical, high, or medium findings. Two low-severity items remain open, both already on my to-do list:
- The uploaded-page extension validator currently checks extension only — it needs the magic-byte/MIME check from section 8 above.
- The comment form has a math captcha but no dedicated IP-based rate limit yet.
Neither is an active exploit path. Both are hardening debt I'm tracking, not incidents waiting to happen.
(Full pentest report attached below — includes phase-by-phase test tables, resolved and open findings, and remediation detail.)
[Attach: Sandbox99 Chronicles Pentest Report — sandbox99.local & sandbox99.cc, Aug 2026]
Final Django Production Security Checklist
Quick-scan version for your own audit pass:
DEBUGdisabled in productionALLOWED_HOSTSset explicitly- HTTPS enforced with secure cookies and HSTS
SECRET_KEYand other secrets loaded from environment, fail-fast if missing- Strong password validation and a modern hasher (Argon2) enabled
- Authorization checked at the object level, not just at login
- CSRF protection preserved across forms and AJAX — with a token delivery method that survives
HttpOnlyhardening - No unsafe
|safeormark_safe()on user content - ORM preferred, every raw SQL query parameterized
- Uploads validated by MIME type, extension match, and size
- API authentication, permissions, and throttling configured
- Security headers and CSP in place — tested against your own frontend, not just a scanner
- Useful, secret-free logging enabled
Django gives you a strong base. What actually keeps production safe is disciplined review — and, ideally, pointing something sharp at your own site before someone else does it for you.
What's Next
The obvious next step, and one of the report's own recommendations, is an authenticated/admin pentest pass before any major release — right now this round only covered unauthenticated attack surface. That's queued up for a future post. In the meantime, if you're running Django behind Podman and Caddy the way I am, most of this maps over cleanly regardless of your reverse proxy — the CSP and header work just moves into your Caddyfile instead of nginx or Traefik config.