I Left Flask's Debug Mode On for 9 Days After Launch. Then I Found the Request That Could Have Ended Everything.
The log line looked harmless at first:
185.220.101.34 - - [09/Sep/2026 03:14:22] "GET /console HTTP/1.1" 200 -
A 200. Not a 404. Something had requested /console on my production server — and my server had said yes, here you go.
If you know, you know. /console is the Werkzeug interactive debugger. It ships with Flask when debug=True. It gives whoever opens it a Python prompt running as my app, on my machine, with my environment variables loaded. And my environment variables contained my Stripe secret key, my database credentials, and the API key for the email service that talks to my entire customer list.
Nine days. It had been exposed for nine days — the entire life of the launch.
This is the story of how that happened, what the logs told me I got away with, and the checklist I now run before anything of mine faces the internet again.
The setup
I run a small checkout API for my own products. It's unglamorous by design: a Flask app on a Raspberry Pi 5 at home, nginx in front of it, Cloudflare in front of that, Tailscale for anything administrative. It has handled real payments for months without drama, which is exactly the kind of track record that makes you sloppy.
For the launch, I needed a quick landing-page backend — email capture, a license-key validator, a couple of webhook receivers. I spun it up in an afternoon because I was in a hurry, and "in a hurry" is the root cause of nearly every incident I've ever written up.
When you scaffold a Flask app fast, you write this:
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)
And then you leave it, because you're going to "productionize it later." I did productionize it — I wrote a systemd unit, put nginx in front, got the TLS cert, wired up the webhooks. The systemd unit ran the app with gunicorn... except I had a fallback ExecStart path from an early debugging session that invoked the app directly with python app.py. A config mixup during one restart meant the fallback won. debug=True came back to life in production, and nobody noticed, because gunicorn and the dev server look identical from the outside when everything is returning 200s.
What the logs said
Once I knew what to grep for, my stomach dropped. Over nine days:
-
41 requests to
/console. From 14 distinct IPs. Mostly known Tor exit nodes and a handful of bulletproof-hosting ranges I looked up later. - Three of them got a 200 — including the one above. The rest hit 502s during windows when nginx was proxying to a restarted gunicorn instance.
-
One session lasted 6 minutes and issued
/consolePOSTs. POSTs to the Werkzeug console mean someone was typing Python.
I've never fully reconstructed what that person ran. Werkzeug's console requires a PIN for eval in newer versions, and I was on a version with PIN protection — which is the only reason this post is a close call and not a obituary. The PIN is derived from machine attributes (MAC address, machine-id, username, etc.), which are guessable but not trivial. The 6-minute session with repeated POSTs and no follow-on traffic suggests someone was brute-forcing or computing the PIN and didn't get in.
Suggests. I don't know. That uncertainty is the worst part of this whole story.
What I do know: automated scanners found a debug console on a payment-adjacent host within under 4 hours of it being reachable. If you think your small project flies under the radar, it doesn't. The internet is wall-to-wall with crawlers doing nothing but looking for exactly this.
The honest failure post-mortem
Here's the part where I don't get to blame a tool.
Failure #1: I deployed with a fallback path I didn't understand. My systemd unit had two ways to start the app and I didn't know which one was active. I had never once run systemctl cat on it after launch to verify what was actually running. I tested the API endpoints; I never tested the deployment.
Failure #2: I assumed nginx was my security boundary. My mental model was "nginx only proxies /api/*, so nothing else is reachable." Wrong. The proxy config had a location / catch-all added during launch week for the landing page assets, and it forwarded everything to the app. I added it, I knew I added it, and I didn't think about what else it exposed.
Failure #3: Nine days without reading an access log. I had logs. I had no alerts on them. The GET /console pattern is so well-known that a single grep in a cron job would have caught this on day one. I was reading Stripe dashboards and Twitter analytics daily while my actual server logs sat unopened.
Failure #4 — the big one: I treated the launch as the finish line. Everything security-related was deferred to "after launch." Debug mode off: after launch. Rate limiting: after launch. Log alerts: after launch. Launch pressure is real, but "after launch" is when your app is public and holding money. That's precisely when the deferral bill comes due.
What I changed
The fixes took about three hours. They should have taken thirty minutes before launch. Here's the concrete list:
1. Kill debug mode everywhere, structurally. Not "remember to set it false" — make it impossible:
import os
DEBUG = os.environ.get("FLASK_ENV") == "development"
assert not (DEBUG and os.environ.get("REQUIRE_AUTH") is None)
And in the systemd unit, Environment=FLASK_ENV=production with a single ExecStart. One way to run the app, declared in version control.
2. Deny by default at the proxy. nginx now has an explicit allowlist; everything else is a 404:
location ~ ^/(api|health) { proxy_pass http://127.0.0.1:5000; }
location / { return 404; }
The landing page moved to static files served directly by nginx. The app only answers the paths it's supposed to.
3. A canary grep that pages me. Five lines in cron:
#!/bin/bash
if grep -qE "(GET|POST) /(console|\.env|admin|phpmyadmin|\.git)" /var/log/nginx/access.log; then
echo "probe detected" | mail -s "SEC: known-bad path hit" me@mydomain
fi
Crude? Yes. It has fired twice since — both times benign scanners — but twice I knew within the hour instead of nine days later.
4. Rotate everything on the assumption of compromise. Stripe keys, DB password, email API key, all rotated that night. If someone had gotten the PIN, log evidence alone couldn't prove they hadn't read the environment. Treat "probably fine" as "not fine" when keys are involved.
5. A written pre-exposure checklist, run every single time. Debug flags off. Default credentials changed. Proxy deny-by-default. Error pages leak nothing (no stack traces, no versions). Endpoints that spend money or send email are authenticated and rate-limited. Secrets out of the repo and out of the URL query string. Logs retained and watched by at least one automated rule. Backups verified by actually restoring one. It takes 20 minutes and it is the cheapest insurance I own.
The lesson I keep relearning
Every incident I've ever had wasn't a sophisticated attack. It was a boring, known, years-old mistake that I personally made while hurrying, left unexamined because nothing visibly broke. Debug mode. Open webhook. Unrotated key. The attackers don't need to be clever; they just need you to skip one step and then not look at your logs.
The fix isn't paranoia. It's a checklist you run when the pressure is on, because the pressure is exactly when your brain drops steps — and a grep running in cron, watching for the things you forgot to worry about.
The full checklist + scripts are in Ship Safe — The Launch-Day Security Kit — code LAUNCH90 at checkout makes it $1.50.
Top comments (0)