@powforge/l402-verify
v0.1.0
Published
Standalone L402 (Lightning HTTP 402) payment verifier. Zero runtime dependencies. Parses Authorization: L402 <macaroon>:<preimage> headers, verifies the HMAC-signed macaroon, checks sha256(preimage) === payment_hash, and confirms invoice settlement via LN
Downloads
130
Maintainers
Readme
@powforge/l402-verify
Standalone L402 (Lightning HTTP 402) payment verifier. Zero runtime dependencies.
The verification half of @powforge/mcp-l402-gate, extracted as a small, citable reference for the x402 / L402 ecosystem. Use it when you want to accept L402 payments without pulling in the full MCP gate + identity-score stack.
What it does
parseL402Header(header)— parseAuthorization: L402 <macaroon>:<preimage>verifyMacaroon(macaroon, opts)— recompute the HMAC-SHA256 signature, check expiry, scope, optional replaypreimageMatchesHash(preimage, paymentHash)— constant-timesha256(preimage) === payment_hashcheckInvoicePaid(paymentHash, opts)— single LNBits GET to see if the invoice is settledverifyL402(authHeader, opts)— compose all four into one async call
What it does NOT do
- Mint invoices, mint macaroons, build 402 challenges (issuer-side; use
@powforge/mcp-l402-gate) - Hold any Lightning admin keys
- Lookup identity scores (separate package:
@powforge/identity) - Persist replay-state (callers own the
usedSet— in-memorySet, Redis, SQLite, whatever you like)
Install
npm install @powforge/l402-verifyRequires Node 18+ (for globalThis.fetch).
Use
One-call verification
const { verifyL402 } = require('@powforge/l402-verify');
const result = await verifyL402(req.headers.authorization, {
secret: process.env.L402_SECRET,
expectedScope: 'mcp-l402-gate:call',
lnbitsUrl: process.env.LNBITS_URL,
lnbitsApiKey: process.env.LNBITS_INVOICE_KEY,
usedSet: globalReplaySet, // optional: Set<string> of redeemed preimages
});
if (!result.valid) {
return res.status(401).json({ error: result.reason });
}
// result = { valid: true, paymentHash, scope, caveats, expiresAt, preimage }Bring-your-own paid-check (LND, Phoenix, hosted)
const result = await verifyL402(req.headers.authorization, {
secret: process.env.L402_SECRET,
expectedScope: 'pay-per-call',
checkPaidFn: async (paymentHash) => {
// Ask your own Lightning node / wallet
const inv = await lnd.lookupInvoice({ r_hash_str: paymentHash });
return inv.state === 'SETTLED';
},
});Step-by-step (advanced)
const { parseL402Header, verifyMacaroon, preimageMatchesHash, checkInvoicePaid } = require('@powforge/l402-verify');
const parsed = parseL402Header(req.headers.authorization);
if (!parsed) return res.status(400).json({ error: 'bad L402 header' });
const v = verifyMacaroon(parsed.macaroon, {
secret: process.env.L402_SECRET,
expectedScope: 'mcp-l402-gate:call',
});
if (!v.ok) return res.status(401).json({ error: v.reason });
if (!preimageMatchesHash(parsed.preimage, v.paymentHash)) {
return res.status(401).json({ error: 'preimage mismatch' });
}
const paid = await checkInvoicePaid(v.paymentHash, {
lnbitsUrl: process.env.LNBITS_URL,
lnbitsApiKey: process.env.LNBITS_INVOICE_KEY,
});
if (!paid) return res.status(402).json({ error: 'not yet paid' });
// caller is paid + macaroon is valid — proceedWire format
Macaroon = base64url(JSON.stringify({
v: 1,
ph: <hex payment hash>,
sc: "<scope>",
exp: <unix seconds>,
cav: { ... },
sig: <hex hmac-sha256 over canonical JSON of body sans sig>
}))
Authorization: L402 <macaroon>:<preimage_hex>This is the same wire format produced by @powforge/[email protected]+. If you mint with one and verify with the other, they will line up.
L402 background
L402 is the Lightning Labs spec for using HTTP 402 + Lightning Network as the payment rail. The server returns WWW-Authenticate: L402 macaroon="...", invoice="...", the client pays the invoice, takes the preimage, and re-requests with Authorization: L402 <macaroon>:<preimage>. The server then verifies (1) the macaroon HMAC, (2) that sha256(preimage) == payment_hash, and (3) that the invoice actually settled on the Lightning Network.
This package implements step 3 against LNBits by default; pass your own checkPaidFn for any other Lightning backend (LND, Phoenix, Voltage, hosted).
Spec: https://docs.lightning.engineering/the-lightning-network/l402
Replay protection
The package is stateless by default. If you want replay protection, pass a usedSet (any object with .has(string) and .add(string) — a JS Set works). On a successful verifyL402 call, the preimage is added to the set. On any subsequent call with the same preimage, verification fails with reason: "preimage already redeemed".
For multi-process deployments, back the set with Redis or a database; the interface is just { has, add }.
License
MIT. PowForge.
