ngx_l402 — L402 Nginx Module
An L402 authentication module for Nginx that enables Lightning Network-based monetization for your REST APIs (HTTP/1 and HTTP/2).
It supports the following Lightning backends:
| Backend | Description |
|---|---|
| LND | Lightning Network Daemon (direct gRPC) |
| LNC | Lightning Node Connect (remote LND via mailbox) |
| CLN | Core Lightning |
| Eclair | Eclair node |
| LNURL | Lightning Network URL |
| NWC | Nostr Wallet Connect |
| BOLT12 | Reusable Lightning Offers |
The module can be configured to charge per unique API call, enabling per-endpoint monetization based on request paths.
How It Works
graph TD;
A[Request Received] --> B{Endpoint L402 Enabled?}
B -->|No| C[Return 200 OK]
B -->|Yes| D{"Any auth header present? (L402 or X-Cashu)"}
D -->|No| F[Generate L402 Header macaroon & invoice]
D -->|Yes| K["Parse L402 macaroon/preimage or X-Cashu (if present)"]
F --> G{Header Generation Success?}
G -->|No| I[Return 500 Internal Server Error]
G -->|Yes| H[Add WWW-Authenticate Header]
H --> J[Return 402 Payment Required]
K --> L{Parse Success?}
L -->|No| Q[Return 401 Unauthorized]
L -->|Yes| AD{"Auto-detect enabled AND no preimage in header?"}
AD -->|Yes| ND[Query Lightning node for settled invoice]
ND --> NS{Invoice settled?}
NS -->|No| NR[Return 402 Payment Required]
NS -->|Yes| NV["Verify macaroon signature (preimage from node)"]
NV -->|Valid| P[Return 200 OK]
NV -->|Invalid| Q[Return 401 Unauthorized]
AD -->|No / preimage provided| N["Verify macaroon/preimage OR Cashu proofs (whitelist; P2PK lock if enabled; double-spend check; amount >= price)"]
N --> O{Verification Success?}
O -->|No| Q
O -->|Yes| P
Auto-detect: When
l402_auto_detect_payment onis set and the client sends onlyAuthorization: L402 <macaroon>(no preimage), the server queries the Lightning node directly. Supported on LND, CLN, BOLT12, Eclair, and NWC wallets that implementlookup_invoice— see the support matrix.
Response Codes
| Status | When |
|---|---|
200 | Payment verified — the upstream response is returned |
402 | No credential presented, or auto-detect found the invoice unpaid. Carries the WWW-Authenticate L402 challenge, and X-Cashu when Cashu is enabled |
401 | A credential was presented and failed: malformed, tampered, replayed, or the preimage does not match. Carries WWW-Authenticate: L402; retry without a credential for a fresh challenge |
400 | A Cashu token from an unlisted mint, in the wrong unit, or below the price |
429 | Invoice rate limit hit (l402_invoice_rate_limit) |
500 | The gateway failed — an unreachable mint, Lightning node or Redis, or a failed database write, not a problem with your payment |
503 | A Lightning credential arrived while REDIS_URL is set but Redis is unreachable; retry once it is back |
402 only asks for payment: the initial challenge, or an auto-detect invoice
not paid yet. A credential that fails is 401, never 402 — the L402
specification
requires this so clients can tell “you need to pay” from “your credential is
broken”. The 400 cases are the ones
NUT-24 names.
A 500 on a request that carried a Cashu token leaves the token’s fate unknown:
swapping it at the mint and recording the proofs are separate steps, so a failure
between them may have spent the token or not touched it. Treat it as an outage
rather than a rejected payment, and don’t discard the token. Other 500s — a
missing price, an uninitialised module — are unrelated to payment.
Quick Start
Note: This module requires NGINX version 1.28.0 or later.
The fastest way to get started is with Docker:
docker run -d \
--name l402-nginx \
-p 8000:8000 \
-e LN_CLIENT_TYPE=LNURL \
-e LNURL_ADDRESS=username@your-lnurl-server.com \
-e ROOT_KEY=your-32-byte-hex-key \
ghcr.io/ngx-l402/ngx-l402:latest
Then test it:
# Should return 200 OK
curl http://localhost:8000/
# Should return 402 Payment Required with L402 header
curl -i http://localhost:8000/protected
See the Installation section for full setup options.
Manual Installation
Note: This module requires NGINX version 1.28.0 or later. Earlier versions will cause module version mismatch errors.
Steps
1. Download the Module
Download libngx_l402_lib.so from the latest release and copy it to your Nginx modules directory:
sudo cp libngx_l402_lib.so /etc/nginx/modules/
2. Load the Module in nginx.conf
load_module /etc/nginx/modules/libngx_l402_lib.so;
3. Enable L402 for Specific Locations
location /protected {
root /usr/share/nginx/html;
index index.html index.htm;
# L402 module directives:
l402 on;
l402_amount_msat_default 10000;
# Note: Dynamic pricing is handled via Redis using the request path as key
# Example: SET /protected 15000 (sets price to 15000 msats for /protected endpoint)
l402_macaroon_timeout 3600; # Macaroon validity in seconds, set to 0 to disable timeout
# Optional: per-location LNURL address for multi-tenant setups
# l402_lnurl_addr "tenant@your-lnurl-server.com";
}
4. Set Environment Variables
Set the following in nginx.service (typically /lib/systemd/system/nginx.service).
See Environment Variables for the complete reference.
5. Set Up SQLite Database Directory (if using Cashu)
# One-time setup — persists across restarts
sudo mkdir -p /var/lib/nginx
# Root owns it, nginx writes through the group, and the sticky bit keeps nginx
# away from the root-owned wallet phrase — see Cashu eCash
sudo chown root:nginx /var/lib/nginx
sudo chmod 1770 /var/lib/nginx
The
cdk-sqlitecrate automatically creates the database file and tables on first run. Database location:/var/lib/nginx/cashu_tokens.db
6. Restart Nginx
sudo systemctl restart nginx
macOS Local Setup Guide
This guide is for contributors running ngx_l402 locally on macOS.
1. Prerequisites
- Docker — make sure the Docker daemon is running.
- Rust — install via rustup if not already present:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
2. Clone and enter the repository
git clone https://github.com/ngx-l402/ngx-l402.git
cd ngx-l402
3. Start the stack
Copy the example env file and start the services:
cp .env.example .env
docker compose up -d bitcoind lndnode-receiver redis grpc-content-server nginx-lnd
The first run compiles the module inside a Linux container (multi-stage Dockerfile), so it will take a few minutes. Subsequent runs use the Docker build cache.
Fund the regtest LND node so it can create invoices:
docker exec bitcoind bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass createwallet miner 2>/dev/null
docker exec bitcoind bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass -rpcwallet=miner generatetoaddress 101 \
$(docker exec bitcoind bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass -rpcwallet=miner getnewaddress) > /dev/null
sleep 5
4. Verify
curl -i http://localhost:8000/protected
Expected: 402 Payment Required with a WWW-Authenticate: L402 ... header. If the request hangs, wait a few seconds for LND to finish syncing and try again.
5. Development workflow
After editing the Rust source, rebuild and restart nginx:
docker compose build nginx-lnd && docker compose up -d nginx-lnd
Dependencies are cached, so only the module recompiles (~20 seconds).
You can also run cargo check locally for fast feedback from your editor without rebuilding the container.
6. Useful commands
docker compose ps # container status
docker logs nginx-lnd -f # nginx logs
docker compose down # stop stack
Docker Installation
The easiest way to deploy the L402 Nginx module is with our official Docker images.
docker pull ghcr.io/ngx-l402/ngx-l402:latest
Quick Start Examples
1. LNURL Backend (Simplest Setup)
docker run -d \
--name l402-nginx \
-p 8000:8000 \
-e LN_CLIENT_TYPE=LNURL \
-e LNURL_ADDRESS=username@your-lnurl-server.com \
-e ROOT_KEY=your-32-byte-hex-key \
ghcr.io/ngx-l402/ngx-l402:latest
2. LND Backend with Cashu Support
mkdir -p ~/l402-data
cp ~/.lnd/data/chain/bitcoin/mainnet/admin.macaroon ~/l402-data/
cp ~/.lnd/tls.cert ~/l402-data/
docker run -d \
--name l402-nginx \
-p 8000:8000 \
-e LN_CLIENT_TYPE=LND \
-e LND_ADDRESS=your-lnd-ip:10009 \
-e MACAROON_FILE_PATH=/app/data/admin.macaroon \
-e CERT_FILE_PATH=/app/data/tls.cert \
-e ROOT_KEY=your-32-byte-hex-key \
-e CASHU_ECASH_SUPPORT=true \
-e CASHU_WALLET_MNEMONIC="word1 word2 ... word12" \
-e CASHU_DB_PATH=/app/data/cashu_tokens.db \
-e CASHU_WHITELISTED_MINTS=https://mint1.example.com,https://mint2.example.com \
-e CASHU_REDEEM_ON_LIGHTNING=true \
-e REDIS_URL=redis://your-redis-host:6379 \
-v ~/l402-data:/app/data \
ghcr.io/ngx-l402/ngx-l402:latest
3. LND via Lightning Node Connect (LNC)
# Generate a pairing phrase from Lightning Terminal first:
# litcli sessions add --label="nginx-l402" --type=admin
docker run -d \
--name l402-nginx \
-p 8000:8000 \
-e LN_CLIENT_TYPE=LND \
-e LNC_PAIRING_PHRASE="word1 word2 word3 word4 word5 word6 word7 word8 word9 word10" \
-e LNC_MAILBOX_SERVER=mailbox.terminal.lightning.today:443 \
-e ROOT_KEY=your-32-byte-hex-key \
ghcr.io/ngx-l402/ngx-l402:latest
4. CLN Backend (Core Lightning)
docker run -d \
--name l402-nginx \
-p 8000:8000 \
-e LN_CLIENT_TYPE=CLN \
-e CLN_LIGHTNING_RPC_FILE_PATH=/app/data/lightning-rpc \
-e ROOT_KEY=your-32-byte-hex-key \
-e CASHU_ECASH_SUPPORT=true \
-e CASHU_WALLET_MNEMONIC="word1 word2 ... word12" \
-e CASHU_DB_PATH=/app/data/cashu_tokens.db \
-v ~/.lightning/bitcoin/lightning-rpc:/app/data/lightning-rpc:ro \
ghcr.io/ngx-l402/ngx-l402:latest
5. NWC Backend (Nostr Wallet Connect)
docker run -d \
--name l402-nginx \
-p 8000:8000 \
-e LN_CLIENT_TYPE=NWC \
-e NWC_URI=nostr+walletconnect://your-pubkey?relay=wss://relay.damus.io&secret=your-secret \
-e ROOT_KEY=your-32-byte-hex-key \
ghcr.io/ngx-l402/ngx-l402:latest
6. High-Performance P2PK Mode (Recommended for Production)
docker run -d \
--name l402-nginx \
-p 8000:8000 \
-e LN_CLIENT_TYPE=LND \
-e LND_ADDRESS=your-lnd-ip:10009 \
-e MACAROON_FILE_PATH=/app/data/admin.macaroon \
-e CERT_FILE_PATH=/app/data/tls.cert \
-e ROOT_KEY=your-32-byte-hex-key \
-e CASHU_ECASH_SUPPORT=true \
-e CASHU_P2PK_MODE=true \
-e CASHU_P2PK_PRIVATE_KEY=your-32-byte-hex-private-key \
-e CASHU_WALLET_MNEMONIC="word1 word2 ... word12" \
-e CASHU_DB_PATH=/app/data/cashu_tokens.db \
-e CASHU_WHITELISTED_MINTS=https://mint1.example.com \
-e CASHU_REDEEM_ON_LIGHTNING=true \
-e REDIS_URL=redis://your-redis-host:6379 \
-v ~/l402-data:/app/data \
ghcr.io/ngx-l402/ngx-l402:latest
7. BOLT12 Backend (Reusable Offers)
docker run -d \
--name l402-nginx \
-p 8000:8000 \
-e LN_CLIENT_TYPE=BOLT12 \
-e BOLT12_OFFER=lno1... \
-e CLN_LIGHTNING_RPC_FILE_PATH=/app/data/lightning-rpc \
-e ROOT_KEY=your-32-byte-hex-key \
-v ~/.lightning/bitcoin/lightning-rpc:/app/data/lightning-rpc:ro \
ghcr.io/ngx-l402/ngx-l402:latest
8. Eclair Backend
docker run -d \
--name l402-nginx \
-p 8000:8000 \
-e LN_CLIENT_TYPE=ECLAIR \
-e ECLAIR_ADDRESS=http://your-eclair-node:8282 \
-e ECLAIR_PASSWORD=your-eclair-password \
-e ROOT_KEY=your-32-byte-hex-key \
ghcr.io/ngx-l402/ngx-l402:latest
Generating Required Secrets
# ROOT_KEY (required for all setups)
openssl rand -hex 32
# CASHU_WALLET_MNEMONIC (for Cashu support): a BIP39 phrase, NOT a random hex.
# Leave it unset to have one generated and saved beside the DB on first run,
# or generate a 12/24-word phrase with any BIP39 tool.
# CASHU_P2PK_PRIVATE_KEY (for P2PK mode)
openssl rand -hex 32
Testing Your Setup
# Test free endpoint
curl http://localhost:8000/
# Test protected endpoint (should return 402 with L402 header)
curl -i http://localhost:8000/protected
# Check container logs
docker logs l402-nginx -f
# Stop the container
docker stop l402-nginx
Specific Versions
docker pull ghcr.io/ngx-l402/ngx-l402:<version> # e.g. 1.3.0
See the available tags.
Production Deployment (TLS)
The install examples run ngx-l402 as plain HTTP on :8000 and assume something in
front terminates TLS. But the official image is built on the nginx image, which
ships http_ssl_module, so nginx can terminate TLS itself — no separate
reverse proxy needed. This page shows the production pattern: serve :443 with a
Let’s Encrypt certificate that renews automatically.
TLS server block
Point nginx at the certificate and enable your paywalled location on :443:
server {
listen 443 ssl;
server_name blob.example.com;
ssl_certificate /etc/letsencrypt/live/blob.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/blob.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location /protected {
l402 on;
l402_amount_msat_default 1000;
proxy_pass http://your-upstream;
}
}
Issuing and renewing the certificate
Run certbot alongside nginx to obtain the cert (standalone, on port 80) and
renew it on a schedule. Share the /etc/letsencrypt volume between the two:
certbot writes the cert, nginx reads it.
services:
certbot:
image: certbot/certbot
ports: ["80:80"]
volumes: ["./certbot/conf:/etc/letsencrypt"]
# obtain once, then renew twice a day
entrypoint: >
sh -c "certbot certonly --standalone -n --agree-tos -m you@example.com
-d blob.example.com || true;
while :; do certbot renew; sleep 12h; done"
nginx-l402:
image: ghcr.io/ngx-l402/ngx-l402:latest
ports: ["443:443"]
volumes: ["./certbot/conf:/etc/letsencrypt:ro"] # nginx reads the cert
# ... LN_CLIENT_TYPE, ROOT_KEY, etc.
Picking up renewed certificates
nginx loads the certificate into memory at startup and won’t see a renewed one until it reloads. The simplest robust approach is a periodic reload — wrap nginx so it reloads every few hours, then runs in the foreground:
{ while :; do sleep 6h & wait ${!}; nginx -s reload; done & nginx -g 'daemon off;'; }
This does not cause downtime, for two reasons:
- Let’s Encrypt renews ~30 days before expiry (certbot’s default). So when the new cert appears, nginx is still holding one with ~30 days left — the ≤6h reload delay is nowhere near expiry, so no request ever meets an expired certificate.
nginx -s reloadis graceful: it starts new workers with the new cert and drains the old ones, so the reload itself drops no connections.
For zero staleness you can instead reload the instant a cert renews, via a certbot
--deploy-hook — but that has to signal nginx across container boundaries, so the
periodic reload is the simpler choice and, given the 30-day margin, just as safe.
A complete working reference
The paywalled-blossom
example wires all of this up end to end — TLS on :443, a certbot sidecar with
auto-renewal, and the reload loop — as a docker compose up -d deployment. Start
from it rather than assembling by hand.
Environment Variables
All configuration is done via environment variables set in nginx.service (typically at /lib/systemd/system/nginx.service).
They are read once, when nginx starts. nginx -s reload keeps the old values, so restart nginx after changing one.
[Service]
...
Environment=VAR_NAME=value
Lightning Client Type
| Variable | Required | Description |
|---|---|---|
LN_CLIENT_TYPE | — | One of: LND, CLN, LNURL, NWC, BOLT12, ECLAIR; defaults to LNURL. For LNC, use LND with LNC_PAIRING_PHRASE. nginx refuses to start on any other value |
Root Key
| Variable | Required | Description |
|---|---|---|
ROOT_KEY | ✅ | Secret that signs macaroons, at least 32 characters (openssl rand -hex 32). nginx refuses to start without it, and changing it invalidates every token already issued |
LND (Direct gRPC)
Environment=LN_CLIENT_TYPE=LND
Environment=LND_ADDRESS=your-lnd-ip.com
Environment=MACAROON_FILE_PATH=/path/to/macaroon
Environment=CERT_FILE_PATH=/path/to/cert
Environment=ROOT_KEY=your-root-key
nginx’s worker user (nginx in the shipped config) must be able to read
MACAROON_FILE_PATH and CERT_FILE_PATH.
LND via Lightning Node Connect (LNC)
Environment=LN_CLIENT_TYPE=LND
Environment=LNC_PAIRING_PHRASE=<10-word-mnemonic-from-litd>
Environment=LNC_MAILBOX_SERVER=mailbox.terminal.lightning.today:443
Environment=ROOT_KEY=your-root-key
CLN (Core Lightning)
Environment=LN_CLIENT_TYPE=CLN
Environment=CLN_LIGHTNING_RPC_FILE_PATH=/path/to/lightning-rpc
Environment=ROOT_KEY=your-root-key
nginx’s worker user must be able to reach the socket: share a group with CLN and
start it with rpc-file-mode=0660. Avoid 0666 outside a test setup — it lets
every local user control the node.
LNURL
Environment=LN_CLIENT_TYPE=LNURL
Environment=LNURL_ADDRESS=username@your-lnurl-server.com
Environment=ROOT_KEY=your-root-key
The username must match the LUD-16 charset (a-z 0-9 - _ .); addresses like user+tag@example.com are rejected since l402_middleware 2.3.4.
NWC (Nostr Wallet Connect)
Environment=LN_CLIENT_TYPE=NWC
Environment=NWC_URI=nostr+walletconnect://<pubkey>?relay=<relay_url>&secret=<secret>
Environment=ROOT_KEY=your-root-key
BOLT12 (Reusable Offers)
Environment=LN_CLIENT_TYPE=BOLT12
Environment=BOLT12_OFFER=lno1...
Environment=CLN_LIGHTNING_RPC_FILE_PATH=/path/to/lightning-rpc
Environment=ROOT_KEY=your-root-key
How it works: When a client requests a protected resource, the module connects to your CLN node via the Unix socket at
CLN_LIGHTNING_RPC_FILE_PATHand callsfetchinvoiceto get a new BOLT12 invoice for each request from the reusable offer. The node resolves the offer’s embedded node ID and negotiates the payment parameters over the Lightning network automatically.CLN_LIGHTNING_RPC_FILE_PATHis therefore required alongsideBOLT12_OFFER, with the same socket access asCLN.
Eclair
Environment=LN_CLIENT_TYPE=ECLAIR
Environment=ECLAIR_ADDRESS=http://127.0.0.1:8282
Environment=ECLAIR_PASSWORD=eclairpass # REQUIRED — no default; module disables auto-detect if unset
Environment=ROOT_KEY=your-root-key
⚠️ Security:
ECLAIR_PASSWORDis required and has no default value. If it is not set the Eclair payment-detector is disabled at startup and an error is logged. Never use a well-known or placeholder password in production.
Redis (Dynamic Pricing & Replay Protection)
Strongly recommended in production. Without Redis, replay protection uses in-process caching only — it is lost on restart and does not work across multiple nginx workers. Multi-worker deployments require Redis.
Not configuring Redis and Redis being down are different. Leaving
REDIS_URLunset is a choice, and the module degrades to the per-worker cache above. AREDIS_URLthat is set but unreachable is an outage: Lightning credentials are refused with 503 and P2PK Cashu tokens with 500 until Redis returns, since an attacker who can take Redis down would otherwise reuse a single payment without limit. Standard-mode Cashu tokens are still accepted: the mint swap already rejects a spent token.
Environment=REDIS_URL=redis://127.0.0.1:6379
# Connection pool size (default: 4)
Environment=REDIS_POOL_SIZE=4
# TTL for spent Lightning preimages (default: 86400 = 24 hours)
Environment=L402_PREIMAGE_TTL_SECONDS=86400
# TTL for spent Cashu tokens (default: 86400 = 24 hours)
Environment=L402_CASHU_TOKEN_TTL_SECONDS=86400
Setting TTL to “infinite” (permanent replay protection)
A preimage marker already never expires on routes with l402_macaroon_timeout 0,
and otherwise outlives the macaroon. Cashu markers always expire after
L402_CASHU_TOKEN_TTL_SECONDS. For permanent protection, set a very large value:
# ~68 years — effectively permanent
Environment=L402_PREIMAGE_TTL_SECONDS=2147483647
Environment=L402_CASHU_TOKEN_TTL_SECONDS=2147483647
Trade-off: Permanent keys accumulate in Redis indefinitely. For a busy API with many unique tokens this will grow Redis memory over time. Size each key at ~100 bytes; 1 million spent tokens ≈ 100 MB.
Do not set
0: Redis rejectsEX 0, so every P2PK Cashu token is refused with500, and auto-detect stops caching settled preimages.
Cashu eCash
Environment=CASHU_ECASH_SUPPORT=true
Environment=CASHU_DB_PATH=/var/lib/nginx/cashu_tokens.db
# BIP39 wallet mnemonic (the Cashu/NUT-13 backup phrase). Leave unset to have one
# generated and saved next to the DB on first run (check the logs for the phrase).
Environment=CASHU_WALLET_MNEMONIC="word1 word2 ... word12"
# Optional: where to persist a generated mnemonic (defaults beside the DB file)
# Environment=CASHU_WALLET_MNEMONIC_FILE=/var/lib/nginx/wallet.mnemonic
# Optional: Whitelist specific mints (comma-separated)
# In standard mode: if not set, all mints are accepted
# In P2PK mode: REQUIRED for security and NUT-24 payment request
Environment=CASHU_WHITELISTED_MINTS=https://mint1.example.com,https://mint2.example.com
# Optional: Auto-redeem Cashu tokens to Lightning
Environment=CASHU_REDEEM_ON_LIGHTNING=true
Environment=CASHU_REDEMPTION_INTERVAL_SECS=3600 # default: 1 hour
⚠️ Security:
CASHU_WALLET_MNEMONICis the BIP39 phrase that derives the wallet seed (NUT-13). It is the only backup of your wallet — anyone with it can steal your tokens, and losing it loses the funds!
- 12 or 24 English words; restorable in any NUT-13 wallet (nutshell, cashu-ts, cdk-cli)
- If unset, one is generated and saved beside the DB on first run — back it up
- On startup the module records a fingerprint of the seed next to the DB and refuses to start if a later mnemonic doesn’t match (so a changed/typo’d phrase can’t silently orphan a funded wallet); delete the
wallet.fingerprintfile to switch wallets intentionally- Never commit it to Git; keep it in a secrets manager
- A phrase or fingerprint file is trusted only if the user nginx’s master runs as owns it and no one else can write it; anything else is refused. Keep its directory root-owned and sticky (
chown root:nginx,chmod 1770), as the Docker image does: nginx then writes the database but can’t delete, rename or replace the phrase. The module warns at startup when another user could delete or rename one.CASHU_WALLET_MNEMONIC_FILEmay be a symlink, as Kubernetes secret mounts are; the defaultwallet.mnemonicand the fingerprint may not
Redemption Fee Handling
# Minimum balance to attempt melting (default: 10 sats)
Environment=CASHU_MELT_MIN_BALANCE_SATS=10
# Percentage to reserve for fees (default: 1%)
Environment=CASHU_MELT_FEE_RESERVE_PERCENT=1
# Minimum fee reserve when percentage is small (default: 4 sats)
Environment=CASHU_MELT_MIN_FEE_RESERVE_SATS=4
# Maximum proofs per melt operation (default: 0 = unlimited)
# Logic: if proof_count > limit, select first N proofs, rest remain for next cycle
# Use case: prevent hitting mint proof limits (e.g. mint.coinos.io has 1000 proof limit)
Environment=CASHU_MAX_PROOFS_PER_MELT=1000
P2PK Mode (High Performance)
Environment=CASHU_P2PK_MODE=true
Environment=CASHU_P2PK_PRIVATE_KEY=<your-private-key-hex>
# Public key is derived automatically from the private key
# CASHU_WHITELISTED_MINTS is REQUIRED in P2PK mode
Environment=CASHU_REQUIRE_DLEQ=true # default: true — keep it on
⚠️ Security:
CASHU_P2PK_PRIVATE_KEYis equally critical. Anyone with this key can spend tokens locked to your public key!
- Generate with:
openssl rand -hex 32- Never commit to Git or share publicly
- Keep it secure alongside
CASHU_WALLET_MNEMONIC
🔒 NUT-12 DLEQ (
CASHU_REQUIRE_DLEQ): In P2PK mode, tokens are verified on a fast path that skips the mint swap for lower latency. DLEQ proofs (NUT-12) are what let us confirm offline — using only cached mint keysets — that each proof was actually signed by the whitelisted mint. With this check off, a forger could submit correctly-shaped, mint-whitelisted proofs that the mint never signed and get free service (the operator only finds out at redemption time, when the melt fails).
- Default
true: a proof with no DLEQ data is rejected. Modern Cashu wallets include DLEQ by default, so this is safe.- Set
CASHU_REQUIRE_DLEQ=falseonly as a temporary safety valve if a real-world wallet ships DLEQ-less tokens. This is insecure and re-opens the forged-proof window above.- The standard (non-P2PK) mode is unaffected: its mint swap already validates proofs authoritatively.
See Cashu eCash for a full explanation of Standard vs P2PK mode and redemption fee examples.
LND via SOCKS5 / Tor proxy
# [Optional] Route LND gRPC through a SOCKS5 proxy
Environment=SOCKS5_PROXY=socks5://127.0.0.1:9050
Capability Manifest Metadata
These optional variables populate the service block in /.well-known/l402-services.
All are omitted from the manifest JSON when unset.
Environment=L402_SERVICE_NAME=My API
Environment=L402_SERVICE_DESCRIPTION=Premium data, paid per request.
Environment=L402_SERVICE_OPERATOR=npub1... # Nostr pubkey, DID, or free-form
Environment=L402_SERVICE_CONTACT=ops@example.com
See Capability Manifest for the full manifest spec.
Logging
Environment=RUST_LOG=info
# For module-specific debug logs:
Environment=RUST_LOG=ngx_l402_lib=debug,info
# Log per-request performance timing at debug level (only `true` enables it)
Environment=L402_PERF_LOG=true
Nginx Location Directives
These are set inside location {} blocks in nginx.conf (not environment variables).
| Directive | Type | Default | Description |
|---|---|---|---|
l402 | boolean¹ | off | Enable L402 protection for this location |
l402_amount_msat_default | integer | — | Price in millisatoshis (overridden by Redis dynamic pricing). Cashu payment requests carry a whole number of sats, so a sub-sat price is advertised rounded up while Lightning is charged exactly; the module warns at startup when the two diverge |
l402_macaroon_timeout | integer (seconds) | 0 (disabled) | Macaroon validity window; 0 = no expiry |
l402_lnurl_addr | string | — | Per-location LNURL address for multi-tenant setups |
l402_invoice_rate_limit | <N>r/s, <N>r/m, <N>r/h, or <N> (per minute) | disabled | Max invoice generation rate per IP per route |
l402_auto_detect_payment | boolean¹ | off | Server-side payment detection — queries the Lightning node instead of requiring the client to supply the preimage |
l402_indefinite_access | boolean¹ | off | Skip the single-use preimage replay check — a single payment stays valid for the macaroon lifetime |
l402_realm | string | — | Bind the macaroon to a named protection space instead of the exact request path, so one payment authorizes every location sharing the name |
l402_exempt_methods | one or more HTTP methods | — | Methods served without payment, e.g. HEAD; nested locations inherit it |
l402_dry_run | on or off | off | Log and count what would be blocked, without blocking — see dry-run.md |
l402_metrics | no arguments | — | Serve Prometheus counters from this location; the shipped nginx.conf allows only localhost — see dry-run.md |
l402_manifest | no arguments | — | Serve the JSON manifest of this server’s paid routes, normally at /.well-known/l402-services — see manifest.md |
l402_manifest_hide | no arguments | — | Leave this location out of the manifest |
l402_log_format | json or text | text | Emit one structured JSON line per L402 access event (verify, challenge, challenge error, rate-limited) — see logging.md |
l402_payment_html | boolean¹ | on | Serve the browser payment page with a 402. Turn it off for API and agent routes to return the challenge headers with an empty body |
¹ Boolean directives accept:
on/off/true/false/1/0/yes/no(case-insensitive).
API routes: skipping the payment page
A 402 normally carries a full HTML page — QR code, copy button, Cashu tab —
for a human paying in a browser. An API client or AI agent reads
WWW-Authenticate and discards the body, so on those routes the page is wasted
bytes on every unpaid request.
location /v1/ {
l402 on;
l402_amount_msat_default 10000;
l402_payment_html off; # headers only
proxy_pass http://upstream;
}
With it off the response is still a 402 and still carries
WWW-Authenticate (and X-Cashu when Cashu is enabled) — only the body is
dropped. Nothing about the payment flow changes; clients that already parse the
header behave identically.
It inherits into nested locations and follows the usual child-wins rule, so an
inner location can turn the page back on under an outer off.
Rate limiting behind a proxy
l402_invoice_rate_limit buckets by the connection’s source address. It
deliberately ignores X-Real-IP and X-Forwarded-For, because a client can set
those itself — keying on one would let anyone mint a fresh bucket per request
and bypass the limit entirely.
If nginx sits behind a load balancer or CDN, every request arrives from the proxy’s address and would share a single bucket. Configure nginx’s own realip module so the real client address is substituted before the L402 access phase runs:
set_real_ip_from 10.0.0.0/8; # your proxy's range — and only your proxy's
real_ip_header X-Real-IP; # or X-Forwarded-For
Listing the trusted ranges is what makes the header safe to believe, and it keeps that decision with the operator, who knows the topology.
If a rate-limited request arrives carrying an X-Real-IP (or an
X-Forwarded-For whose leftmost entry) that does not match the connection
address, the module logs this once per worker so the misconfiguration is visible
before users start hitting limits they shouldn’t:
l402_invoice_rate_limit is bucketing by connection address 10.0.0.7, but this
request carried X-Real-IP: 203.0.113.5. If 10.0.0.7 is your proxy, configure
`set_real_ip_from 10.0.0.7;` with `real_ip_header X-Real-IP;` — otherwise every
client behind it shares one bucket. If nothing proxies to you, a client set that
header itself and this is safe to ignore; the header is never trusted directly,
which is why you are seeing this. Logged once per worker.
On a server with no proxy in front of it anyone can trigger that line by sending
the header, and it names their address rather than a proxy’s — so treat it as a
prompt to check your topology, not as a set_real_ip_from value to paste. If
you are not behind a proxy, ignore it: the rate limit is already keyed correctly.
Realm-scoped access
By default a macaroon is bound to the exact path it was minted for: paying for
/a gives you /a and nothing else. l402_realm replaces that binding with a
named protection space, so one payment covers every location that names it.
location /library/preview {
l402 on;
l402_amount_msat_default 10000;
l402_macaroon_timeout 300;
l402_realm "library";
l402_indefinite_access on;
try_files $uri $uri/index.html =404;
}
location /library/full {
l402 on;
l402_amount_msat_default 10000;
l402_macaroon_timeout 300;
l402_realm "library";
l402_indefinite_access on;
try_files $uri $uri/index.html =404;
}
Three things to know before using it:
l402_indefinite_access onis required. The client presents the same preimage on every path in the realm, and the single-use replay check would reject the second request. The module rejects the combination at config-parse time, so nginx will not start without it.- The realm is not bound to a price. Two locations sharing a name with
different
l402_amount_msat_defaultmeans a token bought at the cheaper one opens the dearer one. Give differently-priced content different realm names. - The name is the whole boundary. Anything naming that realm is reachable
with one payment, so prefer specific names (
library-2026) over generic ones (api), and setl402_macaroon_timeoutto bound how long access lasts.
The HTTP method is still bound in realm mode: a GET token will not satisfy a
POST.
See Realms for the full treatment — inheritance, nested realms, and what stays bound.
Example: auto-detect enabled location
location /protected {
l402 on;
l402_amount_msat_default 10000;
l402_macaroon_timeout 0;
l402_auto_detect_payment on;
try_files /index.html =404;
}
Example: subscription-style (indefinite) access
location /subscriber-only {
l402 on;
l402_amount_msat_default 100000;
l402_macaroon_timeout 2592000; # 30 days
l402_indefinite_access on; # single payment stays valid until macaroon expires
try_files /index.html =404;
}
Warning:
l402_indefinite_access onshould always be paired with a non-zerol402_macaroon_timeout. Without an expiry, the macaroon never expires and the same preimage grants access forever.
Backends that support auto-detect:
LND,CLN,BOLT12,ECLAIR, andNWCwhere the wallet implements the optional NIP-47lookup_invoice.LNDover LNC andLNURLdo not support server-side lookup.
Redis & Dynamic Configuration
The module supports real-time configuration updates via Redis without requiring an Nginx reload.
Setup
Environment=REDIS_URL=redis://127.0.0.1:6379
Dynamic Pricing
Set the price for a specific path in Redis. Changes are picked up immediately by the next request.
# Set price to 1000 msats for /api/resource
SET /api/resource 1000
# Set price to 5000 msats for /api/premium
SET /api/premium 5000
Note: If no Redis key exists for a path, the module falls back to
l402_amount_msat_defaultinnginx.conf.
Dynamic LNURL (Per-Tenant Routing)
Override the LNURL address for a specific request path. This takes precedence over l402_lnurl_addr in nginx.conf.
Key format: lnurl:<request_path>
# Route /api/tenant1 payments to alice
SET lnurl:/api/tenant1 alice@getalby.com
# Route /api/tenant2 payments to bob
SET lnurl:/api/tenant2 bob@getalby.com
Note
These keys set the payout destination for a route — for Lightning invoices and for Cashu redemption, where a token accepted on a route is melted to the LNURL that route maps to. Redis is a trusted component here: bind it to a private interface, set
requirepass, and limit write access to tenant configuration.
Replay Attack Prevention
Redis is used to enforce single-use of L402 preimages and Cashu tokens, preventing replay attacks across distributed deployments.
Environment=REDIS_URL=redis://127.0.0.1:6379
Environment=L402_PREIMAGE_TTL_SECONDS=86400 # Default: 24 hours
Environment=L402_CASHU_TOKEN_TTL_SECONDS=86400 # Default: 24 hours
How it works: After successful verification, a SHA-256 hash of the preimage or token is stored in Redis, and reusing the credential is rejected with 401. Protection persists across Nginx restarts and works with multiple Nginx instances.
A preimage’s marker lives for L402_PREIMAGE_TTL_SECONDS or the macaroon’s own timeout, whichever is longer. On routes with l402_macaroon_timeout 0 it never expires, because neither does the macaroon. A Cashu token’s marker lives for L402_CASHU_TOKEN_TTL_SECONDS.
Invoice Rate Limiting
Limits how many invoices (402 responses) a single IP can request per route within a time window. This protects your Lightning node from invoice-spam without affecting clients that hold a valid token.
location /api/resource {
l402 on;
l402_amount_msat_default 1000;
l402_invoice_rate_limit 5r/m; # 5 invoices per minute per IP
}
Supported formats:
| Value | Limit |
|---|---|
5r/m | 5 per minute |
10r/h | 10 per hour |
2r/s | 2 per second |
5 | 5 per minute (shorthand) |
Requests that exceed the limit receive 429 Too Many Requests with a Retry-After header set to the window duration.
The rate limit only applies to unauthenticated requests (those that would result in a 402). Requests presenting a valid L402 token bypass it entirely.
How it works: Uses a fixed-window Redis counter (INCR + EXPIRE on first hit) keyed by IP and path. Fails open — if Redis is unavailable, rate limiting is disabled and traffic passes through normally.
Keys the Module Writes
| Key | Holds | Expires |
|---|---|---|
<path> | Dynamic price you set | Never — you manage it |
lnurl:<path> | Per-route LNURL override you set | Never — you manage it |
l402:preimage:<sha256> | Spent Lightning preimage | See Replay Attack Prevention |
l402:cashu_token:<sha256> | Spent Cashu token: a hash of the token, or in P2PK mode of its proofs, so a re-encoded token still matches | L402_CASHU_TOKEN_TTL_SECONDS |
l402:settled:<payment hash> | Settled preimage cached by auto-detect | L402_PREIMAGE_TTL_SECONDS |
l402:invoice_rate:<sha256> | Invoice rate-limit counter for a client and route | The rate-limit window |
cashu:proof_lnurl:<sha256> | Which tenant a Cashu proof belongs to | 20 redemption intervals — see Multi-Tenant |
Run Redis with maxmemory-policy noeviction: an evicted replay marker makes its credential usable again.
Multi-Tenant Configuration
The module supports multi-tenant mode, allowing different API routes to use different Lightning/LNURL backends. This is useful for platforms hosting multiple merchants or services, where each tenant receives payments to their own wallet.
Current Support: Multi-tenant is currently supported for Cashu eCash payments only when using
LN_CLIENT_TYPE=LNURL.
How It Works
- Per-location LNURL addresses: Use the
l402_lnurl_addrdirective to specify a different LNURL address per Nginx location block. - Proof tracking: When a Cashu token is received, the proofs are mapped to the tenant’s LNURL address in Redis, in both standard and P2PK mode.
- Grouped redemption: The automatic redemption task groups proofs by tenant and redeems each group to the correct LNURL address.
If Redis is missing or down:
- No
REDIS_URL: nothing can be mapped, so every tenant’s proofs are redeemed toLNURL_ADDRESS. - Redis configured but unreachable: redemption pauses rather than guess, and the proofs stay unspent until Redis is back.
A mapping lives for 20 redemption intervals, between one and thirty days and never less than two intervals. Proofs still unredeemed after that go to LNURL_ADDRESS.
Nginx Configuration
# Tenant 1 — payments go to alice@getalby.com
location /api/tenant1 {
l402 on;
l402_amount_msat_default 10000;
l402_macaroon_timeout 0;
l402_lnurl_addr "alice@getalby.com";
}
# Tenant 2 — payments go to bob@getalby.com
location /api/tenant2 {
l402 on;
l402_amount_msat_default 15000;
l402_macaroon_timeout 0;
l402_lnurl_addr "bob@getalby.com";
}
# Tenant 3 — self-hosted LNURL server
location /api/tenant3 {
l402 on;
l402_amount_msat_default 5000;
l402_macaroon_timeout 0;
l402_lnurl_addr "user@your-lnurl-server.com";
}
Required Environment Variables
# Use LNURL client type
Environment=LN_CLIENT_TYPE=LNURL
# Default LNURL address (fallback when l402_lnurl_addr is not set)
Environment=LNURL_ADDRESS=default@your-domain.com
# Redis is required for proof-to-tenant mapping
Environment=REDIS_URL=redis://127.0.0.1:6379
# Enable Cashu eCash support
Environment=CASHU_ECASH_SUPPORT=true
Environment=CASHU_WALLET_MNEMONIC="word1 word2 ... word12"
Environment=CASHU_WHITELISTED_MINTS=https://mint.example.com
# Enable automatic redemption to Lightning
Environment=CASHU_REDEEM_ON_LIGHTNING=true
Environment=CASHU_REDEMPTION_INTERVAL_SECS=60
Dynamic LNURL Override via Redis
You can also override the LNURL address per path dynamically without reloading Nginx:
SET lnurl:/api/tenant1 alice@getalby.com
SET lnurl:/api/tenant2 bob@getalby.com
See Redis & Dynamic Config for more details.
Realms (One Payment, Many Paths)
By default a payment buys one exact URL. The macaroon carries a
RequestPath = /article/1 caveat, so the token that unlocked /article/1 is
rejected on /article/2 — the client pays again. That is the right model for
metered, per-resource pricing.
A realm changes the unit of sale. With l402_realm "name"; the macaroon
carries Realm = name instead of a path, and one payment authorizes every
path the server maps to that realm — a subscription or day-pass, rather than
a per-article charge.
Enabling a realm
location /premium/ {
l402 on;
l402_realm "premium"; # one payment covers this location
l402_indefinite_access on; # REQUIRED — see below
l402_macaroon_timeout 86400; # 24h pass
l402_amount_msat_default 50000;
proxy_pass http://upstream;
}
A client pays once at /premium/anything, then reuses the same
Authorization: L402 <macaroon>:<preimage> header across every path under
/premium/ until the macaroon expires.
The realm name
The name goes verbatim into the Realm = <name> caveat and is compared by exact
match. It must be non-empty and contain no whitespace or control characters —
nginx fails to start otherwise, rather than silently accepting an ambiguous
caveat:
l402_realm requires a non-empty name without whitespace
Pick a stable identifier (premium, api-tier-1). Changing the name
invalidates every token already issued under the old one.
l402_indefinite_access on is mandatory
A realm token carries one preimage to every path in the realm. Preimage replay protection is single-use by design: the first request claims the preimage, and every later request in the realm is rejected as a replay. The operator would have sold exactly one request.
So the module refuses the combination at config-parse time. Omit it and nginx will not start:
ngx_l402: l402_realm requires l402_indefinite_access on. Without it the realm
token is accepted once and every later request in the realm is rejected as a
replay.
This is a deliberate fail-closed check: the broken configuration is rejected loudly at startup instead of silently short-changing users at runtime.
Bounding a realm token
Because l402_indefinite_access on disables single-use replay protection, the
macaroon’s own lifetime becomes the only limit on how long a payment stays
valid. Always pair a realm with l402_macaroon_timeout:
l402_macaroon_timeout 86400; # 24 hours
With the default l402_macaroon_timeout 0; the macaroon never expires and a
single payment authorizes the realm forever.
What stays bound
Switching to a realm relaxes the path binding only. Everything else still holds:
| Caveat | Realm mode | Default (path) mode |
|---|---|---|
| Protection space | Realm = name | RequestPath = /exact |
| HTTP method | Bound — a GET token is rejected on POST | Bound |
| Expiry | ExpiresAt when l402_macaroon_timeout > 0 | Same |
All three are enforced by exact match, and the verifier explicitly rejects these predicates rather than letting them fall through — a token minted for one realm, path, or method can never validate against another.
The name is the whole boundary
Two things follow from the realm being a flat, name-keyed protection space:
- A realm is not bound to a price. If two locations share a name but set
different
l402_amount_msat_default, a token bought at the cheaper one opens the dearer one — the caveat records only the name. Give differently-priced content different realm names. - Anything naming the realm is reachable with one payment. Prefer specific
names (
library-2026) over generic ones (api), so a realm added later cannot accidentally join an existing protection space.
Inheritance
l402_realm inherits into nested locations. An inner location can join the
parent’s realm by inheriting it, or define its own to carve out a separate
protection space:
location /premium/ {
l402 on;
l402_realm "premium";
l402_indefinite_access on;
l402_macaroon_timeout 86400;
location /premium/vip/ {
l402_realm "premium-vip"; # separate space, separate payment
l402_amount_msat_default 200000;
}
}
A premium token is not accepted at /premium/vip/ — the Realm caveats
differ, so verification fails and the client is charged the VIP price.
Choosing between realm and path mode
| Use | When |
|---|---|
| Default (path) | Metered per-resource pricing — pay-per-article, pay-per-API-call, pay-per-download |
| Realm | Subscriptions and passes — one payment unlocks a whole section for a period |
Realms are opt-in. Locations without l402_realm keep exact-path binding.
Lightning Network Payments
ngx_l402 implements the L402 protocol, enabling API monetization via Lightning Network payments. When a client hits a protected endpoint without a valid token, the module responds with 402 Payment Required and a Lightning invoice. The client pays the invoice, receives a preimage, and presents it alongside the macaroon to gain access.
Supported Backends
Configure the backend via the LN_CLIENT_TYPE environment variable:
LN_CLIENT_TYPE | Description |
|---|---|
LND | Lightning Network Daemon — direct gRPC connection |
LND + LNC_PAIRING_PHRASE | Lightning Node Connect — remote LND via mailbox (no open port needed) |
CLN | Core Lightning |
ECLAIR | Eclair node |
LNURL | Lightning Network URL — delegate invoice generation to an LNURL server |
NWC | Nostr Wallet Connect |
BOLT12 | Reusable Lightning Offers (BOLT12) |
See Environment Variables for the full list of per-backend settings.
Each worker connects to the backend on the first request that needs it, so an unreachable node does not stop nginx from starting. While it is down, requests that need a new invoice return 500 (counted in l402_invoices_generation_errors_total), and so do auto-detect retries whose preimage isn’t cached in Redis. A full macaroon:preimage credential still verifies without the node.
Payment Flow
- Client requests a protected endpoint (no auth header).
- Module generates a macaroon and requests an invoice from the configured Lightning backend.
- Module responds
402 Payment Requiredwith:WWW-Authenticate: L402 macaroon="<macaroon>", invoice="<bolt11>" - Client pays the invoice and obtains the preimage.
- Client retries with:
Authorization: L402 <macaroon>:<preimage> - Module verifies the macaroon + preimage and returns
200 OK.
Authorization Header Format
Two formats are accepted:
| Format | Header value | When to use |
|---|---|---|
| Classic | L402 <macaroon>:<preimage_hex> | Client has the preimage (standard wallet flow) |
| Auto-detect | L402 <macaroon> | Server queries the node; no preimage needed from client |
The preimage in the classic format must be the 32-byte (256-bit) hex-encoded payment preimage corresponding to the invoice’s
payment_hash.
Auto-Detect Payment (Server-Side Settlement Lookup)
With auto-detect enabled the client only needs to send the macaroon — no preimage required. The module queries your Lightning node directly to check whether the invoice is settled and retrieves the preimage from the node.
Enabling auto-detect
Add l402_auto_detect_payment on to any location {} block:
location /protected {
l402 on;
l402_amount_msat_default 10000;
l402_macaroon_timeout 0;
l402_auto_detect_payment on; # ← enables server-side lookup
}
All boolean directives (l402, l402_auto_detect_payment) accept: on / off / true / false / 1 / 0 / yes / no (case-insensitive).
Client flow with auto-detect enabled
- Client requests a protected endpoint → receives
402 Payment Requiredwith an invoice. - Client pays the invoice (no preimage handling needed).
- Client retries with just the macaroon:
Authorization: L402 <macaroon> - Module extracts the
payment_hashfrom the macaroon identifier, queries the node, and — if the invoice is settled — uses the returned preimage to verify the macaroon signature. - On success the module returns
200 OK. If the invoice is not yet settled, it returns402 Payment Required. If the lookup fails or takes longer than 5 seconds, it returns500.
Preimage caching (Redis)
When Redis is configured (REDIS_URL), settled preimages are cached under the key l402:settled:<payment_hash_hex>. Subsequent requests for the same payment hash are served from the cache, avoiding repeated node round-trips.
Backend support matrix
LN_CLIENT_TYPE | Auto-detect supported | Notes |
|---|---|---|
LND | ✅ | Uses LookupInvoice gRPC |
CLN | ✅ | Uses listinvoices JSON-RPC over unix socket |
BOLT12 | ✅ | Uses listinvoices like CLN, so BOLT12_OFFER must be an offer from your own node |
ECLAIR | ✅ | Uses POST /getreceivedinfo REST API |
NWC | ⚠️ | Uses NIP-47 lookup_invoice, which wallets may not implement |
LND over LNC | ❌ | LNC mailbox does not expose LookupInvoice |
LNURL | ❌ | Remote wallet — no server-side query API |
Note
Even when
l402_auto_detect_payment onis set, the classicL402 <macaroon>:<preimage>format is still accepted — auto-detect only activates when the client omits the preimage.
Wallet Compatibility
Note
NWC works for accepting payments, not just paying. A common misconception is that Nostr Wallet Connect (NIP-47) is only a spending protocol.
LN_CLIENT_TYPE=NWCuses the connection to receive: it creates each L402 invoice withmake_invoiceand verifies payment withlookup_invoice. The catch is that both are optional NIP-47 methods — not every wallet implements them, and a given connection secret may be permission-scoped (e.g. pay-only). Use a wallet and connection that grantmake_invoiceandlookup_invoice(a wallet advertises its supported commands in its NIP-47 info event); a pay-only connection can spend but cannot accept.
Warning
Some wallets (e.g. Wallet of Satoshi) return 48-byte non-standard preimages, which are not compatible with this module. Use a wallet that returns a standard 32-byte preimage.
Also Supported: Cashu eCash
In addition to Lightning, the module accepts Cashu eCash tokens via the X-Cashu header as an alternative payment method. See Cashu eCash Support for details.
Cashu eCash
The module supports Cashu eCash tokens as an alternative payment method to Lightning invoices.
Paying with a Token
Clients send the token in the X-Cashu header, per NUT-24:
curl -i https://your-gateway/protected -H "X-Cashu: cashuB..."
The token must come from a mint in CASHU_WHITELISTED_MINTS and cover the route’s price.
Authorization: Cashu <token> is also accepted and wins if both are sent. It predates
NUT-24 support here; new clients should use X-Cashu.
Standard Mode vs P2PK Mode
| Standard Mode | P2PK Mode | |
|---|---|---|
| How it works | Calls wallet.receive() → contacts mint to swap tokens | Verifies token locked to proxy’s public key locally |
| Speed | Slower (blocks on mint API call per request) | Fast (milliseconds — no mint call!) |
| Best for | Low-traffic or simple setups | High-traffic production deployments |
| Extra requirement | None | CASHU_WHITELISTED_MINTS is required |
Standard Mode Setup
Environment=CASHU_ECASH_SUPPORT=true
Environment=CASHU_DB_PATH=/var/lib/nginx/cashu_tokens.db
# BIP39 wallet mnemonic (NUT-13 backup phrase); unset = auto-generated on first run
Environment=CASHU_WALLET_MNEMONIC="word1 word2 ... word12"
# Whitelist accepted mints (comma-separated). Optional, but see the note below.
Environment=CASHU_WHITELISTED_MINTS=https://mint1.example.com,https://mint2.example.com
# Optional: Auto-redeem to Lightning
Environment=CASHU_REDEEM_ON_LIGHTNING=true
Environment=CASHU_REDEMPTION_INTERVAL_SECS=3600
Standard mode also answers a 402 with an X-Cashu payment request, stating the
amount, unit and accepted mints. It carries no NUT-10 lock — that is what P2PK
mode adds — but wallets need it to construct a payable token.
Set
CASHU_WHITELISTED_MINTS. The mint list is themfield of the payment request, so leaving it unset means there is nothing to advertise, no challenge is sent, and NUT-24 wallets cannot pay at all — on any deployment. Where callers are untrusted it does a second job: without it, someone can present tokens from a mint they run themselves and pass the paywall for free.
⚠️ Security:
CASHU_WALLET_MNEMONICis the BIP39 phrase that derives the wallet seed (NUT-13) — the only backup of your wallet. Anyone with it can steal your tokens, and losing it loses the funds. It’s a 12/24-word phrase (not a hex secret); leave it unset to auto-generate and persist one on first run, and never commit it to Git. On startup the module records a seed fingerprint next to the DB and refuses to start if a later mnemonic doesn’t match — deletewallet.fingerprintto switch wallets intentionally.
P2PK Mode Setup (High Performance)
Environment=CASHU_P2PK_MODE=true
Environment=CASHU_P2PK_PRIVATE_KEY=<your-private-key-hex>
# CASHU_WHITELISTED_MINTS is REQUIRED in P2PK mode
Environment=CASHU_WHITELISTED_MINTS=https://mint1.example.com
⚠️ Security:
CASHU_P2PK_PRIVATE_KEYis equally critical. Anyone with this key can spend tokens locked to your public key. Generate withopenssl rand -hex 32.
How P2PK mode works per request:
- Proxy derives a public key from
CASHU_P2PK_PRIVATE_KEYand sends it to clients via theX-Cashuheader (NUT-24) - Client creates P2PK-locked tokens to that public key
- Proxy verifies tokens are locked to its public key (NUT-11) — local check, no network call
- Proxy unlocks proofs with private key (local cryptographic operation)
- Unlocked proofs are stored directly in CDK database via
wallet.receive_proofs() - Background redemption task finds proofs via
wallet.get_unspent_proofs()and redeems to Lightning viawallet.melt()
Redemption Fee Configuration
With CASHU_REDEEM_ON_LIGHTNING=true, one worker periodically melts received tokens to your Lightning backend. In LNURL mode that needs LNURL_ADDRESS or l402_lnurl_addr; with neither, nginx warns at startup and the ecash accumulates unredeemed.
# Minimum balance to attempt melting (default: 10 sats)
Environment=CASHU_MELT_MIN_BALANCE_SATS=10
# Percentage to reserve for fees (default: 1%)
Environment=CASHU_MELT_FEE_RESERVE_PERCENT=1
# Minimum fee reserve when percentage is small (default: 4 sats)
Environment=CASHU_MELT_MIN_FEE_RESERVE_SATS=4
# Maximum proofs per melt operation (default: 0 = unlimited)
# Use this if your mint has a per-melt proof limit (e.g. mint.coinos.io = 1000)
Environment=CASHU_MAX_PROOFS_PER_MELT=1000
Fee calculation: fee_reserve = max(total_amount × percent/100, min_fee_sats)
Example 1 — Large balance (500 sats) with 1% fee reserve:
- Percentage fee:
500 × 1% = 5 sats - Minimum fee:
4 sats - Used reserve:
max(5, 4) = 5 sats - Redeemable:
500 - 5 = 495 sats
Example 2 — Small balance (50 sats) with 1% fee reserve:
- Percentage fee:
50 × 1% = 0.5 sats - Minimum fee:
4 sats - Used reserve:
max(0.5, 4) = 4 sats← Minimum kicks in! - Redeemable:
50 - 4 = 46 sats
Example 3 — Proof count limiting when exceeding mint limit (CASHU_MAX_PROOFS_PER_MELT=1000):
- Scenario: 1282 proofs worth 13,588 sats total
- Check:
1282 proofs > 1000 limit→ Limiting triggered - Action: Select first 1000 proofs worth ~10,600 sats
- Invoice: Generate invoice for 10,600 sats
- Remaining: 282 proofs (~2,988 sats) stay for next cycle
- Next cycle:
282 proofs < 1000 limit→ all remaining proofs melted
Actual melt quote fees are verified against the reserve; warnings appear if the reserve was insufficient.
Note on
CASHU_WHITELISTED_MINTS: If not configured, all mints are accepted in standard mode. In P2PK mode, whitelisted mints are REQUIRED for security and the payment request (NUT-24).
SQLite Database Setup
# One-time setup — persists across restarts
sudo mkdir -p /var/lib/nginx
sudo chown root:nginx /var/lib/nginx
sudo chmod 1770 /var/lib/nginx
The cdk-sqlite crate automatically creates the database file and tables. Database location: /var/lib/nginx/cashu_tokens.db
Note
This directory holds the token database and, when the mnemonic is auto-generated,
wallet.mnemonic(mode0600). Root owns it and nginx writes through the group; the sticky bit (the1in1770) lets nginx delete or rename only its own files, so it can’t remove or replace the phrase. The module refuses a phrase file it doesn’t own, and the Docker image sets all this up on every start. Include both files in your encrypted backups.
Building from Source
Prerequisites
Install required system dependencies:
sudo apt-get install -y \
build-essential \
clang \
libclang-dev \
libc6-dev \
zlib1g-dev \
pkg-config \
libssl-dev \
protobuf-compiler \
nginx
Install Rust and Cargo:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Build Steps
- Clone the repository:
git clone https://github.com/ngx-l402/ngx-l402.git
cd ngx-l402
- Download Nginx source and build the module:
⚠️ Important: You must download the exact Nginx source code matching your target version and set the
NGINX_SOURCE_DIRenvironment variable before building.
# Example for Nginx 1.28.0
curl -fsSL http://nginx.org/download/nginx-1.28.0.tar.gz -o nginx.tar.gz
tar -xzf nginx.tar.gz
cd nginx-1.28.0
./configure --with-compat
cd ..
export NGINX_SOURCE_DIR=$(pwd)/nginx-1.28.0
cargo build --release --features export-modules
The compiled module will be at target/release/libngx_l402_lib.so.
- Copy to your Nginx modules directory:
sudo cp target/release/libngx_l402_lib.so /etc/nginx/modules/
- Follow the remaining manual installation steps.
Logging
View Logs
systemd / Manual Install
# Module initialization and system logs
sudo journalctl -u nginx
# Nginx error logs (real-time)
sudo tail -f /var/log/nginx/error.log
# Cashu redemption logs
sudo tail -f /var/log/nginx/cashu_redemption.log
Docker
docker logs l402-nginx -f
Log Levels
Control verbosity via the RUST_LOG environment variable:
# Standard info logs (recommended for production)
Environment=RUST_LOG=info
# Detailed debug logs for all modules
Environment=RUST_LOG=debug
# Module-specific debug logs only (reduces noise)
Environment=RUST_LOG=ngx_l402_lib=debug,info
Structured JSON Logs
Set l402_log_format json; on a protected location to emit one JSON line per L402 access event, alongside the usual text logs. Field names match the dry-run line, so the same jq / log-aggregator queries work for both:
{"event":"l402_verify","route":"/api/data","backend":"LND","client_ip":"203.0.113.7","auth_state":"valid","method":"lightning","latency_ms":42}
Events: l402_verify (auth_state valid or invalid, method when known), l402_challenge (a 402 was issued, with price_msat and price_source), l402_challenge_error (error: backend, timeout, or panic), and l402_rate_limited (a 429, with window_secs).
Off by default (text), so existing deployments see no change. The directive is per-location: an inner l402_log_format text; overrides an outer json.
Dry-Run (Shadow) Mode
Shadow mode lets operators roll out L402 enforcement safely. With
l402_dry_run on; set on a location, the module evaluates the full pricing
pipeline, synthesises a valid L402 challenge, and records structured logs
and Prometheus metrics — but always passes the request through to the
upstream. No client ever sees 401 or 402.
This is the recommended way to validate pricing, LN backend reachability, and traffic patterns with real production traffic before flipping a route to enforcement.
Enabling shadow mode
location /api/ {
l402 on;
l402_amount_msat_default 10000;
l402_dry_run on; # evaluate, log, never block
proxy_pass http://upstream;
}
l402_dry_run accepts on or off (default). It can be combined with any
other l402_* directive — dynamic pricing from Redis, multi-tenant LNURLs,
macaroon timeouts, invoice rate limits — so the shadow-mode numbers you
measure match the configuration you are about to enforce.
l402 must still be on for the module to enter the access handler.
Turning l402 off disables the module entirely, including shadow mode.
What happens per request
For every request reaching a shadow-mode location, the module:
- Reads the static and dynamic (Redis) price for the route and picks the
effective
amount_msat. - Looks up any per-tenant LNURL override.
- Verifies the
Authorizationheader if one is present (L402 or Cashu). - If no valid token is present, calls the configured LN backend and generates a real invoice + macaroon — exactly the challenge enforce mode would have returned.
- Emits a structured JSON log line and bumps the relevant Prometheus counters.
- Returns
NGX_DECLINED, so Nginx continues to the content phase and serves the upstream response with its natural status code.
Cost note: generating a challenge contacts your LN backend on every unauthenticated request. If you have high traffic, start by enabling shadow mode on a sampled location (e.g. a canary route) before rolling it out everywhere.
Latency cap: the challenge-synthesis call is bounded by a 5-second timeout. If the LN backend does not respond within that window the request still passes through (with no
X-L402-Dry-Run-Challengeheader) andl402_dry_run_challenge_errors_totalis incremented — shadow mode must never add latency to user-facing traffic.
Response headers
Shadow mode attaches debug headers to the upstream response so operators can inspect what would have happened without scraping logs:
| Header | Meaning |
|---|---|
X-L402-Dry-Run: 1 | Marks the response as produced by shadow mode. Always present. |
X-L402-Dry-Run-Price-Msat: <n> | Effective price for this route. Only emitted when the request would have been challenged (402) — not on paid-valid or rejected-invalid responses, to avoid leaking pricing against decided traffic. |
X-L402-Dry-Run-Challenge: L402 macaroon="...", invoice="..." | The exact WWW-Authenticate value enforce mode would have returned. Only present when the request would have been challenged (402) and the LN backend produced an invoice. |
WWW-Authenticate: L402 macaroon="...", invoice="..." | Also set alongside the challenge header, so real L402 clients can follow the payment flow in a staging environment. |
X-L402-Dry-Run-Rate-Limited: 1 + X-L402-Dry-Run-Retry-After: <sec> | Set when the request would have been challenged but hit l402_invoice_rate_limit. No invoice is generated and no challenge header is attached, mirroring what enforce mode would have done (429 + Retry-After). |
Structured log events
Every shadow-mode request produces a single info-level JSON line via the
Rust logger. A minimal example (formatted for readability):
{
"event": "l402_dry_run",
"route": "/api/resource",
"price_msat": 10000,
"price_source": "static",
"backend": "LNURL",
"client_ip": "203.0.113.42",
"auth_state": "missing",
"would_return": 402
}
Fields:
| Field | Values |
|---|---|
route | Normalised request path used for pricing lookups. |
price_msat | Effective price in millisatoshis. |
price_source | static (from nginx.conf) or dynamic (from Redis). |
backend | LN backend type snapshot: LND, LNURL, NWC, CLN, BOLT12, ECLAIR. |
client_ip | The connection’s source address. Proxy headers are not trusted; configure nginx’s realip module to substitute the real client address. |
auth_state | missing, valid, or invalid. |
would_return | HTTP status enforce mode would have used (200, 401, 402). |
rate_limited | true when l402_invoice_rate_limit would have produced a 429 — challenge synthesis was skipped to protect the LN backend. |
Pipe into jq to see a live firehose:
sudo tail -f /var/log/nginx/error.log \
| grep '"event":"l402_dry_run"' \
| jq -c 'select(.would_return != 200) | {route, price_msat, auth_state}'
Prometheus metrics
The l402_metrics directive turns a location into a Prometheus scrape
endpoint. It serves counters in text exposition format v0.0.4.
The shipped nginx.conf closes it to everything but localhost, because the
counters carry invoice volume and revenue signals and that file ships inside the
Docker image. Widen allow to reach it from your scrape host:
location = /metrics {
allow 127.0.0.1;
allow ::1;
allow 10.0.0.0/8; # your Prometheus host or monitoring subnet
deny all;
l402_metrics;
}
Scraping from another container puts the request on the Docker bridge, not
loopback — allow that network rather than removing deny all.
Scrape it with a standard Prometheus config:
scrape_configs:
- job_name: ngx_l402
metrics_path: /metrics
static_configs:
- targets: ['nginx:8000']
Counters are kept in an nginx shared-memory zone, so they are aggregated
across all worker processes: a scrape served by any worker returns the true
total regardless of worker_processes. (If the shared zone cannot be
allocated at startup the module logs a warning and falls back to per-worker
counters, which under-report by roughly a factor of worker_processes.)
Exported counters
| Metric | Meaning |
|---|---|
l402_requests_total | Every request that entered the access handler with l402 on;. Incremented for both enforce and shadow traffic. |
l402_challenges_issued_total | Requests that received a 402 response (enforce mode), counted after the rate-limit gate. |
l402_rate_limited_total | Requests rejected with 429 by l402_invoice_rate_limit (enforce mode). |
l402_payments_valid_total | Authorization headers that verified successfully — Lightning + Cashu (enforce mode only — dry-run traffic goes to l402_dry_run_*). |
l402_payments_lightning_total | Successful payments settled via a Lightning macaroon (classic preimage or auto-detect). |
l402_payments_cashu_total | Successful payments settled via a Cashu token redemption. |
l402_payments_invalid_total | Authorization headers that failed verification (enforce mode only). |
l402_payments_missing_total | Requests without an Authorization header (enforce mode only). |
l402_invoices_generated_total | Lightning invoices successfully generated for L402 challenges (enforce mode only). |
l402_invoices_generation_errors_total | Failures generating a Lightning invoice during challenge synthesis (returns 500). |
l402_dry_run_requests_total | Requests handled in shadow mode. |
l402_dry_run_would_block_total | Shadow-mode requests that would have been blocked (401 or 402). |
l402_dry_run_would_allow_total | Shadow-mode requests that would have been allowed (200). |
l402_dry_run_rate_limited_total | Shadow-mode requests that would have hit l402_invoice_rate_limit — challenge synthesis was skipped. |
l402_dry_run_challenge_errors_total | Shadow-mode requests where challenge synthesis failed (e.g. LN backend unreachable). |
l402_dry_run_price_msat_sum | Sum of msat prices evaluated in shadow mode. Pair with _requests_total to derive an average price. |
Useful PromQL
# Fraction of traffic that would be blocked if you flipped enforcement on:
rate(l402_dry_run_would_block_total[5m])
/
rate(l402_dry_run_requests_total[5m])
# Average price served by shadow mode (msat):
rate(l402_dry_run_price_msat_sum[5m])
/
rate(l402_dry_run_requests_total[5m])
# Challenge-synthesis error rate — a signal that your LN backend is flaky:
rate(l402_dry_run_challenge_errors_total[5m])
The endpoint has no built-in authentication. Restrict it at the Nginx level with
allow/deny, an auth subrequest, or a firewall rule — exposing it publicly leaks traffic volume and pricing details.
Suggested rollout recipe
- Deploy with
l402 on;andl402_dry_run on;on the target location. Leave existing routes untouched. - Scrape
/metricsfor 24–48 hours. Confirm:l402_dry_run_challenge_errors_totalstays flat (LN backend healthy).l402_dry_run_would_allow_total / l402_dry_run_requests_totalmatches the fraction of paying clients you expect.l402_dry_run_price_msat_sumdivided by request count matches your posted price.
- Sample the JSON log for a few high-volume paths and confirm
price_sourceis what you configured (staticvsdynamic). - Remove
l402_dry_run on;(or set it tooff). Reload Nginx. The location now enforces.
If you ever need to revert, setting l402_dry_run on; again immediately
disables enforcement without touching upstream code paths.
Capability Manifest
The l402_manifest directive turns a location into a discovery endpoint
that emits a JSON description of every L402-protected route on the
server. It is intended to live at /.well-known/l402-services (RFC 8615),
making this instance self-describing to clients that have only the host.
location = /.well-known/l402-services {
l402_manifest;
# Optional: restrict who can scrape pricing details.
# allow 10.0.0.0/8;
# deny all;
}
This is the agent-era equivalent of robots.txt or security.txt. An
autonomous agent (or any client) given only https://example.com can
fetch the manifest, learn which routes are paid, how much they cost, and
which payment backends are accepted — without any out-of-band integration.
Example response
{
"version": "1",
"service": {
"name": "Example API",
"description": "Stock data API"
},
"payment_methods": [
{
"type": "lightning",
"backend": "LNURL",
"address": "hello@getalby.com"
},
{
"type": "cashu",
"mints": ["https://mint.minibits.cash"],
"p2pk_supported": true,
"challenge_header": "X-Cashu"
}
],
"routes": [
{
"path": "/protected",
"price": {
"type": "static",
"amount_msat": 10000
},
"caveats_required": ["RequestPath = /protected", "RequestMethod = <METHOD>"]
},
{
"path": "/rate-limited",
"price": {
"type": "static",
"amount_msat": 10000
},
"caveats_required": ["RequestPath = /rate-limited", "RequestMethod = <METHOD>"],
"rate_limit": {
"max_requests": 2,
"window_secs": 60
}
}
]
}
What the manifest describes
| Field | Source | Meaning |
|---|---|---|
version | constant "1" | Schema version. Bumped on breaking changes; agents should reject unknown majors. |
service.name, service.description, service.operator, service.contact | env vars L402_SERVICE_NAME, L402_SERVICE_DESCRIPTION, L402_SERVICE_OPERATOR, L402_SERVICE_CONTACT | Optional, omitted when unset. |
payment_methods[].type | lightning or cashu | Which payment rail this method describes. |
payment_methods[].backend | env var LN_CLIENT_TYPE | LNURL, LND, CLN, NWC, BOLT12, ECLAIR (LNC shows as LND). |
payment_methods[].address | env var LNURL_ADDRESS (LNURL backends only) | Server-default LN address. May be overridden per-route via lnurl_addr. |
payment_methods[].mints | env var CASHU_WHITELISTED_MINTS | Allowed Cashu mints (when Cashu is enabled). |
payment_methods[].p2pk_supported | env var CASHU_P2PK_MODE | true when NUT-24 P2PK Cashu is enabled; omitted otherwise. |
payment_methods[].challenge_header | constant X-Cashu | The header a 402 carries the Cashu payment request in (NUT-24). |
routes[].path | location directive | URL path served by this route. |
routes[].price.amount_msat | l402_amount_msat_default | Base price after merge_loc_conf. |
routes[].caveats_required | derived | Caveats that bind the macaroon to the request: RequestPath = <path> (or Realm = <name> with l402_realm) and RequestMethod = <METHOD>, filled in with the request’s method. With l402_macaroon_timeout set, the macaroon also carries ExpiresAt, given here as macaroon_timeout_secs. |
routes[].macaroon_timeout_secs | l402_macaroon_timeout | Omitted when 0 (no expiry). |
routes[].lnurl_addr | l402_lnurl_addr | Per-route LNURL override for multi-tenant deployments. |
routes[].rate_limit | l402_invoice_rate_limit | Server-side invoice rate limit applied before challenge issuance. |
routes[].auto_detect_payment | l402_auto_detect_payment | When true, clients can omit the preimage and the server settles via node lookup. |
Dynamic (Redis-backed) pricing is not reflected in price.amount_msat —
the manifest emits the static default. Dynamic prices change per request
and would require a Redis round-trip per route to render accurately;
that’s out of scope for v1.
Hiding a route
Operators may want certain paid routes to remain undiscoverable — private
APIs, beta tiers, customer-specific endpoints. Use l402_manifest_hide;
on the location:
location /internal-paid {
l402 on;
l402_amount_msat_default 100000;
l402_manifest_hide; # not advertised in /.well-known/l402-services
}
The route still enforces L402 normally. It just doesn’t appear in the
manifest’s routes[] array.
Service-level metadata
The optional service block is read from environment variables at
manifest-render time:
L402_SERVICE_NAME="Example API"
L402_SERVICE_DESCRIPTION="Premium financial data, paid per request."
L402_SERVICE_OPERATOR="npub1abcd..." # Nostr pubkey, DID, or free-form
L402_SERVICE_CONTACT="ops@example.com"
All four are optional. Unset variables are omitted from the response so the manifest stays valid JSON even with no service metadata.
Caveats and limitations
- Per-worker registry. The manifest registry is per-nginx-worker. On a
multi-worker deployment, every worker sees the same routes (config is
shared), so this is a non-issue for the manifest itself. (Unlike the
l402_metricscounters, which use a shared-memory zone, the registry is read-only per-worker state and needs no cross-worker aggregation.) - No authentication by default. Pricing information is public.
Restrict the endpoint with
allow/deny, an auth subrequest, or a firewall if competitors should not see your full pricing matrix. - Reload behaviour. On
nginx -s reload, new workers start with a fresh registry built from the new config. Old workers serve in-flight requests with their existing registry until they exit.
Why this matters
Without a manifest, every L402 integration is a bespoke wiring job: the client must be told the routes, prices, payment backends, and caveat formats out of band. With one, an agent can land on a host and onboard itself end-to-end:
GET /.well-known/l402-services → learn the API surface
GET /protected → receive 402 + bolt11 invoice
PAY the invoice → get preimage
GET /protected with L402 auth → success
For autonomous agents — Claude tools, MCP servers, custom-built — this is the difference between L402 being a protocol and L402 being a discoverable web standard.