← Toutes les actualités
ops5 min de lecture

The cost of leaving debug mode on in production

Par Seaworthy · 29 juillet 2026

Lines of code and error output displayed on a computer screen

Photo by Artturi Jalli on Unsplash

A user submits a malformed request. Maybe it's an off-by-one in a query param, maybe it's just a typo. The app throws, and instead of a clean 500 page, the browser gets a full stack trace: file paths, source snippets, local variables, and the environment dump the framework was kind enough to include for debugging convenience. Somewhere in that dump is DATABASE_URL=postgres://prod_admin:[email protected]:5432/app.

That's not a hypothetical. It's the default behavior of most web frameworks running with debug mode on, and it's one of the most common findings when you scan a production app that started life as a weekend project.

What debug mode actually hands out

Debug mode exists so you don't have to read logs while you're developing. It's genuinely useful in that context. The problem is what it does when it's reachable from the public internet instead of your laptop.

Django's DEBUG = True renders a full traceback page on any unhandled exception, complete with the source code around the failing line, local variable values at every frame, and a settings dump that includes SECRET_KEY and database credentials unless you've gone out of your way to redact them. Flask's debugger goes further: leave app.run(debug=True) in production and you don't just get a traceback, you get an interactive Werkzeug console sitting behind a PIN that's derived from machine identifiers an attacker can often reconstruct. That's not information disclosure, that's remote code execution with extra steps.

Rails in development mode shows the same category of thing: full backtraces, request params, session data, and enough of the app's internals to map out your routes and models without touching the source repo. Express with NODE_ENV not set to production skips view caching and, depending on your error middleware, can leak stack traces straight into the response body. Laravel's APP_DEBUG=true in a .env file that shipped to prod is such a common finding that it has its own line in most bug bounty writeups.

None of these frameworks are doing anything wrong by design. They're doing exactly what a debug flag is supposed to do. The failure is that the flag followed the code to production instead of staying behind in dev.

A concrete leak

Here's the shape of it in Flask, which is illustrative because the failure mode is so direct:

# app.py
import os
from flask import Flask

app = Flask(__name__)
db_url = os.environ["DATABASE_URL"]

@app.route("/users/<int:user_id>")
def get_user(user_id):
    conn = connect(db_url)
    # bug: no validation, negative IDs crash the query builder
    return query_user(conn, user_id)

if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0")

Hit /users/-1, the query builder chokes, and the response is a full Werkzeug traceback. Every frame includes local variables, and db_url is sitting right there in the get_user frame, fully expanded, connection string and password included. No SQL injection needed. No auth bypass. Just a request that wasn't validated, meeting a debugger that was never told it wasn't allowed to talk.

The fix is one line, and the second problem is making sure it's the right line in the right place:

# app.py
debug_mode = os.environ.get("FLASK_ENV") == "development"

if __name__ == "__main__":
    app.run(debug=debug_mode, host="0.0.0.0")

Better still, don't gate it on an environment variable you have to remember to set correctly on every host. Most production deployments shouldn't call app.run() at all: put Gunicorn or uWSGI in front and the debug flag never enters the picture. But if you are calling it directly, default debug to False and require an explicit opt-in for anything else, rather than defaulting to True and requiring an opt-out.

Why it gets left on

Nobody sets DEBUG = True for production on purpose. It happens because the flag was true from django-admin startproject on day one, worked fine through weeks of local development and staging, and then the same .env file or settings module got copied to the production host because there was only one settings module to copy. Config that's supposed to differ per environment only actually differs per environment if something enforces that split. Without a hard separation, "environment" is just a variable that happens to have the same value everywhere until someone remembers to change it, and by the time anyone remembers, the app has already been live for a month.

AI coding assistants make this worse in a specific way: ask for "a Flask app that returns user data from a database" and you will very often get app.run(debug=True) back, because that's the version that appears in every tutorial and Stack Overflow answer the model was trained on. It's correct for a getting-started guide. It's a live credentials leak the day someone deploys it as-is, which is exactly the failure mode "vibe coding" workflows are prone to: code that runs, looks finished, and was never told which environment it was going to live in.

Seaworthy's static analysis checks for exactly this: debug mode and verbose logging flags left enabled in production configuration, across the frameworks where the default is dangerous by default.

The general rule holds across every language: debug output is for developers who already have shell access to the box. Anyone else who can see it is someone you didn't mean to give a look inside.

Cet article a été généré par IA et résume des sources publiquement disponibles.

Par les créateurs de Seaworthy

Seaworthy analyse votre dépôt pour détecter les problèmes abordés dans des articles comme celui-ci.

Failles de sécurité, secrets exposés et mauvaises configurations détectés avant le déploiement. Gratuit, aucun compte requis.

$ npx seaworthycode