English · 简体中文
Lap is a lightweight, open-source CAPTCHA alternative built on proof-of-work and instrumentation challenges. No images, no puzzles, no tracking — users are verified in the background while their browser does a little maths.
This repository packages Lap as a fully serverless deployment that runs on Cloudflare Workers or Cloudflare Pages Functions, with Cloudflare D1 (SQLite) as the only backing store. No Docker, no Redis, no VPS, no always-on process — and it fits comfortably inside Cloudflare's free tier.
Lap is a rebranded fork of Cap by tiago, licensed under Apache-2.0. See Credits.
- Why serverless Lap
- How it works
- Prerequisites
- Deployment A — Workers + D1 (recommended)
- Deployment B — Cloudflare Pages Functions
- Deployment C — GitHub Actions (CI/CD)
- Create a site key
- Add the widget to your site
- Verify tokens on your server
- Configuration reference
- API reference
- Local development
- Testing
- Troubleshooting
- Credits & differences from Cap
| Zero infrastructure | One Worker + one D1 database. Nothing to patch or keep alive. |
| Free tier friendly | Fits in Cloudflare's free Workers + D1 allowances. |
| Self-hosted widget | The Worker serves the browser widget itself at /widget.js — no third-party CDN, no npm package, no supply-chain risk. |
| Privacy-first | No telemetry, no cookies, no cross-site tracking. |
| Runs at the edge | Challenges are issued from the Cloudflare PoP nearest the user. |
1. POST /:siteKey/challenge
┌──────────┐ ──────────────────────────► ┌─────────────────┐
│ │ ◄────────────────────────── │ │
│ Browser │ { challenge, token } │ Lap Worker │──► D1
│ (widget) │ │ (Cloudflare) │ (SQLite)
│ │ 2. POST /:siteKey/redeem │ │
│ │ ──────────────────────────► └─────────────────┘
└──────────┘ ◄──────────────────────────
{ success, token }
│
│ 3. token travels with your form submit
▼
┌──────────┐ 4. POST /siteverify {secret, response}
│ Your │ ─────────────────────────────────────► Lap Worker
│ backend │ ◄─────────────────────────────────────
└──────────┘ { success: true }
- The widget asks the Worker for a challenge — a signed JWT plus the
parameters
c(count),s(salt size) andd(difficulty). - The browser derives
csalt/target pairs from the token and brute-forces a nonce for each so thatsha256(salt + nonce)starts withtarget. This is the proof-of-work; it costs the user a moment and a bot a fortune at scale. - The Worker re-derives the same pairs, checks every nonce, and — if the challenge has not been replayed — issues a one-time redeem token.
- Your backend exchanges that token for a
success: trueat/siteverify. Tokens are single-use and are consumed on first verification.
- A Cloudflare account (free is fine).
- Node.js 18+ (Node 22 recommended).
- Wrangler, Cloudflare's CLI — no global install needed,
npx wranglerworks.
git clone <your-fork-url> lap
cd lap/cloudflare
npm install
npx wrangler login # opens a browser to authorise your accountThe fastest path, and the one used by CI. Everything happens in cloudflare/.
npx wrangler d1 create lap-serverlessWrangler prints a TOML block. Copy the database_id value into
cloudflare/wrangler.toml, replacing REPLACE_WITH_YOUR_D1_ID:
[[d1_databases]]
binding = "DB"
database_name = "lap-serverless"
database_id = "0f9c1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b" # <- yours
binding = "DB"is what the code reads asenv.DB. Don't rename it unless you also changesrc/worker.js.
npx wrangler d1 migrations apply lap-serverless --remoteThis creates six tables: site_keys, tokens, nonces, blocklist,
ratelimit and meta. The migration is idempotent, so re-running it is safe.
The admin key protects the /admin/* endpoints that mint site keys. It is a
Worker secret — never commit it.
npx wrangler secret put ADMIN_KEY
# paste a long random string, e.g. `openssl rand -hex 32`npm run deploy # runs sync-widget, then `wrangler deploy`Your Worker is live at https://lap-serverless.<your-subdomain>.workers.dev.
Check it:
curl https://lap-serverless.<your-subdomain>.workers.dev/health
# {"ok":true,"service":"lap-serverless","version":"1.0.0"}In the Cloudflare dashboard: Workers & Pages → your Worker → Settings → Domains & Routes → Add custom domain. A domain already on Cloudflare gets TLS automatically.
Use this if you prefer Pages, or want to host a static site and the CAPTCHA
backend from the same project. cloudflare/functions/[[path]].js is a
catch-all route that forwards every request to the same Worker code, so both
targets run identical logic.
cd cloudflare
node scripts/sync-widget.mjs # generates public/ + src/assets/
npx wrangler pages project create lap-serverless --production-branch main
# Static assets live in public/, Functions are auto-detected from ./functions
npx wrangler pages deploy public --project-name lap-serverlessPages projects don't read wrangler.toml bindings, so set them in the
dashboard — Workers & Pages → your Pages project → Settings:
- Bindings → Add → D1 database
- Variable name:
DB - D1 database:
lap-serverless - Add it to both Production and Preview.
- Variable name:
- Environment variables → Add → Encrypt
- Name:
ADMIN_KEY, value: your random string, then click Encrypt.
- Name:
- Redeploy so the bindings take effect.
Static files in
public/are served directly by Cloudflare's edge and take precedence over Functions, so/widget.jsis served as a plain static asset on Pages — slightly faster, and identical in content to the Workers route.
Workers & Pages → Create → Pages → Connect to Git, then set:
| Setting | Value |
|---|---|
| Build command | node scripts/sync-widget.mjs |
| Build output directory | public |
| Root directory | cloudflare |
Add the same D1 and ADMIN_KEY bindings as above.
.github/workflows/deploy-serverless.yml tests and deploys on every push to
main that touches cloudflare/ or widget/.
Cloudflare dashboard → My Profile → API Tokens → Create Token → Create Custom Token with these permissions:
| Type | Resource | Permission |
|---|---|---|
| Account | Workers Scripts | Edit |
| Account | D1 | Edit |
Repo → Settings → Secrets and variables → Actions → New repository secret:
| Secret | Where to find it |
|---|---|
CLOUDFLARE_API_TOKEN |
the token from step 1 |
CLOUDFLARE_ACCOUNT_ID |
Workers & Pages → Account details → Account ID |
ADMIN_KEY is not a GitHub secret — it lives in the Worker. Set it once
with wrangler secret put ADMIN_KEY; deploys preserve existing secrets.
git push origin mainThe workflow runs the test suite, verifies the committed widget bundle is not
stale, applies D1 migrations, then deploys. You can also trigger it manually
from the Actions tab (workflow_dispatch).
A site key is a public identifier for one website; its secret is used by your backend to verify tokens. The secret is shown once — store it safely.
ORIGIN=https://lap-serverless.<your-subdomain>.workers.dev
ADMIN_KEY=<the key you set earlier>
curl -X POST "$ORIGIN/admin/keys" \
-H "x-admin-key: $ADMIN_KEY" \
-H "content-type: application/json" \
-d '{}'{
"id": "a1b2c3d4e5f60718293a4b5c",
"secret": "9f8e7d6c5b4a39281706f5e4d3c2b1a0",
"config": { "difficulty": 4, "challengeCount": 80, "saltSize": 32, "...": "..." }
}Tune a key at any time:
curl -X PUT "$ORIGIN/admin/keys/<id>" \
-H "x-admin-key: $ADMIN_KEY" -H "content-type: application/json" \
-d '{"config":{"difficulty":4,"challengeCount":50,"instrumentation":true}}'The Worker serves the widget itself, so there is no CDN or npm dependency:
<script src="https://lap-serverless.<your-subdomain>.workers.dev/widget.js"></script>
<form method="POST" action="/signup">
<input name="email" type="email" required />
<lap-widget
data-lap-api-endpoint="https://lap-serverless.<your-subdomain>.workers.dev/<SITE_KEY>/">
</lap-widget>
<button type="submit">Sign up</button>
</form>Note the trailing slash on data-lap-api-endpoint, and that the path
includes your site key — the widget appends challenge and redeem to it.
On success the widget writes a hidden input named lap-token into the
surrounding form, so your backend just reads req.body["lap-token"].
<script src=".../widget.js"></script>
<script>
const lap = new Lap({
apiEndpoint: "https://.../<SITE_KEY>/",
});
const { token } = await lap.solve();
// send `token` to your backend yourself
</script>const el = document.querySelector("lap-widget");
el.addEventListener("solve", (e) => console.log("token:", e.detail.token));
el.addEventListener("progress", (e) => console.log("progress:", e.detail.progress));
el.addEventListener("error", (e) => console.error("error:", e.detail.message));
el.addEventListener("reset", () => console.log("reset"));Every visual property is a CSS custom property prefixed --lap-:
lap-widget {
--lap-background: #11111b;
--lap-border-color: #313244;
--lap-border-radius: 12px;
--lap-color: #cdd6f4;
--lap-checkbox-background: #1e1e2e;
--lap-spinner-color: #89b4fa;
--lap-widget-width: 260px;
}| Path | Purpose |
|---|---|
/widget.js |
main build (modern browsers) |
/widget.compat.js |
legacy-browser build |
/floating.js |
floating / invisible mode helper |
Always verify server-side. A token from the browser means nothing until
/siteverify confirms it, and each token is consumed on first use.
curl -X POST "$ORIGIN/siteverify" \
-H "content-type: application/json" \
-d '{"secret":"<SITE_SECRET>","response":"<SITEKEY>:<ID>:<TOKEN>"}'
# {"success":true}Node.js / Express
app.post("/signup", async (req, res) => {
const token = req.body["lap-token"];
const r = await fetch(`${process.env.LAP_ORIGIN}/siteverify`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
secret: process.env.LAP_SECRET,
response: token,
}),
});
const { success } = await r.json();
if (!success) return res.status(403).send("CAPTCHA failed");
// ...continue
});Python / Flask
import os, requests
from flask import request, abort
@app.post("/signup")
def signup():
r = requests.post(
f"{os.environ['LAP_ORIGIN']}/siteverify",
json={
"secret": os.environ["LAP_SECRET"],
"response": request.form.get("lap-token"),
},
timeout=10,
)
if not r.json().get("success"):
abort(403, "CAPTCHA failed")
# ...continuePHP
$ch = curl_init(getenv('LAP_ORIGIN') . '/siteverify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'secret' => getenv('LAP_SECRET'),
'response' => $_POST['lap-token'] ?? '',
]),
]);
$ok = json_decode(curl_exec($ch), true)['success'] ?? false;
if (!$ok) { http_response_code(403); exit('CAPTCHA failed'); }Set at creation (POST /admin/keys) or later (PUT /admin/keys/:id).
| Field | Default | Meaning |
|---|---|---|
difficulty |
4 |
Target prefix length in hex chars. Each +1 is ~16× more work. |
challengeCount |
80 |
Number of sub-puzzles per challenge. |
saltSize |
32 |
Salt length in hex chars. |
instrumentation |
false |
Also run a browser-behaviour challenge. |
blockAutomatedBrowsers |
true |
Reject headless/automated browsers (needs instrumentation). |
obfuscationLevel |
3 |
1–10, obfuscation applied to the instrumentation script. |
rsw |
false |
Enable Reusable Serial Work challenges. |
rswT |
75000 |
RSW squaring iterations. |
ratelimitMax |
30 |
Requests allowed per window, per IP. |
ratelimitDuration |
5000 |
Window length in ms. |
requiredHeaders |
[] |
Reject requests missing any of these headers. |
blockNonBrowserUA |
true |
Reject non-browser user agents. |
Tuning:
difficulty: 4withchallengeCount: 80takes roughly a second on a modern laptop. RaisechallengeCount(linear cost) before raisingdifficulty(exponential cost) — it gives smoother, more predictable timing.
Set in wrangler.toml as a JSON string (Pages: an environment variable):
[vars]
LAP_GLOBAL = "{\"ratelimitMax\":30,\"ratelimitDuration\":5000,\"blockNonBrowserUA\":true,\"requiredHeaders\":[]}"| Name | Kind | Purpose |
|---|---|---|
ADMIN_KEY |
secret | Authorises /admin/* |
DB |
D1 binding | Site keys, tokens, nonces, blocklist, rate limits |
LAP_GLOBAL |
var (optional) | Global config JSON |
| Method | Path | Purpose |
|---|---|---|
POST |
/:siteKey/challenge |
Issue a challenge |
POST |
/:siteKey/redeem |
Submit solutions, receive a token |
POST |
/:siteKey/siteverify |
Verify a token (site-scoped) |
POST |
/siteverify |
Verify a token (site key read from the token) |
GET |
/health |
Health check |
GET |
/ |
Landing page |
GET |
/widget.js, /widget.compat.js, /floating.js |
Widget bundles |
| Method | Path | Purpose |
|---|---|---|
GET |
/admin/keys |
List site keys |
POST |
/admin/keys |
Create a site key |
GET |
/admin/keys/:id |
Inspect a site key |
PUT |
/admin/keys/:id |
Update its config |
DELETE |
/admin/keys/:id |
Delete it |
GET |
/admin/keys/:id/block |
List blocklist entries |
POST |
/admin/keys/:id/block |
Add an entry |
DELETE |
/admin/keys/:id/block/:key |
Remove an entry |
Blocklist entries accept a bare IP, or cidr:10.0.0.0/8, asn:13335,
country:CN.
cd cloudflare
npm install
echo "ADMIN_KEY=dev-secret" > .dev.vars
npx wrangler d1 migrations apply lap-serverless --local
npx wrangler dev --localThe Worker runs on http://127.0.0.1:8787 against a local SQLite file, with no Cloudflare account required.
cd cloudflare
npm test # crypto + protocol roundtrip, and in-process API tests
npm run test:roundtrip # SHA-256/AES-GCM vs Node, PoW & RSW roundtrips
npm run test:integration # every endpoint against an in-memory D1 mock
# These two need `wrangler dev` running in another terminal:
npm run test:e2e # full flow against real workerd + real D1
npm run test:browser # real Chromium: renders, solves, verifiestest:browser needs Playwright (npm i -D playwright && npx playwright install chromium). It is the test that catches client/server protocol drift, because
it drives the actual widget rather than re-using server code to fake a solution.
| Symptom | Cause / fix |
|---|---|
Invalid solution on every attempt |
Client and server disagree on the PoW derivation. Run npm run test:roundtrip — the widget-style solution accepted by server case pins this down. |
401 {"error":"Unauthorized"} |
Wrong or missing x-admin-key. |
{"error":"Admin key not configured"} |
ADMIN_KEY was never set: wrangler secret put ADMIN_KEY. |
no such table: site_keys |
Migrations not applied — run the d1 migrations apply step (add --remote for production). |
D1_ERROR right after deploy |
database_id in wrangler.toml is still the placeholder. |
429 Rate limited while testing |
Expected: 30 requests / 5 s per IP. Raise ratelimitMax. |
| Widget never appears | Check the browser console for a 404 on /widget.js, and that <lap-widget> is spelled correctly. |
| Widget shows but never solves | data-lap-api-endpoint must end in / and include the site key. |
403 Blocked from curl |
blockNonBrowserUA is on; send a browser User-Agent or disable it. |
| Pages deploy 500s on every route | D1 binding missing in the Pages project — add DB under Settings → Bindings, then redeploy. |
Lap is a rebranded fork of Cap by tiago, used under the Apache-2.0 licence. All of the cryptographic design and the widget UX are Cap's work.
What this fork changes:
- Serverless port.
node:crypto→ Web Crypto plus a pure-JS synchronous SHA-256/HMAC; Valkey/Redis → D1. Anti-replay uses an atomicINSERT OR IGNOREon a unique nonce column. - Self-hosted widget. The Worker serves the widget from
/widget.js, so there is no dependency on a published npm package. - Rebrand.
<cap-widget>→<lap-widget>,data-cap-*→data-lap-*,--cap-*→--lap-*,window.Cap→window.Lap,CAP_*→LAP_*.scripts/rebrand.mjsperforms this rewrite reproducibly. - Not included: the standalone analytics dashboard, per-country/ASN metrics and MaxMind geo-IP. Challenge/redeem/verify, blocklists and rate limiting are all present.
- Site secrets are hashed with SHA-256 rather than bcrypt, so no native dependency is required.
The widget's optional WASM accelerator is still fetched from the upstream package:
https://cdn.jsdelivr.net/npm/@cap.js/wasm@0.0.7/browser/cap_wasm_bg.wasm
That artifact is published under Cap's npm scope; there is no @lap.js/wasm,
so rewriting the URL would 404 and silently disable the fast solver. To host it
yourself, mirror the file and point the widget at your copy:
<script>window.LAP_CUSTOM_WASM_URL = "https://example.com/lap_wasm_bg.wasm";</script>
<script src=".../widget.js"></script>(The widget falls back to a pure-JS solver whenever the WASM module is unavailable, so this is an optimisation, not a requirement.)
Apache-2.0 — see LICENSE.
Copyright © 2025–present tiago and Lap contributors.