toll-booth handles real money. This guide covers the security properties you get out of the box and the decisions you need to make as an operator.
toll-booth sits between untrusted clients and your upstream API. It must:
- Prevent access without payment - no bypass of the 402 challenge
- Prevent replay of credentials - a paid credential cannot be reused beyond its credit balance
- Prevent fund loss - Cashu tokens must not be double-spent or lost in transit
- Protect client privacy - no unnecessary PII collection or storage
- Resist abuse - rate limiting, input validation, memory bounds
The rootKey is the master secret for all macaroon credentials. If compromised, an attacker can mint arbitrary macaroons with any credit balance.
// Generate a root key
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Production requirements:
- Always set a persistent
rootKey(64 hex chars / 32 bytes). Without it, a random key is generated per restart and all existing macaroons become invalid. - Store the root key in a secrets manager or environment variable; never commit it to source control.
- Rotate the root key periodically. Rotation invalidates all outstanding macaroons, so coordinate with your users or rely on the credit system's natural expiry.
Macaroons support first-party caveats that restrict their use:
payment_hash,credit_balance,currency- reserved caveats set by toll-booth; cannot be overridden via the APIroute- restricts the macaroon to a specific path prefixexpires- Unix timestamp after which the macaroon is rejectedip- restricts usage to a specific client IP- Custom caveats are forwarded as
X-Toll-Caveat-*headers to the upstream
Hardening measures:
- Maximum 16 custom caveats per macaroon
- Maximum 1024 characters per caveat value
- Caveat keys are validated as alphanumeric only; special characters are rejected
- Caveat values are stripped of newlines to prevent header injection
- Duplicate custom caveats are rejected
When a client sends Authorization: L402 <macaroon>:<preimage>, toll-booth runs a multi-stage verification pipeline before granting access. Every stage must pass; failure at any point returns a fresh 402 challenge.
The Authorization header is split at the last colon to extract the base64-encoded macaroon and the hex preimage:
const auth = req.headers.authorization // "L402 <base64>:<hex64>"
const token = auth.replace(/^L402\s+/i, '')
const lastColon = token.lastIndexOf(':')
const macaroonBase64 = token.slice(0, lastColon)
const preimage = token.slice(lastColon + 1)The macaroon is deserialised and its HMAC chain is verified against the server's rootKey. This confirms the macaroon was minted by this server and has not been tampered with. The macaroon library's m.verify() walks the caveat chain, checking each HMAC link.
During verification, toll-booth also rejects duplicate caveats. Macaroons allow anyone to append caveats (they can only narrow permissions, never widen them), but an attacker could append a duplicate credit_balance caveat hoping to override the server-set value. toll-booth tracks caveat keys during verification and rejects any key that appears more than once. Additionally, caveat parsing uses first-occurrence-wins semantics, so server-set caveats (which come first in the chain) always take precedence.
The macaroon's binary identifier follows the Aperture-compatible version-0 layout:
| Bytes | Content |
|---|---|
| 0-1 | Version (uint16 big-endian, must be 0) |
| 2-33 | Payment hash (32 bytes) |
| 34-65 | Random token ID (32 bytes) |
The payment hash is extracted from this identifier and cross-checked against the payment_hash caveat. Both are covered by the root signature, so a mismatch indicates tampering and fails verification.
If the macaroon contains built-in caveats, they are checked against the current request context:
| Caveat | Check | Example |
|---|---|---|
route = /api/* |
Request path must match the pattern. Supports prefix matching with /* wildcard. |
route = /api/v1/* allows /api/v1/users but not /admin |
expires = 2026-06-01T00:00:00Z |
Current time must be before the expiry timestamp. | Rejects expired macaroons |
ip = 203.0.113.1 |
Client IP must match exactly. | Binds the macaroon to a specific client |
Any failing caveat check returns { valid: false } immediately.
The preimage proves the client actually paid. toll-booth accepts two forms of proof:
Lightning preimage: The standard proof - SHA256(preimage) must equal the payment hash. Both values are compared using crypto.timingSafeEqual to prevent timing attacks:
const computed = createHash('sha256').update(Buffer.from(preimage, 'hex')).digest()
timingSafeEqual(computed, Buffer.from(paymentHash, 'hex'))Cashu settlement secret: For Cashu payments, the preimage is compared against the settlement secret stored during Cashu redemption. This also uses timing-safe comparison:
timingSafeEqual(Buffer.from(preimage), Buffer.from(settlementSecret))If neither check passes, authentication fails. A macaroon alone grants no access; the preimage is the proof of payment.
On first valid presentation of a macaroon+preimage pair, toll-booth credits the balance to storage via settleWithCredit(). This is an atomic operation; if two concurrent requests race with the same credential, only one settles credits - the other continues with the existing balance. A random settlement secret is stored (not the raw preimage) to avoid leaking the bearer credential through the invoice-status endpoint.
After authentication, the engine debits the route's cost from the credit balance. If the balance is insufficient, the request falls through to a fresh 402 challenge. The remaining balance is returned to the client in the X-Credit-Balance response header.
Client sends: Authorization: L402 <macaroon>:<preimage>
│ │
┌───────┘ └────────┐
▼ ▼
Verify HMAC chain Verify SHA256(preimage)
against rootKey equals payment_hash
│ │
▼ ▼
Check caveats Settle credits
(route, expires, ip) (first time only)
│ │
└───────────┬───────────────────┘
▼
Debit route cost
│
▼
Proxy to upstream API
This entire pipeline runs on every authenticated request. The cryptographic checks (HMAC verification, SHA256 preimage check) are the expensive part; caveat enforcement and balance debit are simple lookups.
Invoice payment is verified via the Lightning backend's checkInvoice() method. toll-booth credits the account only after the backend confirms payment. The preimage returned by the Lightning network serves as cryptographic proof.
Cashu ecash payments use a lease-based crash recovery system:
- Claim phase - the payment hash is claimed with a lease (default 60 seconds). The claim is atomic; concurrent requests for the same payment hash are rejected.
- Redemption phase - the operator's
redeemCashucallback verifies and redeems the token with the Cashu mint. - Settlement phase - on success, credits are granted and the claim is settled. On failure, the lease expires and the claim becomes available for recovery.
- Recovery - on startup,
recoverPendingClaims()retries any claims that were leased but never settled (crash between claim and settlement).
Operator responsibility: Your redeemCashu callback must be idempotent for the same paymentHash. If the process crashes after redeeming with the mint but before settling in toll-booth, recovery will call your callback again.
Settlement secrets (used for Cashu auth tokens) are generated as 64-character hex strings (32 bytes of entropy from crypto.randomBytes), not UUIDs. Status tokens use timing-safe comparison to prevent timing attacks.
- Authorization header format is strictly validated (
L402 <base64>:<hex64>) - Payment hashes are validated as 64-character hex strings
- Invoice amounts are validated as positive integers within safe bounds
- BOLT-11 strings, NWC URIs, and Cashu tokens have length limits enforced
X-Toll-Costheader (variable metering) uses strict integer validation; scientific notation and floating point are rejected
- IP addresses from
X-Forwarded-Forare validated against IPv4/IPv6 format patterns - Non-IP strings are rejected to prevent arbitrary values filling the tracking map
- Free-tier tracking is capped at 100,000 distinct IPs to prevent memory exhaustion
- IP addresses are one-way hashed with a daily-rotating salt before any in-memory storage
Requests proxied to the upstream API have the following headers stripped:
Authorization(L402 credential; not forwarded upstream)Host(replaced with upstream host)- Hop-by-hop headers (
Connection,Keep-Alive,Proxy-Authenticate,Transfer-Encoding, etc.) - Any headers listed in the
Connectionheader value
Responses from the upstream have hop-by-hop headers stripped before returning to the client.
All toll-booth responses include:
Cache-Control: no-storePragma: no-cacheX-Content-Type-Options: nosniff
Payment page responses additionally include:
X-Frame-Options: DENYReferrer-Policy: no-referrerPermissions-Policy: camera=(), microphone=(), geolocation=()
The upstream proxy rejects absolute-form request targets (e.g. GET http://evil.com/) to prevent SSRF via crafted request paths. Only relative paths are forwarded.
Enable trustProxy: true only when toll-booth runs behind a trusted reverse proxy that overwrites X-Forwarded-For. Without a trusted proxy, a client can spoof their IP to bypass per-IP free-tier limits.
For custom runtimes (Deno, Bun, Workers), use the getClientIp callback to extract the client IP from the runtime's connection info rather than relying on headers.
The default SQLite storage uses WAL mode for concurrent read performance. Three tables:
credits- balance ledger (payment hash to credit balance)invoices- pending and settled invoices (pruned hourly by default)cashu_claims- Cashu redemption leases for crash recovery
Expired invoices are pruned automatically (default: older than 24 hours). Invoices with pending Cashu claims are protected from pruning.
Use a persistent dbPath in production. The default (./toll-booth.db) may not survive container restarts. Mount a Docker volume or use an absolute path.
flowchart TD
A[Set persistent rootKey] --> B[Set persistent dbPath]
B --> C[Enable strictPricing]
C --> D[Configure trustProxy or getClientIp]
D --> E[Rate-limit /create-invoice at reverse proxy]
E --> F[Make redeemCashu idempotent]
F --> G[Monitor event hooks for anomalies]
G --> H[Set up log aggregation]
- Set a persistent
rootKey- without it, macaroons are invalidated on restart - Use a persistent
dbPath- credits and invoices survive restarts - Enable
strictPricing: true- prevents unpriced routes from bypassing billing - Configure proxy trust -
trustProxy: truebehind a reverse proxy, orgetClientIpfor custom runtimes - Rate-limit
/create-invoiceat your reverse proxy - each call creates a real Lightning invoice - Make
redeemCashuidempotent - crash recovery depends on it - Monitor event hooks - use
onPayment,onRequest, andonChallengefor anomaly detection - Aggregate logs - payment and request events contain no PII but are valuable for debugging
If you discover a security vulnerability, please report it privately via GitHub Security Advisories on the toll-booth repository.