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.