← Blogsecurity
We Left a Demo Backdoor Open in Production for 30 Days. Here's the Seven-Layer Fix.
On 2026-04-16 we shipped a demo login to production with a hard-coded test/test credential. We caught it on 2026-05-16. Anyone who guessed the URL had full admin access for thirty days. This is the timeline, the root cause, and the seven-layer fix — written for the engineers who'll inherit this codebase, not the lawyers.
A demo login is the most useful and most dangerous thing you can put on a B2B SaaS product. Useful because the moment a prospect asks "can I see it?" you want a one-click answer. Dangerous because the moment you ship it, you've added an authenticated entrypoint to your production environment with a password that — almost by definition — you've written down somewhere public.
I shipped one on April 16, 2026. I caught it on May 16, 2026. For thirty days, anyone who guessed the URL had full admin access to the entire Binnacle AI dashboard — the port-captain prompts, the insurance package, the audit-ready compliance logic, the voice pipeline, every "high-IP" surface the gating system was supposed to hide.
This post is the timeline, the root cause, and the seven-layer fix we shipped on May 16. It's written for the engineer who'll inherit this codebase and look at the lockdown patches and wonder why there are seven of them for one feature. Hint: each one is a layer of defense for a different failure mode I now know about because I lived through it.
Timeline
2026-04-16. I shipped a feature flag — ALLOW_DEMO_LOGIN — gated behind a single env var on production. When set to true, the login page accepted test / test and the user landed in the Aloha Tug & Tow demo organization with full OWNER privileges. I set it to true "just for the launch demo with X." I never set it back.
2026-04-17 through 2026-05-15. Thirty days. The flag stayed on. The credential stayed in src/lib/auth.ts as a literal string match. I shipped six more features on top of it. I gave at least four other people the demo URL during sales calls. None of them ever asked me to revoke their access.
2026-05-16, ~6am HST. My twin brother was helping me test the new prospect-tokenization flow. He opened the URL on his own laptop. Then said, "wait, I just typed test/test and it logged me in as an owner." That was the moment the floor dropped out.
2026-05-16, 7am–11pm HST. Lockdown. By midnight, we had shipped seven layers of defense, audited every other authentication path, and verified the patches survive a fresh deploy. This post walks through each one.
Why one layer of defense isn't enough
Before the seven layers, here's the question I had to answer for myself: why isn't "remove the env var" enough?
Because at 8am, after I removed ALLOW_DEMO_LOGIN=true from the production environment, I rebuilt and redeployed and the test/test login still worked. Why? Because the Docker entrypoint script had ALLOW_DEMO_LOGIN=true baked in as a fallback default. So the env var was "removed" but the runtime still saw true.
That's failure mode #1. Now imagine the engineer (me, in three months, having forgotten this incident) who removes the entrypoint default but leaves the test/test branch in auth.ts. Then someone re-adds the env var "just for a demo." Boom — back open.
That's why each layer is independent: any one of them, if it survives, closes the door. The bug doesn't come back unless all seven layers fail at once, and the cost of a deploy that flips even one of them gets caught by a CI check or a runtime guard.
The seven layers, end to end
Here's each layer, what it does, why it's necessary, and where in the codebase it lives. If you ever need to remove a layer (you won't), the matrix below tells you what the other six will catch.
Layer 1 — Environment variable removed
The production environment no longer has ALLOW_DEMO_LOGIN=true. This was the first thing I did, and the least important. Anyone with shell access to the host could re-add it in 5 seconds.
Why it's worth doing anyway: it triggers a fast feedback loop. If something tries to use it (a deploy script, a docker-compose file, an old CI job), the read fails immediately rather than silently succeeding.
Layer 2 — Database flag
The demo organization (org_aloha_tug) used to have a column called allowDemoLogin set to true. This was originally a "kill switch" — the idea being that if I ever wanted to disable the demo without redeploying, I could just flip the row.
That column is now permanently false and the field is marked @deprecated in the Prisma schema with a one-line comment explaining the 2026-05-16 incident.
Why it's worth doing anyway: a future engineer who reads the schema sees the field, sees the deprecation, sees the date, and knows not to revive it without a very good reason.
Layer 3 — Source-level credential removed
The auth.ts file used to have something like this:
if (
process.env.ALLOW_DEMO_LOGIN === "true" &&
credentials.username === "test" &&
credentials.password === "test"
) {
return { id: "demo-user", role: "OWNER", ... };
}That entire block is gone, replaced with a comment that reads:
// LOCKDOWN 2026-05-16: do not restore the test/test literal branch.
// See blog post: binnacle-demo-lockdown-2026-05-16Why the comment names the post: when a future grep for "demo login" hits this file, the engineer has a single link to the full incident before they reach for the keyboard. The comment is more important than the deletion.
Layer 4 — API endpoint disabled in production
There used to be a /api/demo/sign-in route that POST-accepted test / test directly, bypassing the form. Useful for automated tests. Catastrophic in production. The route now has a runtime guard at the top:
if (process.env.NODE_ENV === "production") {
return new NextResponse(null, { status: 404 });
}A 404, not a 403 — we don't want to advertise the route's existence on production at all. The route is functional in dev so tests still work; the production environment just pretends it was never deployed.
Layer 5 — Docker entrypoint default removed
The docker-entrypoint.sh script used to have : "${ALLOW_DEMO_LOGIN:=true}" near the top — a defensive default in case the env var wasn't set. That line is gone, replaced with # LOCKDOWN 2026-05-16 and a hard ALLOW_DEMO_LOGIN=false export so the next-process environment sees the flag explicitly off even if something upstream tries to set it on.
Layer 6 — Seed file scrubbed
The prisma/seed-demo-tug.ts file used to set a hashedPassword on the demo OWNER user when seeding. Anyone running prisma db seed against a fresh database would re-create a working test/test account.
That property is now absent. The seed creates the demo organization and the demo data but does not set any password. If someone runs the seed and then tries test/test, they get a credential mismatch — there's no password to match against.
Layer 7 — Cron job removed
There was a demo-reset.sh cron that ran nightly to "reset the demo to a clean state" — which, helpfully, also re-applied the seed (see Layer 6) and re-set the env flag (see Layer 1). It was an automated way to undo Layers 1, 2, and 6 at 4am every morning.
The cron is gone. The .sh file is deleted. If we ever want demo-reset behavior back, it's going to be a per-org reset triggered from a properly-authenticated admin UI, not a system-wide refresh that re-arms a dangerous credential.
Defense-in-depth, illustrated
What does it take to re-open the backdoor? You'd need to:
- Re-add
ALLOW_DEMO_LOGIN=trueto the prod environment (Layer 1) - Set the org's
allowDemoLogincolumn back totrue(Layer 2) - Re-add the test/test literal branch to
auth.ts(Layer 3) - Remove the
NODE_ENV === "production"guard from the sign-in route (Layer 4) - Re-add the
ALLOW_DEMO_LOGIN=truedefault to the entrypoint (Layer 5) - Re-add the
hashedPasswordto the seed and re-run it (Layer 6) - Re-create the demo-reset cron (Layer 7)
Any one of these by itself does nothing. Two of them does nothing. You need all seven.
The seven steps span four file types (TypeScript source, shell script, Prisma seed, cron config), three review processes (code review, infra review, ops review), and three different people would have to sign off on the changes if I ever tried to do this through a normal PR.
That's the point. The cost of removing any one layer is high enough that a future engineer will pause before doing it. Including future-me.
What we did besides the seven layers
Three other things came out of the lockdown sprint that aren't part of the seven but matter for the broader picture:
Token-based demo access. Instead of a shared credential, every prospect now gets a per-prospect access token issued by an admin from /admin/demo-requests. The token is single-tenant, expires in 14 days, and can be revoked from the admin UI or by the prospect themselves via the one-click decline link in our reminder emails. The token URL replaces the test/test path entirely.
NDA gating. Twenty-one of the dashboard's surfaces (the ones with real IP — port-captain prompts, insurance package internals, audit-ready logic) now render a "Sign NDA to unlock" placeholder for token-gated sessions. The prospect signs in-app (typed signature with E-SIGN-Act audit trail) or via DocuSeal envelope, then the placeholders flip to real content. Three surfaces stay placeholders even post-NDA — the ones we only show to post-contract customers.
Engagement audit per prospect. Every page a prospect visits during a tokenized session gets logged with timestamp, IP, user-agent, and dwell time. The admin engagement-detail page shows the full timeline plus aggregate stats and a "how does this prospect compare to others" percentile bar. If a token is revoked or expires, the audit trail stays — we can still see what they saw and how long they spent on it.
What I'd tell other founders
Three things, plain.
One. If you have a demo login in production, the credentials are public. Not "could be public" — are. The prospect you gave them to has them. The prospect they CC'd has them. The prospect who screenshotted them has them. By month two you've lost track. By month six you should assume the entire internet has them. Treat it that way from day one.
Two. "I'll turn it off after launch" is not a security control. The day before launch and the day after launch are the same day from a "remember to flip the flag" perspective. If the flag can be left on, it will be left on, and the only question is how long it takes you to notice.
Three. Per-prospect tokens are not harder to build than a shared demo credential. They're maybe a day of work — admin issue flow, token model, signing-in route, revocation. After that, each prospect's access is independently revocable, auditable, and time-limited. There is no scenario where I'd ever ship a shared-credential demo again.
Where to look in the code
If you want to verify the seven layers are still in place, here's the one-line check:
grep -c LOCKDOWN src/lib/auth.ts \
src/app/api/demo/sign-in/route.ts \
docker-entrypoint.shExpected output: 1 1 1. If any of those returns 0, that layer's been removed and the next deploy should reject the build until the comment is restored.
The CI workflow at .github/workflows/lint-typecheck.yml runs this check on every PR. A 0 in any of the three positions fails the build before the merge button is even available.
The full lockdown commit is on main as part of PR #14. The follow-up token-based demo access and NDA gating are in PRs #16 through #28.
Closing
Most security post-mortems are written by lawyers for lawyers. This one's written for the engineer who's about to add a feature in this codebase and wants to know what the seven // LOCKDOWN 2026-05-16 comments are about. They are about the day my twin brother typed test/test into a production login form and got it.
If you're reading this in 2027 and the comments are still there, good. Leave them. They are not historical artifacts. They are the only thing standing between a future quick-fix and the same bug.
Binnacle AI is a maritime ops platform for charter and tug operators. If you want to see the parts that are now properly gated, [request a demo](/demo) — you'll get a per-prospect token, not a shared password.
Binnacle AI is not affiliated with, endorsed by, or sponsored by the U.S. Coast Guard. CFR citations refer to the current Code of Federal Regulations as of publication; confirm against eCFR before filing or inspection. This article is informational and is not legal advice — consult a qualified maritime attorney for specific regulatory questions.