Skip to content
lenmei233Public

About

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.

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

Lap - Fork form Cap

banner

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.

How To Deploy


Contents


Why serverless Lap

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.

How it works

                 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 }
  1. The widget asks the Worker for a challenge — a signed JWT plus the parameters c (count), s (salt size) and d (difficulty).
  2. The browser derives c salt/target pairs from the token and brute-forces a nonce for each so that sha256(salt + nonce) starts with target. This is the proof-of-work; it costs the user a moment and a bot a fortune at scale.
  3. The Worker re-derives the same pairs, checks every nonce, and — if the challenge has not been replayed — issues a one-time redeem token.
  4. Your backend exchanges that token for a success: true at /siteverify. Tokens are single-use and are consumed on first verification.

Prerequisites

  • A Cloudflare account (free is fine).
  • Node.js 18+ (Node 22 recommended).
  • Wrangler, Cloudflare's CLI — no global install needed, npx wrangler works.
git clone <your-fork-url> lap
cd lap/cloudflare
npm install
npx wrangler login      # opens a browser to authorise your account

Deployment A — Workers + D1 (recommended)

The fastest path, and the one used by CI. Everything happens in cloudflare/.

1. Create the D1 database

npx wrangler d1 create lap-serverless

Wrangler 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 as env.DB. Don't rename it unless you also change src/worker.js.

2. Apply the database schema

npx wrangler d1 migrations apply lap-serverless --remote

This creates six tables: site_keys, tokens, nonces, blocklist, ratelimit and meta. The migration is idempotent, so re-running it is safe.

3. Set the admin key

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`

4. Deploy

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"}

5. (Optional) Use your own domain

In the Cloudflare dashboard: Workers & Pages → your Worker → Settings → Domains & Routes → Add custom domain. A domain already on Cloudflare gets TLS automatically.


Deployment B — Cloudflare Pages Functions

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.

Via the CLI

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-serverless

Bind D1 and the admin key

Pages projects don't read wrangler.toml bindings, so set them in the dashboard — Workers & Pages → your Pages project → Settings:

  1. Bindings → Add → D1 database
    • Variable name: DB
    • D1 database: lap-serverless
    • Add it to both Production and Preview.
  2. Environment variables → Add → Encrypt
    • Name: ADMIN_KEY, value: your random string, then click Encrypt.
  3. Redeploy so the bindings take effect.

Static files in public/ are served directly by Cloudflare's edge and take precedence over Functions, so /widget.js is served as a plain static asset on Pages — slightly faster, and identical in content to the Workers route.

Connect a Git repo instead

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.


Deployment C — GitHub Actions (CI/CD)

.github/workflows/deploy-serverless.yml tests and deploys on every push to main that touches cloudflare/ or widget/.

1. Create a Cloudflare API token

Cloudflare dashboard → My Profile → API Tokens → Create Token → Create Custom Token with these permissions:

Type Resource Permission
Account Workers Scripts Edit
Account D1 Edit

2. Add the GitHub secrets

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.

3. Push

git push origin main

The 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).


Create a site key

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}}'

Add the widget to your site

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"].

Programmatic use

<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>

Events

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"));

Styling

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;
}

Other bundles

Path Purpose
/widget.js main build (modern browsers)
/widget.compat.js legacy-browser build
/floating.js floating / invisible mode helper

Verify tokens on your server

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")
    # ...continue
PHP
$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'); }

Configuration reference

Per-site key config

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: 4 with challengeCount: 80 takes roughly a second on a modern laptop. Raise challengeCount (linear cost) before raising difficulty (exponential cost) — it gives smoother, more predictable timing.

Global defaults

Set in wrangler.toml as a JSON string (Pages: an environment variable):

[vars]
LAP_GLOBAL = "{\"ratelimitMax\":30,\"ratelimitDuration\":5000,\"blockNonBrowserUA\":true,\"requiredHeaders\":[]}"

Secrets and bindings

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

API reference

Public

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

Admin — all require x-admin-key

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.

Local development

cd cloudflare
npm install

echo "ADMIN_KEY=dev-secret" > .dev.vars
npx wrangler d1 migrations apply lap-serverless --local
npx wrangler dev --local

The Worker runs on http://127.0.0.1:8787 against a local SQLite file, with no Cloudflare account required.

Testing

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, verifies

test: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.

Troubleshooting

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.

Credits & differences from Cap

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 atomic INSERT OR IGNORE on 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.mjs performs 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.

One deliberate exception to the rebrand

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.)

License

Apache-2.0 — see LICENSE.

Copyright © 2025–present tiago and Lap contributors.

About

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.

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages