Last month I shipped a small SaaS tool I'd been building for about four months. Password-protected dashboard, rate-limited API, HTTPS everywhere, dependency audit clean. I felt good about it.
At 9:00 AM I posted the launch. By 2:00 PM, my error-tracking inbox lit up with a pattern I didn't recognize: hundreds of 404s, all hitting the same path — /api/v1/export — with incrementing user IDs.
Someone (or something) had found an internal data-export endpoint I had completely forgotten existed. Not because they were clever. Because I had left it wide open during development and never put it behind auth. It wasn't linked anywhere in the UI. It wasn't in the docs. It didn't matter.
This is the post-mortem. The bug was boring. The fix was boring. The reason it happened anyway is the part I think is worth your time.
The timeline
- 9:00 AM — Launch post goes live. Traffic starts arriving. Everything looks normal.
- 11:30 AM — A few unusual requests in the logs. I assume it's curious launch-day visitors clicking around.
-
2:00 PM — Error tracker shows a spike of 403/404 responses on
/api/v1/export?user_id=NNN, whereNNNincrements 1, 2, 3… Classic IDOR-style crawling. - 2:10 PM — I check the endpoint. It returns real data. No auth. I had built it as a quick debug export during development, wired it into the router, and simply… never removed it or protected it. It shipped because the route existed, and nobody — including me — ever hit it in testing.
- 2:15 PM — I kill the route. Check access logs. The crawler had been enumerating IDs for roughly 40 minutes before I noticed. Exported maybe 300 rows of non-sensitive-but-still-private data (names and plan tiers; no emails, no passwords, no payment data — I got lucky on what that endpoint returned).
- 2:45 PM — Full audit of every registered route against an auth checklist. Found two more debug endpoints. One returned config values. One could trigger a cache flush. Both unauthenticated.
The honest failure part
Here's the part most post-mortems skip: this wasn't a knowledge gap. It was a process gap.
I know you put auth on endpoints. I've written auth middleware. The problem is that I had no moment in my launch process where I was forced to look at the complete list of attack surface and answer, one route at a time: does this need protection, and does it have it?
The endpoints existed for weeks. Every time I ran the app, they were there. But "I've never clicked it, so nobody will" is not a security model. Obscurity is not auth. And a solo developer's memory is not a threat model.
A bot found the endpoint in under five hours of public existence. I'd bet it was a scanner that crawls common API paths and fuzzes for IDOR patterns — automated recon that runs against every new domain it finds. The attacker didn't outsmart me. They just had a checklist and a loop, and I had neither for defense.
What I changed (the boring, durable fix)
I didn't fix this by "being more careful." I fixed it by making carefulness automatic and verifiable.
1. A launch-day security checklist that runs against the real deployment.
Not a wiki page I skim. A script that runs right before I announce anything:
#!/bin/bash
# launch-security-check.sh — run against the LIVE deployment
BASE="https://app.example.com"
# Every route my app registers, dumped from the router
grep -rhoE "(get|post|put|delete|patch)\(['\"]([^'\"]+)" src/routes/ \
| sed -E "s/.*(get|post|put|delete|patch)\(['\"]//" | sort -u > /tmp/routes.txt
echo "Routes with no auth middleware in the handler chain:"
# Cross-check each route against routes that call requireAuth()
for r in $(cat /tmp/routes.txt); do
grep -q "$r" src/routes/authed.txt || echo " UNPROTECTED: $r"
done
echo ""
echo "Common scanner paths — should ALL return 404 or auth-required:"
for p in /api/v1/export /debug /config /admin /env /.env /api/users /graphql; do
code=$(curl -s -o /dev/null -w "%{http_code}" "$BASE$p")
echo " $code $p"
done
echo ""
echo "Response headers (security headers present?):"
curl -sI "$BASE" | grep -iE "strict-transport|x-frame|x-content-type|content-security"
The important line in that script is the one that dumps every registered route and diffs it against the list of routes I've explicitly marked as authed. If a route exists but isn't on the authed list, it gets flagged. Debug endpoints can't hide from a diff.
2. Default-deny routing.
I flipped the pattern: new routes are unauthenticated-by-accident no more. The router now applies auth middleware globally, and a route has to explicitly opt out with a code comment explaining why. Public routes are now the exception I have to justify, not the default I can forget.
3. Rate limiting on anything that takes an ID.
The crawl that found me was slow — maybe 8 requests a minute — which is why a blunt IP-based limiter almost misses it. So I added per-endpoint anomaly alerts: any endpoint that suddenly starts receiving sequential IDs gets a notification. It wouldn't have saved me that day, but it turns the next incident from "found in error logs" into "found in 5 minutes."
4. The 404s were a gift. I set up canary paths.
I now have a few fake juicy-looking paths (/api/v1/export, /admin/dump) registered on purpose, returning 403 and logging every hit. If a scanner touches them, I know someone is probing — usually before they find anything real. My accidental debug endpoint became my tripwire.
What this cost me, in the end
- About 300 rows of low-sensitivity data exposed for ~40 minutes. I emailed the affected users. That was the worst part — the apology email, not the fix.
- Roughly 6 hours of audit, fix, and hardening.
- A permanent change to how I ship: no launch happens until the checklist script runs clean against the production URL.
The lesson isn't "use auth." You already know that. The lesson is that whatever you don't verify automatically, you will eventually forget — and on launch day, when your attention is on the launch post and the replies, memory is the weakest security control you own. A bot with a checklist will always find what a human with a to-do list forgot.
Build the checklist. Run it against the live URL. Then launch.
The full checklist + scripts are in Ship Safe — The Launch-Day Security Kit — code LAUNCH90 at checkout makes it $1.50.
Top comments (0)