Back to KB
Difficulty
Intermediate
Read Time
8 min

Add a 3-Sat Pay-to-Skip Tier to Your Self-Hosted CAPTCHA

By Codcompass Team··8 min read

Economic Gating for Form Verification: Implementing a Dual-Tier PoW and Lightning Micro-Payment System

Current Situation Analysis

Form verification has historically operated on a binary premise: either you accept high user friction to block automation, or you rely on computational puzzles that drain device resources and increase abandonment rates. Traditional image-grid or audio-based CAPTCHAs suffer from accessibility failures and mobile usability collapse. Pure proof-of-work (PoW) alternatives improve privacy and remove tracking, but they still force every visitor to burn CPU cycles, which translates to slower page loads, increased battery consumption on mobile devices, and measurable conversion loss.

The industry overlooks a fundamental truth: not all verification attempts require the same security posture. Most teams treat CAPTCHAs as static gates rather than dynamic economic filters. Bot operators scale by minimizing per-request cost. When verification is free, automation becomes economically viable. When verification requires expensive hardware or third-party solve farms, costs rise but user experience degrades. The missing layer is micro-economic gating: a mechanism that allows human users to bypass computational friction at a negligible cost, while simultaneously raising the capital requirement for automated attacks.

Data from form-security benchmarks shows that bot farms charge approximately $2.00–$3.50 per 1,000 solves. At a micro-payment tier of 3 sats (roughly $0.003 at current exchange rates), an attacker attempting 100,000 submissions faces a direct cost of ~300,000 sats, or ~$300. This shifts the attack vector from a free target to an uneconomical one. The system does not block the bot; it prices it out. Meanwhile, legitimate users experience near-zero friction, with Lightning Network settlements typically completing in under two seconds.

WOW Moment: Key Findings

The following comparison illustrates how a dual-tier architecture fundamentally alters the verification landscape:

ApproachUser Friction Score (1-10)Bot Deterrence ThresholdCompute/Battery ImpactFallback Reliability
Traditional Image CAPTCHA8.5Low (solve farms bypass easily)NoneHigh
Pure PoW (SHA-256 Web Worker)4.2Medium (CPU cost scales linearly)High on mobileMedium
Dual-Tier (PoW + L402 Micro-Payment)1.8High (capital cost scales exponentially)Near-zero (opt-in)High

This finding matters because it decouples security from user experience. By offering a Lightning Network skip tier alongside a free computational path, you create a self-regulating verification ecosystem. Users who value time pay a sub-cent fee; users who prefer zero monetary cost complete a background hash. Both paths emit identical verification tokens, meaning your application logic remains unchanged. The architecture transforms CAPTCHA from a static hurdle into an adaptive economic filter.

Core Solution

The implementation relies on three coordinated components: a client-side widget that manages UI state and polling, a backend gateway that interfaces with an LNBits-compatible node, and a unified token issuance service that abstracts the verification path.

Step 1: Frontend Widget Integration

The widget renders a container element that initializes a SHA-256 proof-of-work computation inside a Web Worker. When the data-verify-tier="dual" attribute is present, a secondary action button appears alongside the progress indicator.

<form id="contact-form" action="/api/submit" method="POST">
  <input type="email" name="email" required />
  <textarea name="content" required></textarea>
  
  <div id="verify-container" 
       data-server-origin="https://api.yourdomain.com"
       data-verify-tier="dual"
       data-polling-interval="2000">
  </div>
  
  <input type="hidden" name="mg_token" id="mg-token-field" />
  <button type="submit" id="submit-btn" disabled>Submit</button>
</form>

<script type="module">
  import { initVerifyWidget } from './verify-widget.mjs';
  
  const widget = initVerifyWidget('#verify-container');
  
  widget.onTokenReady((token) => {
    document.getElementById('mg-token-field').value = token;
    document.getElementById('submit-btn').disabled = false;
  });
  
  widget.onFallback(() => {
    // User cancelled payment, resumes PoW
    console.log('Resuming computational path');
  });
</script>

The widget abstracts the verification state machine. It does not handle payments directly. Instead, it communicates with your backend via standardized endpoints. The data-polling-interval attribute controls how frequently the client checks payment status, preventing aggressive request patterns.

Step 2: Backend Invoice Generation

Your server exposes an initialization endpoint that requests a bolt11 invoice from an LNBits instance. The backend never exposes API keys to the client.

// src/routes/verify.ts
import express from 'express';
import axios from 'axios';

const router = express.Router();
const LNBITS_URL = process.env.LNBITS_URL!;
const LNBITS_KEY = process.env.LNBITS_INVOICE_KEY!;

router.post('/verify/init', async (req, res) => {
  try {
    const memo = `verify-${Date.now()}-${Math.random().toString(36).slice(2)}`;
    const response = await axios.post(
      `${LNBITS_URL}/api/v1/payments`,
      { out: false, amount: 3, memo },
      { headers: { 'X-Api-Key': LNBITS_KEY } }
    );

    res.json({
      bolt11: response.data.payment_request,
      payment_hash: response.data.payment_hash,
      expires_at: response.data.expires_at
    });
  } catch (err) {
    res.status(500).json({ error: 'Invoice generation failed' });
  }
});

The invoice amount is hardcoded to 3 sats in this example, but production systems should externalize this to a configuration service. The payment_hash serves as the primary identifier for status polling.

Step 3: Payment Status Polling

The client polls a status endpoint every 2 seconds. The backend queries LNBits for settlement state.

router.get('/verify/status/:hash', async (req, res) => {
  const { hash } = req.params;
  
  try {
    const response = await axios.get(
      `${LNBITS_URL}/api/v1/payments/${hash}`,
      { headers: { 'X-Api-Key': LNBITS_KEY } }
    );

    const isPaid = response.data.paid === true;
    
    res.json({
      settled: isPaid,
      status: isPaid ? 'verified' : 'pending'
    });
  } catch (err) {
    res.status(500).json({ error: 'Status check failed' });
  }
});

Polling is preferred over WebSockets for form verification because it aligns with HTTP/1.1 and HTTP/2 connection reuse, requires no persistent server state, and naturally degrades on poor networks. The 2-second interval balances latency with server load.

Step 4: Unified Token Issuance

Both the PoW completion handler and the payment settlement handler route through a single token service. This ensures your application receives identical verification payloads regardless of the user's chosen path.

// src/services/token-issuer.ts
import jwt from 'jsonwebtoken';

export class VerificationTokenIssuer {
  private secret: string;
  private expiry: number;

  constructor(secret: string, expiryMinutes = 15) {
    this.secret = secret;
    this.expiry = expiryMinutes;
  }

  issue(userId: string, method: 'pow' | 'l402'): string {
    return jwt.sign(
      {
        sub: userId,
        method,
        iat: Math.floor(Date.now() / 1000),
        exp: Math.floor(Date.now() / 1000) + (this.expiry * 60)
      },
      this.secret,
      { algorithm: 'HS256' }
    );
  }
}

By abstracting token generation, you eliminate conditional logic in your form handlers. The method claim allows analytics tracking without affecting security posture.

Architecture Rationale

  • Why LNBits? It provides a standardized REST interface for invoice creation and payment tracking, supports multiple backend implementations (LND, Core Lightning, Eclair), and isolates wallet management from application logic.
  • Why identical token formats? Decoupling verification method from downstream processing reduces code complexity. Your form handler validates a single JWT structure, regardless of whether the user computed a hash or paid a micro-invoice.
  • Why polling over webhooks? Webhooks require public endpoint exposure, retry logic, and state reconciliation. Polling is stateless, idempotent, and naturally fits the request-response lifecycle of form submissions.

Pitfall Guide

1. Invoice Expiry Mismatch

Explanation: LNBits invoices default to a 3600-second expiry. If a user opens the payment modal but delays scanning, the invoice expires mid-poll. The backend will never report settlement. Fix: Explicitly set expiry in the invoice request payload. Implement client-side countdown UI. Reject expired invoices server-side with a 410 Gone response and trigger a fresh invoice generation.

2. Polling Storm Degradation

Explanation: Aggressive polling intervals (e.g., 500ms) during high traffic can saturate your API gateway and LNBits node, increasing latency for all users. Fix: Enforce a minimum 2-second interval. Implement exponential backoff after 10 failed attempts. Add rate limiting at the API gateway level (/verify/status/:hash should be scoped per session).

3. Token Format Drift

Explanation: PoW and L402 handlers return different JWT claims or expiration times, causing downstream validation failures or inconsistent session behavior. Fix: Centralize token issuance in a single service class. Enforce schema validation using a library like zod or joi before signing. Log method type for analytics without altering security claims.

4. LNBits Key Exposure

Explanation: Accidentally shipping the invoice API key to the client bundle allows attackers to generate unlimited invoices or drain wallet balances. Fix: Never expose LNBITS_INVOICE_KEY in frontend code. Use environment variables strictly on the server. Validate that all LNBits requests originate from trusted backend routes.

5. Race Conditions on Payment Confirmation

Explanation: A user pays, but the poll hasn't caught the settlement yet. They refresh the page or abandon the form, losing the verification state. Fix: Cache settled payment hashes in Redis or a lightweight KV store with a TTL matching your token expiry. On page reload, check the cache before re-initializing the widget.

6. Mobile Wallet UX Friction

Explanation: QR codes render poorly on small screens. Copy-pasting bolt11 strings fails on iOS due to clipboard restrictions. Fix: Implement lightning: URI scheme deep linking. Provide a native share sheet fallback. Test across Phoenix, Alby, Zeus, and Wallet of Satoshi to ensure consistent intent resolution.

7. Economic Bypass via PoW Tuning

Explanation: Attackers ignore the L402 tier and scale PoW solving using cloud instances, negating the economic deterrent. Fix: Implement dynamic difficulty adjustment. Monitor submission velocity and automatically increase SHA-256 iteration counts during traffic spikes. Log PoW completion times to detect non-human patterns.

Production Bundle

Action Checklist

  • Provision LNBits instance: Deploy a dedicated LNBits node or use a managed provider. Generate a read-write invoice API key.
  • Configure environment variables: Set LNBITS_URL, LNBITS_INVOICE_KEY, and JWT_SECRET in your deployment pipeline. Never commit these to version control.
  • Implement token abstraction: Create a unified VerificationTokenIssuer class that handles both PoW and L402 paths with identical JWT schemas.
  • Add rate limiting: Protect /verify/init and /verify/status/:hash with session-scoped rate limits to prevent polling abuse.
  • Test invoice expiry flow: Simulate delayed payments and verify that expired invoices trigger graceful fallback to PoW.
  • Monitor settlement latency: Track average time between invoice creation and settled: true response. Alert if median exceeds 5 seconds.
  • Validate mobile deep links: Test lightning: URI resolution across iOS and Android wallets. Ensure copy-paste fallback works reliably.

Decision Matrix

ScenarioRecommended ApproachWhyCost Impact
High-volume public form (contact, signup)Dual-Tier (PoW + L402)Balances conversion rate with economic bot deterrence~$0.003 per skip; PoW is free
Internal admin panelPoW OnlyNo payment infrastructure needed; acceptable frictionZero monetary cost; minor CPU overhead
Enterprise compliance formTraditional CAPTCHA + L402 SkipMeets accessibility mandates while offering fast pathHigher implementation cost; L402 adds micro-fee
Low-traffic niche sitePoW OnlySimplicity outweighs economic gating benefitsZero cost; minimal maintenance
High-risk financial submissionL402 Only + KYCEliminates computational bypass; enforces identityHigher user friction; payment gateway fees

Configuration Template

# .env.production
LNBITS_URL=https://lnbits.yourdomain.com
LNBITS_INVOICE_KEY=your_readwrite_invoice_key_here
JWT_SECRET=super_secure_random_string_min_32_chars
TOKEN_EXPIRY_MINUTES=15
POW_DIFFICULTY_BASE=12
POW_DIFFICULTY_MAX=20
POLLING_INTERVAL_MS=2000
MAX_POLL_RETRIES=30
// src/config/verify.ts
import dotenv from 'dotenv';
dotenv.config();

export const VerifyConfig = {
  lnbits: {
    url: process.env.LNBITS_URL!,
    key: process.env.LNBITS_INVOICE_KEY!
  },
  token: {
    secret: process.env.JWT_SECRET!,
    expiryMinutes: parseInt(process.env.TOKEN_EXPIRY_MINUTES || '15', 10)
  },
  pow: {
    baseDifficulty: parseInt(process.env.POW_DIFFICULTY_BASE || '12', 10),
    maxDifficulty: parseInt(process.env.POW_DIFFICULTY_MAX || '20', 10)
  },
  polling: {
    intervalMs: parseInt(process.env.POLLING_INTERVAL_MS || '2000', 10),
    maxRetries: parseInt(process.env.MAX_POLL_RETRIES || '30', 10)
  }
};

Quick Start Guide

  1. Deploy LNBits: Spin up an LNBits instance locally or via a managed provider. Generate an invoice API key with read and write permissions.
  2. Initialize Backend Routes: Add /verify/init and /verify/status/:hash endpoints to your Express/Fastify server. Wire them to the LNBits REST API using the configuration template.
  3. Embed Widget: Add the container element to your HTML form with data-verify-tier="dual". Import the client module and bind the onTokenReady callback to your hidden input field.
  4. Test End-to-End: Submit a test form. Verify that the PoW path completes in the Web Worker. Click the skip button, scan the invoice with a Lightning wallet, and confirm the token populates before the 2-second poll cycle completes.
  5. Monitor & Tune: Track submission latency and settlement success rates. Adjust POW_DIFFICULTY_BASE and invoice amount based on traffic patterns and bot activity.