One GET Request, One Production Backend: Anatomy of a .env Exposure
#08HIGH/ FirewallFables/ Aug 15, 2026/ 8 min read
Jump to section
- 01The Discovery
- 02Read-Only Verification (the only honest way to do this)
- 03The Bundle (anonymized, but faithful)
- 041. The database — DATABASE_URL
- 052. The signing secret — SUPABASE_JWT_SECRET
- 063. WhatsApp — self-hosted gateway + official API
- 074. Voice AI — ElevenLabs
- 085. LLM proxy — LiteLLM → DeepSeek
- 096. Edge functions & outbound
- 10The Chain
- 11Root Cause
- 12Why This Keeps Happening
- 13Remediation
- 14What I'd Tell Builders
Verdict: A single unauthenticated HTTP GET returned the entire production secrets bundle for a customer-facing WhatsApp lead-generation bot. Everything required to operate the service — database, messaging provider, voice AI, LLM proxy, webhook secrets — was served as plain text.
This is a write-up of the investigation, root cause, and the chain that turns one file into full service takeover. No secrets are reproduced; the operator was contacted with the details for rotation.
#The Discovery
It started the way most of these do: not with a pentest, not with a bug bounty, but with a passive index — a public searchable corpus of internet-facing exposures collected by continuous scanning (think Shodan-for-leaks). My usual workflow is to pull recent records from this index and triage them by freshness and plugin type.
One record stood out immediately:
- Plugin:
DotEnvConfigPlugin— the scanner found a.envfile that looked like a runtime configuration. - Host: a subdomain of an Italian digital studio that builds web apps, CRMs, and AI assistants for clients.
- Severity flagged: high.
The funny thing about .env leaks is that they're never just a .env. A .env is the crystallized truth of an application — every credential it needs to run, in one flat file, sitting in the document root. And this one belonged to a WhatsApp chatbot the studio runs for a real-estate agency — a virtual assistant that captures leads, books appointments, and talks to customers over WhatsApp, the web, and voice.
I had a strong suspicion this was going to be the full bundle. I needed to verify it read-only.
#Read-Only Verification (the only honest way to do this)
Before touching anything, I set the ground rules for myself:
- No authentication against the target with any leaked credential.
- No modification of any resource.
- Only the minimum requests needed to confirm the exposure and understand the runtime.
Here's what those requests looked like:
GET /.env HTTP/1.1
Host: <redacted>.example-studio.io
Response: 200 OK, 2,866 bytes of production secrets. Confirmed live — not a stale scanner artifact.
A few more passive checks filled in the picture:
GET / HTTP/1.1 → 200 OK, "Test Lead" page, nginx + Plesk
GET /.env.bak HTTP/1.1 → 404 (no easy backup copy)
GET /env HTTP/1.1 → 404
And the two most valuable, still fully read-only:
GET /package.json → 200 — start command: node --env-file=.env dist/main.js
GET /health → 200 — {"status":"ok"}
That package.json line is the smoking gun. --env-file=.env means the exposed file is the exact file the running service loads at boot. This isn't a leftover sample; it's the live configuration. The .env.example in the repo was public too, which let me diff the shape of the schema against the real values — the exposed file was a strict superset with concrete secrets matching the app's documented structure (a NestJS service, agent-<client>, WhatsApp provider switched to the official Meta Cloud API).
#The Bundle (anonymized, but faithful)
The file was organized in commented sections — you can tell a lot about an app from how its env is ordered. This one had:
# --- Runtime ---
NODE_ENV=production, PORT=3000, LOG_LEVEL=info
# --- DB (managed Postgres + auth) ---
DATABASE_URL → pooled Postgres connection string **with password**
SUPABASE_JWT_SECRET → HS256 signing secret for the auth layer
SUPABASE_ANON_BEARER→ long-lived anon JWT (valid past 2053)
SUPABASE_URL → project REST endpoint
# --- LLM proxy ---
LLM_BASE_URL, LLM_API_KEY (sk-…), LLM_MODEL
# --- WhatsApp gateway (self-hosted) ---
WAHA_BASE_URL, WAHA_API_KEY, WAHA_SESSION, WAHA_HMAC_SECRET
# --- WhatsApp provider (official Meta API) ---
WHATSAPP_PROVIDER=flusia
FLUSIA_API_BASE_URL, FLUSIA_API_TOKEN, FLUSIA_WEBHOOK_SECRET
FLUSIA_TEMPLATE_NAME, FLUSIA_TEMPLATE_LANG
# --- CRM edge functions ---
CHATBOT_CALENDAR_URL, CHATBOT_BOT_API_KEY, ORG_ID
# --- Admin / abuse controls ---
ADMIN_ROLE_WHITELIST, KILLSWITCH_DEFAULT_TTL_MINUTES
# --- Outbound ---
OUTBOUND_API_KEY (64-char hex)
# --- Voice AI ---
ELEVENLABS_API_KEY, ELEVENLABS_AGENT_ID, ELEVENLABS_WEBHOOK_SECRET
VOICE_TOOL_SECRET, VOICE_TEST_ACCESS_KEY, VOICE_PUBLIC_BASE_URL
# --- Legacy / migration ---
LEGACY_SHEET_ID, LEGACY_SHEET_GID
Every single category here has a direct exploitation path. Let me walk each one, because the interesting part is how they compose.
#1. The database — DATABASE_URL
A postgresql:// URL with credentials to a pooled transaction-mode connection on the managed Postgres provider. One connection string. Anyone who fetches it gets:
- Full read/write on the CRM tables — leads, appointments, customer conversations.
- The same access the production backend has, because it's the same string.
There is no "low-privilege" fallback here. A connection string is a skeleton key by design.
#2. The signing secret — SUPABASE_JWT_SECRET
This one is the quiet killer. The JWT secret (a 64-character HS256 signing key) lets you forge tokens. With it you don't need to log in — you mint your own session as any user. And the leaked anon JWT had an expiry past 2053, meaning the "public" unauthenticated surface stays open for decades unless rotated.
Signing secrets in .env files are the difference between "data leak" and "authentication bypass."
#3. WhatsApp — self-hosted gateway + official API
Two WhatsApp paths were live:
- WAHA (WhatsApp HTTP API): a self-hosted gateway where the bot session lives. The API key means read/send WhatsApp messages as the bot, control the session.
- Flusia (Meta Cloud API): the current provider per a June migration comment. The API token reads/sends as the client's WhatsApp Business number, and the webhook secret lets an attacker forge inbound webhook events — i.e., craft fake "message received" callbacks that the bot's logic will trust.
For a lead-capture bot, impersonating the WhatsApp channel is full brand and trust compromise. Customers have no way to tell the difference.
#4. Voice AI — ElevenLabs
Voice synthesis key + agent ID + two webhook secrets. Blast radius: clone the assistant's voice, run the agent, burn voice credits, or forge voice-tool webhook calls. For a voice-enabled bot this is identity theft of the product itself.
#5. LLM proxy — LiteLLM → DeepSeek
An sk-… key into the studio's LLM proxy. No per-request budget in the config, no rate limits visible. That's unbounded inference cost on someone else's bill — the classic "I can bankrupt your AI spend" path.
#6. Edge functions & outbound
An API key for Supabase edge functions (chatbot-calendar) and a 64-hex outbound key — business-flow triggers and outbound sends without needing anything else.
#The Chain
The reason this matters more than the sum of its parts is the composition. A single GET /.env yields:
1 unauthenticated GET
→ Postgres access (leads, appointments, customers)
+ JWT forgery (auth bypass)
+ WhatsApp read/send (message channel takeover)
+ Webhook forgery (inject trusted events)
+ Voice clone + LLM cost abuse
= full takeover of a customer-facing bot AND its database
There is no step that requires breaking anything. No exploit, no bypass, no brute force. It's the equivalent of finding the backup tape in the mailbox.
#Root Cause
Three compounding decisions, in order of importance:
.envinside the web root. The file lives at the site document root. It should be outside it (parent directory or injected by the process manager), so no web server can ever serve it.- Dotfiles not blocked. nginx was serving the file verbatim. One
location ~ /\. { deny all; }(or Plesk's "deny access to hidden files") closes the entire class. The scanner found it because the server happily returned it. - No separation of deploy vs. runtime secrets. The
.envwas a superset of.env.example— fine for DX, fatal when the runtime file is also the exposed one.node --env-file=.envis convenient; it also means "this file is the production config" by construction.
The Plesk + --env-file combination is worth calling out specifically: Plesk makes it extremely easy to drop a Node app's working tree into httpdocs/, and --env-file makes .env load automatically. Convenient defaults, working against you.
#Why This Keeps Happening
.env leaks aren't a vulnerability in any framework — they're a lifecycle failure:
- The file is created by convention (
cp .env.example .env), committed to a VPS, deployed with the code, and never treated as a credential. - Scanners index these because the signal is trivially detectable (a file named
.envreturning 200). - Once indexed, the exposure is permanent and observable: the index itself becomes a leak-of-the-leak. In this case the exposure was already catalogued by the public index before I looked — meaning the credentials had been readable by the public index operator and anyone using it.
That last point is the uncomfortable one: if it's in the index, it's already been read by machines other than you.
#Remediation
For the operator (a condensed, ordered checklist — already shared):
- Immediately block dotfiles at the web server (
location ~ /\. { deny all; }for nginx), then re-verify/.envreturns 404. - Rotate everything in the file. Not just the DB password: the JWT signing secret, the anon key, both WhatsApp credentials + webhook secrets, the voice keys + webhook secret, the LLM key, the edge-function and outbound keys. If a key was in that file, treat it as public.
- Move
.envout of the document tree — parent directory, or inject via the service manager/environment. - Audit sibling subdomains — the studio hosts many client apps; if one repo's layout leaked, check the others for the same pattern.
- Assess notification duties. Customer WhatsApp conversations and lead data may have been exposed to the index operator and scanners. For EU operators, that's a GDPR conversation to have, not a server-config one.
#What I'd Tell Builders
- A
.envis a credential file, not a config file. Treat it like a key: never in the web root, never in the repo, always rotated when it touches a network boundary. - Block dotfiles at the server, always, even if you think nothing sensitive is up there. It costs one location block and removes an entire vulnerability class.
- Use the process manager for secrets (systemd
EnvironmentFile, containers' env injection, or a secrets manager).--env-file=.envis fine for dev; production should load from somewhere a web server can't reach. - Assume scanners see what you serve. If a file returns 200, it is public. There is no "nobody will look there" in 2026 — an index already did, before you even knew the file existed.
The whole finding fits in one line of an incident report: a single unauthenticated GET of /.env returns the production secrets bundle for the client WhatsApp/voice lead system. Everything after that line is just the price tag.
This post contains no real credentials, hosts, or company identifiers. The affected party was notified privately with the full inventory of exposed keys for rotation.
Pass it on
More fables
The SQLite File That Exposed a Monitoring Environment
A three-year-old Grafana path traversal led to a plaintext database, then to Zabbix API access, then to a full infrastructure map.
No Auth, 164 Million Records: The Malawi Credit Bureau Exposure
A Kibana dashboard with zero authentication exposed the entire Malawian credit bureau — 2.4 million full identity profiles, 49 million loan records and live application logs — to anyone who knew the IP.
The Registry That Called Everyone: 265 Million Numbers Exposed at India's DND
Two unauthenticated CouchDB nodes held India's entire Do-Not-Disturb registry — 245 million phone preferences, 7 million consent records and full residential PII — behind nothing but an open port.