BTCPay Server: The 2FA That Only Protected the Front Door
BTCPay Server is the self-hosted payment processor that Bitcoin merchants use to avoid custodial middlemen. It runs their Lightning nodes, holds their wallet keys, and processes their transactions. Over 30,000 instances run in the wild — on Umbrel boxes, Start9 nodes, bare Docker hosts, and enterprise setups. When BTCPay tells its users to enable two-factor authentication, they trust that the second factor actually matters.
On builds up to 2.4.1, it didn’t. Not on the API.
The Greenfield API — BTCPay’s RESTful interface for everything from store management to Lightning node control — accepts HTTP Basic authentication. The handler that processes Authorization: Basic headers has a single multi-factor gate, and that gate checks for exactly one type of 2FA: FIDO2 hardware keys. If your second factor is a TOTP authenticator app (as it is for the vast majority of users), the handler sees zero FIDO2 credentials, shrugs, and authenticates you with just your password. The browser login page enforces TOTP correctly. The API does not. Two doors to the same house, one locked, one wide open.
This was fixed in v2.4.2 (PR #7491) after being reported by @benthecarman. Attackers exploited it in the wild — Lightning nodes were drained after credentials were stolen. I reproduced it in a local lab to understand what the bypass actually gives an attacker and to ship detection rules.
Repo with PoC, scanner, live PCAP and Suricata/Sigma rules: github.com/al4n4n/btcpay-greenfield-2fa-bypass.
What the Greenfield API controls
The Greenfield API is BTCPay Server’s programmatic interface. It manages stores, invoices, wallets, payouts, pull payments, users, server settings, and — critically — Lightning nodes. If you can authenticate to it, you can do everything the web UI does and more. Server-admin accounts get the Unrestricted permission: every endpoint, every store, every node.
The API supports multiple authentication methods: API keys (scoped), Bearer tokens, and HTTP Basic authentication (email + password). Basic auth is the simplest — a single header with base64-encoded credentials — and it’s the one the bypass affects.
The vulnerable code
The authentication decision lives in one file: BasicAuthenticationHandler.cs. Here’s the relevant section from v2.4.1:
1 | // BTCPayServer/Security/GreenField/BasicAuthenticationHandler.cs (v2.4.1) |
user.Fido2Credentials.Any() returns true only for users who registered a hardware security key. A TOTP-only user has zero FIDO2 credentials. The check evaluates to false, the password is verified, and the handler issues a claims identity carrying Unrestricted plus the user’s roles. Two-factor authentication is never consulted.
The browser login path is a completely separate code path. It goes through ASP.NET Identity’s PasswordSignInAsync, which calls IsTwoFactorEnabledAsync and redirects to /login/second-login for the TOTP prompt. The API handler reimplements its own MFA check — and gets it wrong.
The fix in PR #7491 replaces the FIDO2-only test:
1 | // v2.4.2 |
IsTwoFactorEnabledAsync checks whether the user has any second factor enabled — TOTP, FIDO2, or future methods. One line, one method call, and the gap is closed.
v2.4.2 also includes PR #7492: Basic auth for the Greenfield API is now disabled by default and only honored for the first 5 minutes after account creation unless a per-user opt-in is set. Defense in depth.
The attack chain
The attack requires a stolen email and password. No TOTP code, no recovery code, no FIDO2 assertion, no API key. Rate limiting is in place, so online brute force is harder — but phished, leaked, or reused credentials are fair game.
Step 1: Send a Basic auth request to any Greenfield endpoint.
1 | GET /api/v1/users/me HTTP/1.1 |
The base64 decodes to admin@lab.local:Lab-Passw0rd!2026. No TOTP code anywhere in the request.
Step 2: On BTCPay <= 2.4.1, the server responds with HTTP 200 and the full user identity:
1 | {"id":"2c043391-...","email":"admin@lab.local","roles":["ServerAdmin"]} |
That’s it. Two steps. One HTTP request. The attacker is now authenticated as a server admin with unrestricted API access.
The proof
I set up a lab with PostgreSQL, BTCPay 2.4.1 (vulnerable), and BTCPay 2.4.2 (fixed). On each I provisioned an admin account with real TOTP 2FA enabled through the actual web enrollment flow — not a mock, not a database hack, but the same process a real user follows. The database confirmed TwoFactorEnabled=true, an AuthenticatorKey token stored, and zero FIDO2 credentials. The exact vulnerable precondition.
1 | $ python3 poc.py --url http://127.0.0.1:18080 --user admin@lab.local \ |
The /api/v1/users endpoint lists every account on the server — a server-admin-only endpoint. It returns HTTP 200. The Lightning endpoint returns 404 because I didn’t configure a node in the minimal lab, but the response is 404 (lightning-not-configured), not 401 or 403 — authentication passed; the failure is at the resource level. The endpoint is reachable past the auth gate.
The verify dashboard ran 5 rounds at 100%:
1 | TEST EXECUTION (expect HTTP 200 = bypass on the vulnerable target) |
Negative control
The identical request against patched v2.4.2:
1 | NEGATIVE CONTROL (patched target - identical request) |
The fix works. The rejection message is the exact string introduced in PR #7491.
Honest impact assessment
I want to be specific about what I proved and what I didn’t.
The bypass gives an attacker with stolen credentials full Unrestricted Greenfield API access. For a server-admin account, that means every endpoint on the server. I confirmed this by reaching the server-admin-only /api/v1/users endpoint (lists all accounts) via the bypass.
The Lightning endpoints are reachable past authentication. In the wild, attackers reportedly used this to extract LND macaroons and drain channels. I did not configure a Lightning node in my lab, so I did not extract a macaroon or move funds. The README claims table marks this as “Not proven” — the endpoint is reachable (auth passes), but the consequential step of credential extraction and fund movement was not exercised.
| Claim | Status |
|---|---|
| TOTP bypass via Basic auth | Proven |
| Server-admin API access | Proven |
| Lightning endpoint reachability | Proven (auth passes, 404 = not configured) |
| Macaroon extraction / fund theft | Not proven (not exercised) |
| Patched v2.4.2 blocks the bypass | Confirmed |
Detection
I wrote Suricata and Sigma rules to detect exploitation. The Suricata rules (SIDs 9100001–9100003) fire on HTTP Basic auth to Greenfield API paths, with elevated severity for server-admin and Lightning endpoints. I validated them against the live PCAP using jasonish/suricata — all rules fire correctly.
The Sigma rules target reverse-proxy access logs (BTCPay’s own app log doesn’t record Greenfield auth decisions at default verbosity — I checked). The rules include the nginx log_format directive needed to capture the Authorization scheme.
Both rule sets and the PCAP are in the repo.
The fix
Upgrade to BTCPay Server 2.4.2 or later. The upgrade also requires NBXplorer 2.6.10.
Patching alone is not enough if credentials were already stolen. BTCPay’s own advisory was explicit about this: rotate LND macaroons, CLN runes, and any API keys that may have been exposed. The bypass let attackers extract credentials that survive a software update.
For defense in depth: use scoped API keys instead of Basic auth for all Greenfield integrations. v2.4.2 disables Basic auth by default for established accounts (PR #7492), but the opt-in exists and some workflows may re-enable it.
Timeline
| Date | Event |
|---|---|
| 2026-08-04 | PR #7491 merged (fix by NicolasDorier, reported by benthecarman) |
| 2026-08-07 | BTCPay Server v2.4.2 released |
| 2026-08-08 | Media reports of Lightning node draining via stolen credentials |
| 2026-11-04 | This write-up published |
No CVE ID or GitHub Security Advisory was published by the vendor.
Reproduction
1 | git clone https://github.com/al4n4n/btcpay-greenfield-2fa-bypass |
Every claim in this post can be verified by running these commands. The scripts use only Python stdlib.