Here is the thing about paywalls. The account-signup step is where most readers give up and close the tab. Email capture, password-reset flow, magic-link dance, all of it is a privacy tax on the reader and a churn tax on the publisher. The shape that works for Bitcoin-native audiences is different.
What if the reader does not need to sign up for anything at all?
The pitch in one curl
$ curl -i http://localhost:4050/article
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Nostr realm="reader-weight", spec="NIP-98", ttl="60"
Content-Type: application/json
{"error":"unauthorized","hint":"sign a NIP-98 kind:27235 event for this URL and send as Authorization: Nostr <base64>","free_threshold":60,"base_price_sats":10}
The server is telling a well-understood Nostr client exactly what to do. Sign a short event. Send it in the Authorization header. The server reads the reader's public key out of the signature, looks up their Depth-of-Identity score across five dimensions of observable work (economic, temporal, social, spatial, access), and decides.
Above the threshold: the article. Zero sats. Free for a reader who has earned it elsewhere.
Below the threshold: a Lightning invoice priced by the reader's score on a linear curve. Depth zero pays ten sats. Depth thirty pays five sats. Depth fifty-nine pays one sat.
No account was created. No cookie was set. No email was collected.
The composition nobody has shipped yet
L402 (Lightning HTTP 402) is well-trodden. Fewsats catalogs dozens of services. AbdelStark has a Rust reference implementation. The l402.org spec is three years old at this point. They all work the same way at the auth layer: an opaque bearer token, handed out after you sign up, carried in the Authorization header.
NIP-98 is also well-trodden. It is a Nostr Improvement Proposal for HTTP authorization by Schnorr signature: you sign a kind:27235 event with tags pinning the URL and method, base64 the signed JSON, and send it as Authorization: Nostr <base64>. Alby, nos2x, and nsec.app all implement it on the client side.
What nobody had shipped as of last week was the composition. One endpoint. The reader either proves they own a key (NIP-98) or proves they paid (L402). Either one works. The server does not care which.
This is the piece that makes per-article pricing interesting. If you price by reader identity, you need to know who is reading. If you want to know who is reading without imposing a signup flow, you need a cryptographic alternative. NIP-98 gives you one for free.
What the handler actually looks like
Here is the core decision, one function, Node 18, nothing exotic:
const { verifyEvent } = require('nostr-tools');
const { buildL402Gate } = require('./scripts/lib/l402-middleware.js');
async function handle(req, res) {
const auth = req.headers.authorization || '';
const fullUrl = `http://${req.headers.host}${req.url}`;
// Path 1: reader has paid, retry with L402 auth header.
if (/^L402\s+/i.test(auth)) {
const gate = gateForPrice(BASE_PRICE_SATS);
const verdict = await gate(req, res);
if (verdict.authorized) serveArticle(res, { paidSats: BASE_PRICE_SATS });
return;
}
// Path 2: reader has signed a NIP-98 event, prove who they are.
if (/^Nostr\s+/i.test(auth)) {
const v = verifyNip98(auth, fullUrl, req.method);
if (!v.ok) return writeAuthChallenge(res, 401, v.reason);
const depth = depthFor(v.pubkey);
const price = priceFor(depth);
if (price === 0) {
serveArticle(res, { pubkey: v.pubkey, depth, paidSats: 0 });
return;
}
// Priced by depth: mint an L402 invoice for the exact right amount.
const gate = gateForPrice(price);
const verdict = await gate(req, res);
if (verdict.authorized) serveArticle(res, { depth, paidSats: price });
return;
}
// No auth header. Emit a NIP-98 challenge so the client knows to sign.
writeAuthChallenge(res, 401);
}
verifyNip98 decodes the base64, parses the JSON, checks kind === 27235, verifies the URL and method tags match the request, confirms created_at is within the last 60 seconds, and runs the Schnorr signature verification through nostr-tools' verifyEvent. Ninety lines of that helper, well-documented in the repo.
buildL402Gate is the factory that handles macaroon minting, invoice creation against LNBits, payment verification, and single-use replay protection. That is in a separate middleware module, scripts/lib/l402-middleware.js, reused across multiple other PowForge services.
depthFor is the piece that changes per deployment. In the demo, it reads a local JSON file mapping hex pubkey to depth score. In production, you would either pre-warm a cache from the /oracle/doi-score API (L402-gated at 2 sats per lookup), or compute scores in-process using the @powforge/identity SDK against events you already have on disk.
The price curve
Deliberately dumb and linear.
function priceFor(depth) {
if (depth >= FREE_THRESHOLD) return 0;
const discount = depth / FREE_THRESHOLD;
return Math.ceil(BASE_PRICE_SATS * (1 - discount));
}
With the defaults of BASE_PRICE_SATS=10 and FREE_THRESHOLD=60, a depth-0 fresh key pays ten sats. A depth-30 mid-reader pays five sats. A depth-59 long-accumulated key pays one sat. At sixty, the invoice goes away entirely.
Nothing in the code insists on this curve. Swap it for a step function, a logarithm, or a lookup table. The point is that the price is a function of the caller and the server can compute it before minting the invoice.
Walking the full flow with curl
From the acceptance test bundled with the repo. Start the server on port 4050, have two known keypairs in the scores.json fixture (high-depth 85 and low-depth 5), and walk the four cases.
Case 1 — no auth at all
$ curl -i http://localhost:4050/article
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Nostr realm="reader-weight", spec="NIP-98", ttl="60"
...
{"error":"unauthorized", ...}
Good. The client knows what to do next.
Case 2 — high-depth signed header
The client (Alby, nos2x, nsec.app, whatever) signs a fresh kind:27235 event with tags ["u", "http://localhost:4050/article"] and ["method", "GET"], base64s the signed JSON, and retries.
$ curl -i -H "Authorization: Nostr eyJpZCI6...=" http://localhost:4050/article
HTTP/1.1 200 OK
Content-Type: text/markdown; charset=utf-8
X-Reader-Depth: 85
X-Reader-Paid-Sats: 0
# The Cost of a Read Is Evidence of a Reader
...
Article served, zero sats charged. The reader's key is their receipt.
Case 3 — low-depth signed header
Same flow, different signer. The low-depth key signs. Server looks up their depth (5), computes the price (ceil(10 * (1 - 5/60)) = 10 sats), mints an L402 invoice, returns it.
$ curl -i -H "Authorization: Nostr eyJpZCI6...=" http://localhost:4050/article
HTTP/1.1 402 Payment Required
WWW-Authenticate: L402 macaroon="eyJ2IjoxLC...", invoice="lnbc100n1p572lg9pp..."
X-L402-Price-Sats: 10
...
{"error":"payment required","scope":"reader-weight:article:10","price_sats":10, ...}
The invoice is real. I ran this against a real LNBits instance and got a real BOLT11 string that a real wallet would pay.
Case 4 — low-depth key pays, then retries
After the reader pays the invoice, their wallet hands them the preimage. They retry:
$ curl -i -H "Authorization: L402 eyJ2IjoxLC...:abc123..." http://localhost:4050/article
HTTP/1.1 200 OK
X-Reader-Paid-Sats: 10
...
Article served. Payment verified. Macaroon burned so the same preimage cannot be reused.
Where I expected this to be harder
Three places, none of which ended up being real blockers.
The NIP-98 signature verification. I thought this would be tricky because Schnorr is not built into Node's crypto module. It turned out that nostr-tools ships a verifyEvent helper that does both the event-id-hash check and the Schnorr signature check in one call. Forty lines of helper code total, mostly parsing and tag-checking.
The L402 macaroon scope per-price. L402 macaroons have a scope field that binds them to a specific endpoint and operation. If I issued one scope and tried to burn it at a different price, the verification would fail. I sidestepped the entire issue by baking the price into the scope: reader-weight:article:10 for ten-sat reads, reader-weight:article:5 for five-sat reads. Each price has its own scope, each scope has its own replay cache. Clean.
The replay protection across NIP-98 and L402. I worried they would collide. They do not. NIP-98 uses a 60-second created_at window. L402 uses single-use preimage caching. They live in separate layers and protect against separate attacks.
Limitations I know about
The demo ships with one article. A real deployment needs routing or an article-id scheme. The NIP-98 window is 60 seconds which is fine for browser-driven reads but vulnerable to an intercept-replay attack within that window. Mitigation is either a shorter TTL or an IP-binding caveat on the L402 macaroon.
The depth scores come from a local JSON file in the demo. Production uses either the oracle API (L402-gated, cached) or the SDK inline. That piece is orthogonal to the auth composition.
The article body is served as markdown. Your frontend is welcome to render it as HTML, stream it, paginate it. The decision layer does not care what you do with the payload.
Source
gitlab.com/powforge/sats-challenge/-/tree/master/examples/reader-weight
Full server.js, full README.md, test fixtures with two deterministic keypairs so the acceptance test runs without editing anything, and a .env.example with the four required variables.
The related examples in the same tree: examples/agent-gate demonstrates pure L402 per-call without the signing layer, and examples/relay-policy demonstrates the depth-based decision applied to a strfry writePolicy plugin instead of an HTTP endpoint. Three integrations, the same underlying primitive, one score.
Why this matters
Paywalls exist because readers have non-zero marginal cost. Account systems exist because businesses want to know who their readers are. Composing NIP-98 and L402 gives you both: a price that scales with the reader's observable investment in the Bitcoin-native ecosystem, and a signature that identifies them without a signup flow.
There is no email. There is no cookie. There is no session. There is a key, a score, and an invoice. That is the shape of a paywall that Bitcoin-native audiences will actually pay.
Build one. It runs in about an hour from a fresh clone.
Top comments (0)