Nostr WoT

Documentation

Everything you need to integrate Web of Trust into your application.

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.

text
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 lnurlp extension installed, and the nwcprovider extension 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.

terminal
$git clone https://github.com/nostr-wot/LNbits-proxy.git
$cd LNbits-proxy
$npm ci
$npm test

Environment variables

Five variables control the service. Only one is required.

VariableDefaultDescription
LNBITS_URLhttp://127.0.0.1:5000Base URL of your LNbits instance.
LNBITS_ADMIN_KEYrequiredLNbits super-user API key. The service refuses to provision without it.
LNBITS_DB_PATH.../data/database.sqlite3Path to the main LNbits SQLite database.
LNURLP_DB_PATH.../data/ext_lnurlp.sqlite3Path to the lnurlp extension's SQLite database.
PORT3003Loopback 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.

javascript
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.

json
{
  "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.

json
{
  "event": {
    "kind": 27235,
    "…": "signed NIP-98 event"
  },
  "name": "My Wallet"
}
json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{
  "name": "My phone",
  "dailyLimit": 10000,
  "days": 90
}
bash
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.

text
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.

text
GET|POST  /api/v1/wallet
GET|POST  /api/v1/payments

NIP-98 authentication

Provisioning and every Lightning Address mutation use challenge-response with a signed Nostr event. There are no passwords and no sessions.

  1. Request a challenge.
  2. Build a kind 27235 event carrying the challenge and the exact request it authorizes.
  3. Sign it with the user's Nostr key and send it as the event field of the POST body.
  4. 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.

javascript
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.

nginx
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.

nginx
# 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.

terminal
$pm2 start server.js --name lnbits-proxy
$pm2 save
$pm2 startup
$pm2 logs lnbits-proxy

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.

ini
# /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.target

The 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.

text
*/5 * * * * /usr/bin/node /srv/monitor/monitor.mjs >> /srv/monitor/cron.log 2>&1

Stack 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.

RouteRequests / minute
/api/nwc/connections60
/api/provision/challenge10
/api/provision5
/api/claim-username3
/api/release-username3

Lightning Address usernames

Usernames must be 3 to 30 characters, lowercase alphanumeric with dots, hyphens and underscores, starting and ending with an alphanumeric character.

javascript
/^[a-z0-9][a-z0-9._-]{1,28}[a-z0-9]$/

These names are blocked:

text
admin  support  help  info  noreply
postmaster  webmaster  abuse  root  system

NWC 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