> The Typo That Shipped DEBUG=True: Fail-Open Defaults and Env-Var Drift
A production Django site served traceback pages for months. No bad decision caused it — three environment variables were spelled differently in two files, and every insecure default won by forfeit.
// TABLE_OF_CONTENTS
The Typo That Shipped DEBUG=True¶
This site ran with DEBUG=True in production long enough that I cannot date the
start. Django was serving full traceback pages — source excerpts, settings keys,
local variable values — to anyone who could trigger a 500.
Nobody decided this. It is worth walking through exactly how it happened, because the failure mode is invisible to code review.
The two files¶
# settings.py
DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() in ('true', '1', 'yes')
# docker-compose.yml
environment:
- DEBUG=False
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
Read either file alone and it looks right. The compose file says DEBUG=False in
plain text. A deployment checklist item reading "confirm DEBUG is False in
production config" passes.
But settings.py reads DJANGO_DEBUG, and compose exports DEBUG. The
variable Django looks for is never set, so os.environ.get returns the default —
and the default was 'True'.
The guard that was supposed to catch this¶
The SECRET_KEY handling had the same drift, plus a safety net:
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
if not SECRET_KEY:
if os.environ.get('DJANGO_ENV') == 'production':
raise ValueError("DJANGO_SECRET_KEY must be set in production")
import secrets
SECRET_KEY = secrets.token_urlsafe(50) # dev convenience
Compose exported SECRET_KEY, not DJANGO_SECRET_KEY. So the first lookup failed.
The raise should then have taken down the container loudly on the next deploy —
except DJANGO_ENV was never set either. Unset ≠ 'production', so control fell
into the development branch and generated a fresh random key on every restart,
silently invalidating every session.
Three variables misnamed. The third one disabled the guard protecting the second.
A safety check gated on configuration shares the fate of that configuration.
Why review does not catch it¶
- The insecure state produces no error, no warning, no log line.
- Both files are individually correct-looking; the bug lives in the relationship between them, and nothing type-checks that relationship.
- Grepping for
DEBUGin either file finds a reassuring result. - The symptom (a traceback page) only appears when something else breaks.
The fix that matters¶
Accepting both spellings is the small fix:
DEBUG = (
os.environ.get('DJANGO_DEBUG')
or os.environ.get('DEBUG')
or 'False' # <- the part that matters
).lower() in ('true', '1', 'yes')
The important change is 'False'. A missing environment variable is a
misconfiguration, and a misconfiguration must degrade toward safety. Any
security-relevant default that fails open converts a typo into a vulnerability.
Ask of every os.environ.get(name, default) in a security path: if this variable
disappeared tomorrow, would I be exposed or merely inconvenienced? Inconvenienced
is the only acceptable answer.
Detecting it in five seconds¶
Do not read the config. Ask the running process what it actually believes:
docker exec <container> python -c \
"from django.conf import settings; print('DEBUG =', settings.DEBUG)"
Config files describe intent. This prints reality. The gap between them is where this entire class of bug lives — see also audit the deployed set, not the lockfile.
A cheap follow-up is a startup assertion that refuses to boot in a state you never want:
if not DEBUG and SECRET_KEY_WAS_GENERATED:
raise ImproperlyConfigured("refusing to start with an ephemeral SECRET_KEY")
Loud beats silent. This bug was silent for months.