V12 Found Two High-Impact Vulnerabilities in LNbits
An authentication-cache bypass gave scoped API tokens wallet-admin access, and PayPal payments were credited before capture. Both are fixed in LNbits v1.6.0.
V12 recently audited LNbits and found two high-impact vulnerabilities. One turned a narrowly scoped API token into wallet-admin access. The other credited spendable wallet balance before a PayPal payment was captured.
Both vulnerabilities were acknowledged by maintainers, and LNbits fixed them in v1.6.0. This post explains each bug and its patch.
Both vulnerable code paths first shipped in v1.4.0, so this was not limited to v1.6.0 prerelease builds.
| Issue | Affected versions | First fixed release |
|---|---|---|
| Authentication cache bypass | v1.4.0–v1.5.6; v1.6.0 prereleases through rc3 |
v1.6.0-rc4 |
| PayPal credit before capture | v1.4.0–v1.5.6; v1.6.0 prereleases through rc2 |
v1.6.0-rc3 |
1/ Authentication Cache Bypass
The first issue broke the security boundary enforced by LNbits API-token access-control lists. A token permitted to call only one harmless read-only endpoint could make that allowed request, populate the authentication cache, and then use the same cached identity on forbidden routes.
In the affected version, a status-only token could list the account’s wallets, obtain a live wallet admin key, reset wallet keys, and use the resulting keys on wallet-admin endpoints.
Background
An LNbits account can contain multiple wallets. Each wallet has an invoice key for limited operations and an admin key that authorizes sensitive wallet actions, including outgoing payments.
LNbits also supports account API tokens governed by an access-control list, or ACL. An ACL can restrict a token by API path and by read or write method.
LNbits caches successful account authentication for performance. The default cache lifetime in the affected version was ten minutes. Caching an identity is safe only if every request still performs the authorization checks that determine what that identity may do.
Root Cause
On a cache miss, LNbits decoded the bearer JWT and checked the API token’s current ACL against the request path and HTTP method:
account = await _get_account_from_token( access_token, request["path"], request["method"], conn=conn)
if payload.api_token_id: await _check_account_api_access( account.id, payload.api_token_id, path, method, conn=conn )After that request succeeded, LNbits cached only the resulting AccountId, using a hash of the bearer token as the key:
cache.set( cache_key, account_id, expiry=settings.auth_authentication_cache_minutes * 60,)The cache-hit branch returned the account identity without decoding the JWT again or calling _check_account_api_access():
account_id = cache.get(cache_key)if account_id: request.scope["user_id"] = account_id.id await _check_user_access(request, account_id.id, conn=conn) return account_idThe cache key contained neither the route nor the method. More importantly, the cache hit skipped the function that enforced both. One permitted request therefore converted a scoped token into a temporarily unrestricted account identity. The same shortcut also skipped token-expiry and revocation checks until the cache entry expired.
Attack Flow
- An integration or other low-privileged actor receives a legitimate API token restricted to read-only
GET /api/v1/status. - The token is correctly denied access to wallet listing and wallet-key reset with HTTP 403.
- The actor calls the allowed status endpoint. LNbits authenticates the token and caches its
AccountIdfor ten minutes. - The actor repeats the forbidden wallet-list request with the same token. The vulnerable cache-hit branch returns the cached identity without checking the token ACL.
- The wallet-list response exposes the wallet ID, invoice key, and live admin key.
- The exposed admin key works on wallet-admin APIs. The scoped token can also reset the wallet’s keys and receive the replacements.
This was a same-account privilege escalation. It did not allow an unauthenticated attacker to select another account, but it defeated the least-privilege guarantee offered by scoped integration tokens.
Impact
The issue allowed any holder of a scoped API token to act with much broader privileges inside the same LNbits account. A token intended only for server-status monitoring could retrieve every wallet’s admin key.
On a funded wallet, that key could authorize outgoing payments. Resetting the keys could also lock out the owner and break existing integrations. Although the cache bypass itself lasted ten minutes by default, a disclosed admin key remained usable until it was rotated.
PoC
The unmodified vulnerable source was run in Docker with the source mounted read-only, SQLite on tmpfs, the default ten-minute authentication cache, and only public HTTP APIs. FakeWallet was used as the isolated Lightning backend and did not affect authentication behavior.
Before the cache was populated, the status-only token was denied as expected:
GET /api/v1/wallet/paginated?limit=1 HTTP/1.1Authorization: Bearer <status-only-token>
HTTP/1.1 403 Forbidden{"detail":"Path not allowed."}The token then called its one permitted endpoint:
GET /api/v1/status HTTP/1.1Authorization: Bearer <same-token>
HTTP/1.1 200 OK{"version":"1.6.0rc2", ...}Immediately afterward, the same wallet-list request succeeded and returned live credentials:
GET /api/v1/wallet/paginated?limit=1 HTTP/1.1Authorization: Bearer <same-token>
HTTP/1.1 200 OK{ "data": [{ "id": "<wallet-id>", "adminkey": "<live-admin-key>", "inkey": "<live-invoice-key>" }]}The disclosed admin key successfully renamed the wallet. The same status-only token also called PUT /api/v1/wallet/reset/<wallet-id> with HTTP 200, and the newly returned admin key performed another authenticated mutation.
Patch
The fix keeps the identity cache but revalidates the signed token and its live ACL on every cache hit:
if account_id: payload = _decode_access_token(access_token) if payload.api_token_id: await _check_account_api_access( account_id.id, payload.api_token_id, request["path"], request["method"], conn=conn, )The cache now reuses identity, not authorization.
The original exploit and a broader cache-hit test matrix were rerun against v1.6.0-rc4. The post-cache wallet list and key reset remained 403, a read-only wallet token was denied a write request, a revoked cached token was immediately rejected, and a one-minute JWT was rejected after expiry even though the authentication cache remained valid for ten minutes.
2/ PayPal Credit Before Capture
The second issue was a payment-state mismatch in the PayPal fiat provider. LNbits created PayPal orders with CAPTURE intent but treated PayPal’s intermediate APPROVED state as completed payment.
A user could approve an order, trigger LNbits reconciliation, receive wallet credit, and spend it while the PayPal order still had zero captures.
LNbits acknowledged the finding as valid, but marked the disclosure as a duplicate of an earlier report.
Background
The PayPal Orders API separates buyer approval from settlement. With CAPTURE intent, the buyer first approves the order. The merchant must then capture it.
APPROVED therefore means the payer approved the checkout; it does not mean the merchant received the funds. LNbits should credit the wallet only after a completed capture has been verified.
Root Cause
LNbits correctly created the PayPal order with CAPTURE intent:
order_data = { "intent": "CAPTURE", "purchase_units": [ { "amount": { "currency_code": currency.upper(), "value": f"{amount:.2f}", }, "custom_id": payment_hash[:127], "invoice_id": payment_hash[:127], } ],}However, its status mapper classified both COMPLETED and APPROVED as success:
def _status_from_order(self, order): status = (order.get("status") or "").upper() if status in ["COMPLETED", "APPROVED"]: return FiatPaymentSuccessStatus()LNbits never captured the order in this path. Once the mapper returned success, check_fiat_status() persisted the payment as successful and queued the internal invoice, making the corresponding wallet balance spendable.
Attack Flow
- A user creates a PayPal-backed fiat invoice through LNbits.
- LNbits creates a PayPal order with
CAPTUREintent. - The user approves the order using a PayPal buyer account. PayPal moves the order to
APPROVED, with no capture yet. - The user polls the LNbits payment-status endpoint, or LNbits processes the legitimate approval webhook.
- LNbits maps
APPROVEDto success and credits the wallet without calling PayPal’s capture endpoint. - The user spends the resulting LNbits balance while the merchant has received no PayPal funds.
Impact
The issue created unbacked but spendable LNbits credit. Any user permitted to create PayPal fiat invoices could approve an order without completing capture and make LNbits’s Lightning-side liquidity cover the credited value.
The practical loss was bounded by the deployment’s fiat limits and available liquidity, but the attack could be repeated.
PoC
The real PayPal order flow was tested against the official PayPal Sandbox. LNbits started with a zero-balance disposable wallet and created a USD 1 order. A Sandbox buyer approved it. Before LNbits reconciliation, a direct PayPal API query showed:
{ "intent": "CAPTURE", "status": "APPROVED", "capture_count": 0, "completed_capture_count": 0}The wallet still had zero balance. The pending LNbits payment was then polled:
GET /api/v1/payments/<payment-hash> HTTP/1.1X-Api-Key: <invoice-key>
HTTP/1.1 200 OK{ "paid": true, "status": "success", "details": { "amount": 1252000, "status": "success" }}LNbits credited 1,252,000 msat. A 1,000 msat invoice belonging to a second disposable wallet was paid from that balance, and the receiver was credited. A final direct PayPal query still showed APPROVED with zero captures.
Patch
The fixed implementation captures an APPROVED order with an idempotency key and only reports success when the order is COMPLETED, at least one capture exists, and every capture is COMPLETED:
order = response.json()if (order.get("status") or "").upper() == "APPROVED": response = await self.client.post( f"/v2/checkout/orders/{paypal_id}/capture", json={}, headers={ **self._auth_headers(), "PayPal-Request-Id": f"capture-{paypal_id}", }, ) order = response.json()
if status != "COMPLETED": return FiatPaymentPendingStatus()
if captures and all( (capture.get("status") or "").upper() == "COMPLETED" for capture in captures): return FiatPaymentSuccessStatus()The flow was repeated against the fix in v1.6.0-rc3. Before reconciliation, PayPal again showed APPROVED with zero captures and LNbits showed zero balance. This time, reconciliation first changed the PayPal order to COMPLETED with one completed USD 1 capture; only then did LNbits mark the payment paid and credit the wallet.
3/ Timeline
- August 25, 2026: Both issues are discovered by V12.
- August 27, 2026: LNbits released the PayPal integration fix in
v1.6.0-rc3. - August 28, 2026: We reported both issues to LNbits through GitHub Security Advisories; the PayPal report was marked as a duplicate.
- August 31, 2026: Authentication-cache fix released in
v1.6.0-rc4. - September 2, 2026: Both fixes reconfirmed in
v1.6.0.
4/ Disclosure
V12 discovered both vulnerabilities while auditing LNbits. We validated the findings and reported them privately to the LNbits maintainers. The PayPal report was closed as a duplicate because the same issue had already been reported. The maintainers pointed us to the existing PayPal fix, implemented a fix for the authentication issue, and asked us to verify both against the development branch.
The fixed PayPal flow now completes capture before crediting the wallet. The authentication fix also held against the original exploit and additional tests covering path restrictions, method restrictions, token revocation, and JWT expiry on cache hits.
We thank the LNbits maintainers for their prompt response and coordination. Users should upgrade to LNbits v1.6.0 or later.