<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.
```typescript
// 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).
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
Decision Matrix
| Scenario | Recommended Approach | Why | Cost 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 panel | PoW Only | No payment infrastructure needed; acceptable friction | Zero monetary cost; minor CPU overhead |
| Enterprise compliance form | Traditional CAPTCHA + L402 Skip | Meets accessibility mandates while offering fast path | Higher implementation cost; L402 adds micro-fee |
| Low-traffic niche site | PoW Only | Simplicity outweighs economic gating benefits | Zero cost; minimal maintenance |
| High-risk financial submission | L402 Only + KYC | Eliminates computational bypass; enforces identity | Higher 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
- Deploy LNBits: Spin up an LNBits instance locally or via a managed provider. Generate an invoice API key with
read and write permissions.
- 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.
- 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.
- 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.
- Monitor & Tune: Track submission latency and settlement success rates. Adjust
POW_DIFFICULTY_BASE and invoice amount based on traffic patterns and bot activity.