LNbits Provisioning Proxy
A small Node service that sits in front of LNbits and gives Nostr clients a safe, narrow surface for provisioning wallets, claiming Lightning Addresses and managing Nostr Wallet Connect grants. This page is a self-hosting guide: run your own instance against your own LNbits.
Why a proxy at all
LNbits exposes a full administrative API. Handing that to a browser extension would mean trusting every client with instance-wide authority. The proxy publishes only the handful of paths a wallet actually needs, authenticates wallet owners with their Nostr key instead of a password, and keeps the LNbits super-user credential on the server where it belongs.
Custom LNbits instances need this adapter for the extension's wallet management UI to work against them. Nothing in the extension is specific to one operator.
Architecture
Requests arrive at your reverse proxy over TLS and are forwarded to the service on loopback. The service talks to LNbits over HTTP and reads two LNbits SQLite databases directly for lookups that the HTTP API does not expose.
Nostr client
| HTTPS
v
Reverse proxy (TLS, zaps.example.com)
| HTTP, loopback
v
LNbits proxy :3003 --- reads ---> database.sqlite3
| HTTP ext_lnurlp.sqlite3
v
LNbits :5000
|
v
Lightning backend (phoenixd, LND, ...)The service binds 127.0.0.1 only. It is never directly reachable from the internet, so a reverse proxy in front of it is required, not optional.
Direct database access is how the proxy maps a Nostr public key to an LNbits wallet and how it writes Lightning Address pay links. Both database files must be readable and writable by the user the service runs as.
Prerequisites
- Node.js 24 or newer. The service uses the built-in SQLite module, which is still experimental on earlier releases.
- A working LNbits instance with the
lnurlpextension installed, and thenwcproviderextension if you want Nostr Wallet Connect support. - A Lightning backend behind LNbits. The reference deployment uses phoenixd, but the proxy does not care which one you run.
- The LNbits super-user API key, which the service uses to create accounts and wallets. This key is never forwarded to clients.
Install and configure
Clone the repository, install the single runtime dependency and start the service with its configuration in the environment.
Environment variables
Five variables control the service. Only one is required.
| Variable | Default | Description |
|---|---|---|
LNBITS_URL | http://127.0.0.1:5000 | Base URL of your LNbits instance. |
LNBITS_ADMIN_KEY | required | LNbits super-user API key. The service refuses to provision without it. |
LNBITS_DB_PATH | .../data/database.sqlite3 | Path to the main LNbits SQLite database. |
LNURLP_DB_PATH | .../data/ext_lnurlp.sqlite3 | Path to the lnurlp extension's SQLite database. |
PORT | 3003 | Loopback port the service listens on. |
Change the domain before you deploy
The public domain is a constant in the source, not an environment variable. NIP-98 verification rejects any signed event whose u tag does not match it exactly, so an unmodified copy will refuse every provisioning request on your own domain. Edit the constant in server.js to your own hostname before starting the service.
const DOMAIN = 'zaps.example.com';What your instance exposes
Everything the service answers is listed below. Any path not in this list returns 404, including the rest of the LNbits API.
Wallet provisioning
GET/api/provision/challenge
Issue a single-use random challenge. No authentication. Challenges expire after 60 seconds and are consumed on first successful use.
{
"challenge": "7f3a…"
}POST/api/provision
Create a wallet for a Nostr public key, or return the existing one. The response carries the wallet's keys and is marked no-store.
Send the signed NIP-98 event as the event field, with an optional wallet name.
{
"event": {
"kind": 27235,
"…": "signed NIP-98 event"
},
"name": "My Wallet"
}{
"walletId": "…",
"adminkey": "…",
"inkey": "…",
"lightningAddress": null
}Lightning Address
POST/api/claim-username
Claim a Lightning Address username for the authenticated key. Creates the LNURL pay link and records the username on the account.
GET/api/lightning-address?pubkey={hex}
Look up the Lightning Address claimed by a public key. No authentication. The pubkey query parameter is required and must be 64 lowercase hex characters.
{
"lightningAddress": "[email protected]"
}POST/api/release-username
Release a claimed username, freeing it for anyone else and removing the pay link.
Nostr Wallet Connect
Wallet-scoped management of NWC grants. Every route requires that wallet's Admin API key in the X-Api-Key header; invoice-only keys are rejected. These routes accept no query string at all.
GET/api/nwc/connections
List active connections with their budget usage, plus the public provider public key and relay. Returns an empty list if the user has not enabled the extension.
{
"connections": [],
"provider": {
"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"relay": "wss://relay.example.com"
}
}PUT/api/nwc/connections/{clientPubkey}
Register a client-generated public key as a connection. A repeat call for the same key is idempotent and returns 200 rather than editing the existing grant.
{
"name": "My phone",
"dailyLimit": 10000,
"days": 90
}curl -X PUT "https://zaps.example.com/api/nwc/connections/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" \
-H "X-Api-Key: $WALLET_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"My phone","dailyLimit":10000,"days":90}'DELETE/api/nwc/connections/{clientPubkey}
Revoke that wallet's grant for a client public key. Already-dispatched payments are not cancelled.
Nostr Wallet Connect
The client generates its own secret and builds the pairing string locally. The proxy never receives, stores, logs or returns a pairing secret, and never puts one in a URL.
New grants are fixed to pay, lookup and info permissions with a daily budget and an expiry. LNbits remains authoritative for ownership, account restrictions and budget enforcement.
Proxied LNbits paths
Two groups of LNbits paths are forwarded verbatim. Everything else is blocked.
Public LNURL paths, forwarded with permissive CORS because wallets call them cross-origin. The Host header is rewritten to your public domain so LNbits builds correct callback URLs.
GET /.well-known/lnurlp/{username}
GET /lnurlp/api/v1/lnurl/cb/{id}Authenticated wallet paths, which require an X-Api-Key header and are used by the extension's wallet view.
GET|POST /api/v1/wallet
GET|POST /api/v1/paymentsNIP-98 authentication
Provisioning and every Lightning Address mutation use challenge-response with a signed Nostr event. There are no passwords and no sessions.
- Request a challenge.
- Build a kind 27235 event carrying the challenge and the exact request it authorizes.
- Sign it with the user's Nostr key and send it as the event field of the POST body.
- The service verifies the Schnorr signature before consuming the challenge, so a failed attempt does not burn it.
Required tags
All three tags are mandatory and checked exactly. The u tag must be the full absolute URL of the endpoint being called on your own domain, and the method tag must match the HTTP method.
const { challenge } = await fetch(
"https://zaps.example.com/api/provision/challenge"
).then(r => r.json());
const event = await window.nostr.signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", "https://zaps.example.com/api/provision"],
["method", "POST"],
["challenge", challenge],
],
content: "",
});
const wallet = await fetch("https://zaps.example.com/api/provision", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event, name: "My Wallet" }),
}).then(r => r.json());Timing
Two independent windows apply: the challenge expires 60 seconds after it is issued, and the event's created_at must be within 60 seconds of server time. A client with a badly skewed clock will fail even with a fresh challenge.
Reverse proxy
Terminate TLS and forward to the loopback port. The block below is the reference configuration.
server {
listen 443 ssl;
server_name zaps.example.com;
ssl_certificate /etc/letsencrypt/live/zaps.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/zaps.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3003;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
}
}
server {
listen 80;
server_name zaps.example.com;
return 301 https://$host$request_uri;
}Set the real client IP if a CDN sits in front
Rate limiting keys on the rightmost X-Forwarded-For entry, which your reverse proxy appends. If a CDN such as Cloudflare proxies your domain, that entry is the CDN's edge address rather than the visitor's, and every user behind the same edge shares one bucket: a handful of requests per minute for everyone at once. Restore the real address before the request reaches the service.
# Inside the server block, before location /
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# ... the rest of your CDN's published ranges
real_ip_header CF-Connecting-IP;
real_ip_recursive on;Skip this block only if nothing sits between the internet and your reverse proxy. Keep the address ranges current with your CDN's published list.
Process management
Any supervisor works. The reference deployment uses pm2, with the configuration injected into the process environment and the process list saved so it survives a reboot.
Inject the super-user key through your supervisor or an environment file that is never committed. It must not live in the repository.
Monitoring
Two independent watchers are worth running: a health check for the stack as a whole, and a watchdog for the Lightning backend.
Lightning backend watchdog
phoenixd can reach a state where the process is alive and listening but its HTTP API never answers, because a reconnect loop to the LSP blocks the event loop. Wallets report failed payments while systemctl reports the service as active. A timer that probes the API and restarts on two consecutive failures recovers this without anyone waking up.
# /etc/systemd/system/check-phoenixd.timer
[Unit]
Description=Run phoenixd health check every 2 minutes
[Timer]
OnBootSec=60
OnUnitActiveSec=120
AccuracySec=10
[Install]
WantedBy=timers.targetThe double check matters: a single timeout is usually a blip, and restarting the backend on every blip is worse than the fault.
Stack health check
A periodic check that exercises LNbits, the proxy, the public LNURL paths and end-to-end invoice generation, and emails on state changes. Run it from cron on the same host.
*/5 * * * * /usr/bin/node /srv/monitor/monitor.mjs >> /srv/monitor/cron.log 2>&1Stack health check
If your check generates real invoices, give it a dedicated wallet and address, use a short expiry, and clean the invoices up. Probing a real user's address every few minutes fills their payment history with invoices nobody ever pays.
Limits and validation
Rate limits
Per client address, per minute. Requests over the limit get 429.
| Route | Requests / minute |
|---|---|
/api/nwc/connections | 60 |
/api/provision/challenge | 10 |
/api/provision | 5 |
/api/claim-username | 3 |
/api/release-username | 3 |
Lightning Address usernames
Usernames must be 3 to 30 characters, lowercase alphanumeric with dots, hyphens and underscores, starting and ending with an alphanumeric character.
/^[a-z0-9][a-z0-9._-]{1,28}[a-z0-9]$/These names are blocked:
admin support help info noreply
postmaster webmaster abuse root systemNWC connection settings
- Connection name: 1 to 50 characters, no control characters.
- Daily limit: 1 to 9,999,999 sats, enforced on a rolling 24 hour window.
- Expiry: 1 to 365 days.
- Maximum 50 active connections per wallet.
Request bodies
64 KB for provisioning and Lightning Address requests, 4 KB for NWC connection creation.
Source
The service is a single Node file with one runtime dependency. Read it before you run it. github.com/nostr-wot/LNbits-proxy