Skip to content

Debug mode: five frameworks, five different exposures

Severity: highApplies to: Django 6.1Applies to: Flask 3.1.3 / Werkzeug 3.1.8Applies to: FastAPI 0.141.1 / Starlette 1.6.0Applies to: Express 5.2.1Applies to: Spring Boot 4.1.0Facts last verified 2026-08-16 against Django 6.1 · Werkzeug 3.1.8 · Starlette 1.6.0 · Express 5.2.1 · Spring Boot 4.1.0 (read at source, no JVM)

“Turn off debug mode in production” is the most repeated line in this subject, and it hides how differently the five frameworks behave when you do not. The same unhandled ValueError, from the same shape of view, produces responses ranging from 21 bytes to 47,207 bytes — and in one case something that is not a response page at all.

Everything below except the Spring Boot row was measured in a single session: identical exception, and a local variable holding a canary value in scope at the moment of failure, so that “leaks a traceback” and “leaks your data” could be told apart. Spring Boot was read at source; there is no JVM on this machine, and that row is marked.

Framework Setting Response Traceback in body Local values in body Interactive console
FastAPI debug=False — the default 21 B no no no
Django DEBUG=False 145 B no no no
Express NODE_ENV=production 148 B no no no
Flask debug=False — the default 265 B no no no
Spring Boot defaults (read at source) error page, no trace no no no
Express NODE_ENV unset — the default 1,703 B yes no no
FastAPI debug=True 4,899 B yes no no
Flask debug=True 12,357 B yes no yes
Django DEBUG=True 47,207 B yes yes no

Express is the only one that leaks by default

Section titled “Express is the only one that leaks by default”

Four of these frameworks default to safe. Express does not, and the reason is a piece of configuration nobody thinks of as a security setting.

Express reads process.env.NODE_ENV || 'development', and its error handler sends err.stack unless that value is exactly production. So an application deployed without NODE_ENV set — which is the state of a bare container, a systemd unit, or a node server.js on a VM — returns a full stack trace to anyone who can provoke an error. Measured: 1,703 bytes with the stack, versus 148 bytes once NODE_ENV=production is set.

Nothing warns about this. There is no debug flag to forget; the exposure comes from an environment variable being absent.

Django’s framework default is not the default you have

Section titled “Django’s framework default is not the default you have”

Django’s shipped default is DEBUG = Falsedjango.conf.global_settings says so. That is not the value in your project.

django-admin startproject writes DEBUG = True into the generated settings.py, along with ALLOWED_HOSTS = []. So every Django project begins life in the most exposed state in this table, and the framework default never applies to a real application. When people say “Django is safe by default” and “Django leaks everything in debug”, both are true and they are talking about different defaults.

Two different worst cases, and neither is simply worse

Section titled “Two different worst cases, and neither is simply worse”

The bottom two rows are the interesting ones, because they fail in ways that are not comparable.

Django leaks the most data. 47,207 bytes containing the traceback, the local variables of every frame — the canary value appears in the response body — and a settings dump. Being precise about that dump: Django sanitizes it. The SECRET_KEY value did not appear; the page shows ******** in its place. So “Django dumps your settings” overstates it. What Django genuinely hands over is the local variable values of your own code, which is frequently worse — that is where the record being processed, the token being validated, and the query parameters actually live.

Flask hands over the most capability. The Werkzeug debugger page is smaller, 12,357 bytes, and notably it does not contain local values inline. What it contains is an interactive console. The exposure is not disclosure at all — it is code execution, in your process, with your credentials. A smaller response carrying strictly more risk.

So the ranking depends on the question. By bytes disclosed, Django. By what an attacker can do, Flask, and it is not close.

FastAPI’s 21 bytes is the whole responseInternal Server Error as plain text. debug=True gives a 4,899-byte traceback and, unlike Django, no local values, which makes it the mildest of the debug modes here.

Spring Boot is the only one whose safe behaviour is a documented decision rather than a default that happens to be off. spring.web.error.include-stacktrace defaults to never and include-message likewise, both since Boot 2.3 tightened them deliberately. The value worth knowing is the third option: on-param adds the stack trace when a request parameter asks for it — client-triggered disclosure, not a middle setting. An application running on-param is one query string away from the Express row.

  • Check NODE_ENV on every Node deployment. It is the only default in this table that leaks, and its absence looks like nothing.
  • Never read a framework’s documented default as your project’s value. Django is the clear case, and the check is grep DEBUG settings.py, not the docs.
  • Distinguish a traceback from local values. Django and FastAPI both “show a traceback” in debug; only one of them includes the data your code was holding.
  • Treat Flask’s debug mode as remote code execution, not as verbose errors, and never as something that can be left on behind an internal-only URL.
  • Check for on-param in Spring configurations. It is the one setting here that lets the client decide how much it gets told.