A "Tabeer.ai Production Readiness Checklist" showed up twice in one afternoon — pasted directly, then relayed a second time through a different session working on an unrelated project, both times insisting it was urgent. Fifteen Django security items, thirteen marked red. Here's the checklist, what each item actually protects against, and why I fixed two of them instead of thirteen.
The Checklist
| # | Check | Why it matters |
|---|---|---|
| 1 | DEBUG = False in production | DEBUG=True leaks full stack traces, local variable values, and settings to any visitor who triggers an error — a direct information-disclosure hole. |
| 2 | SECRET_KEY stored outside source code | Django's SECRET_KEY signs sessions, password-reset tokens, and CSRF tokens. Committed to git, it's in every clone and every fork, forever. |
| 3 | Production secrets in environment/secret manager | Same reasoning as #2, generalized — DB passwords, API keys, anything else. |
| 4 | ALLOWED_HOSTS explicitly configured | Without it, Django will happily render pages for whatever Host header an attacker sends, enabling cache-poisoning and password-reset-link poisoning attacks. |
| 5 | CSRF_TRUSTED_ORIGINS configured | Needed the moment your frontend and API are on different subdomains — without it, legitimate cross-origin POSTs get rejected, or worse, get misconfigured wide open to work around the rejection. |
| 6 | HTTPS enforced | Plaintext HTTP means session cookies, login credentials, and everything else travel in the clear. |
| 7 | HTTP → HTTPS redirect | The enforcement mechanism for #6 — a visitor who types the bare domain shouldn't get a plaintext response even once. |
| 8 | Secure cookies enabled | The umbrella item for #9 and #10. |
| 9 | SESSION_COOKIE_SECURE = True | Without it, the session cookie is sent over HTTP too, if a visitor ever ends up there — trivially interceptable on public wifi. |
| 10 | CSRF_COOKIE_SECURE = True | Same reasoning, for the CSRF token cookie. |
| 11 | HSTS configured | Tells the browser to refuse plaintext HTTP for this domain from now on, closing the gap between "we redirect HTTP to HTTPS" and "a user's very first request, or a stripped link, never touches plaintext at all." |
| 12 | SECURE_CONTENT_TYPE_NOSNIFF = True | Stops browsers from guessing a file's type from its content instead of its declared Content-Type — the classic vector for turning an uploaded "image" into executable script. |
| 13 | X_FRAME_OPTIONS configured | Prevents your login page, or any page, from being framed inside an attacker's site for clickjacking. |
| 14 | Production logging configured | You can't respond to an incident you can't see. |
| 15 | Django deployment checks run regularly | The meta-item — the other fourteen only stay fixed if something keeps checking them. |
Every one of these is a real, well-established Django hardening practice — OWASP's own guidance and Django's official deployment checklist cover the same ground. None of that is in question. What was in question is whether Tabeer.ai actually had thirteen of them wrong.
The Tool That Actually Knows
Django ships a command specifically for this:
python manage.py check --deploy
It doesn't guess from crawling a homepage or pattern-matching a repo — it imports your actual
settings.py, with your actual production environment variables, and checks the real values
Django will run with. I ran it against Tabeer.ai's live server config:
System check identified some issues:
WARNINGS:
?: (security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting...
?: (security.W008) Your SECURE_SSL_REDIRECT setting is not set to True...
System check identified 2 issues (0 silenced).
Two warnings. Not thirteen. DEBUG, SECRET_KEY, secret storage, ALLOWED_HOSTS,
CSRF_TRUSTED_ORIGINS, both secure-cookie flags, nosniff, and X-Frame-Options were all
already correctly configured — checked directly against backend/config/settings.py and the
live .env, not inferred from a scan of the rendered page. Whatever produced that fifteen-item
report never actually asked Django.
The Two Real Gaps
SECURE_SSL_REDIRECT and HSTS were genuinely missing at the Django level. Tabeer.ai already sits
behind Cloudflare and nginx, both of which already enforce HTTPS and already send an HSTS header
at the edge — but check --deploy is specifically asking whether Django itself also enforces
it, as defense-in-depth for the case where a request ever reaches the app server directly. Fair
point. Fixed both, in the same if not DEBUG: block that already held the secure-cookie flags:
if not DEBUG:
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Safe alongside SECURE_PROXY_SSL_HEADER above — Django checks the trusted
# X-Forwarded-Proto header (not the raw connection) to decide whether a request is already
# HTTPS, so this won't redirect-loop behind Cloudflare/nginx.
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 63072000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
The one-line comment matters more than it looks. SECURE_SSL_REDIRECT = True behind a
reverse proxy is a classic way to create an infinite redirect loop — Django sees the internal
connection from nginx, which is plain HTTP, and redirects a request that was already HTTPS at the
edge. The fix is SECURE_PROXY_SSL_HEADER, already set earlier in the same file for the
secure-cookie logic to work correctly:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
With that in place, Django trusts nginx's X-Forwarded-Proto header instead of the raw socket,
so the redirect only fires for a request that's genuinely still plaintext somewhere in the chain.
Before trusting this, I ran the full test suite — 671 tests — to make sure the new
SECURE_SSL_REDIRECT didn't start redirecting Django's own test client mid-suite (a real risk:
if not DEBUG blocks activate whenever DEBUG is falsy, and plenty of test runners force that).
Locally, DEBUG stays True from .env, same as it always has for every other setting in that
block — all 671 passed unchanged.
Making It Stick — Item 15
The checklist's own final item was the most legitimate one: nothing was running check --deploy
anywhere except by hand, whenever someone remembered to. Fixed with a small GitHub Actions
workflow:
- name: Run production-readiness check (manage.py check --deploy)
working-directory: backend
env:
DEBUG: "False"
run: python manage.py check --deploy
Running alongside the actual pytest suite
on every push to develop and main, against a real Postgres service container — matching the
same "verify in CI, not by hand, not by trusting a report" instinct that shaped the Trivy
integration earlier. A regression here
now fails a pull request instead of surfacing three weeks later in someone's audit screenshot.
The Actual Lesson
A security checklist with fifteen items and thirteen red marks looks urgent — that's exactly
what makes it worth verifying before reacting to it. Django already has an authoritative answer to
"is this deployment configured correctly," built into the framework, free, and it takes ten
seconds to run. When a report disagrees with what the tool itself says, the tool wins — not because
checklists are worthless, but because a checklist is a claim about your config, and
manage.py check --deploy is your config, read directly.
If you're staring at a security audit for a Django app and something on it feels off, the fastest
way to find out is the same one command: python manage.py check --deploy. If you want a second
opinion on what it turns up, get in touch.
By Shahid Malik