knock-verifier is a small Rust service you run beside your site. It gives out challenges and checks knocks for one origin, your site's, and keeps what it has seen in a SQLite file. It reads ENS on Ethereum mainnet and bindings on World Chain through RPCs, needs no keys, and never calls Knock.
If your server runs JavaScript or TypeScript, you don't need the service: @knock-dev/verify runs the same checks inside your server.
Build it
From the root of a checkout of getknock/knock:
cargo build --release -p knock-verifier
./target/release/knock-verifierIt's one binary, with SQLite built in.
Settings
Everything is an environment variable, and only VERIFIER_ORIGIN usually needs setting.
| Variable | Default | What it is |
|---|---|---|
VERIFIER_ORIGIN | http://localhost:3001 | Your site's origin, exactly as browsers send it: https://host or https://host:port, or http://localhost or http://127.0.0.1, with or without a port, for development. Knocks for any other origin fail check 2, and CORS allows only this one. |
VERIFIER_ADDR | 127.0.0.1:8788 | Where it listens. |
VERIFIER_DB | knock-verifier.db | Its SQLite file. |
VERIFIER_ETH_RPC | https://ethereum-rpc.publicnode.com | An Ethereum mainnet RPC, for ENS. |
VERIFIER_WORLD_RPC | https://worldchain-mainnet.g.alchemy.com/public | A World Chain RPC, for bindings. |
VERIFIER_MAX_BINDING_DAYS | 365 | How old a binding's World ID proof may be, in days. Older bindings fail check 6 as binding_expired. |
VERIFIER_STRICT | off | Whether a binding must be final on World Chain first: off, on (or safe), or finalized. Strict mode explains the choice. |
VERIFIER_KNOCK_BINDING | knock | The KnockBinding whose bindings count: knock for Knock's, or the address of a network of your own's. |
The default origin is http://localhost:3001, for development, so set yours:
VERIFIER_ORIGIN=https://club.example \
VERIFIER_DB=/var/lib/knock/verifier.db \
VERIFIER_ETH_RPC=https://your-mainnet-rpc.example \
VERIFIER_WORLD_RPC=https://your-worldchain-rpc.example \
./target/release/knock-verifierWhat it checks before it starts
Before it takes a single knock, the verifier makes sure of where it reads from, and stops if anything is off:
VERIFIER_ORIGINis an origin it can check knocks for.VERIFIER_ETH_RPCis chain 1, Ethereum mainnet, and ENS's Universal Resolver is there.VERIFIER_WORLD_RPCis chain 480, World Chain, and the KnockBinding it accepts reports version 3.
The KnockBinding it accepts is Knock's production v3 on World Chain, 0x7748D2787DcB6E6024968edC19fcA13ccAbA1699, built in from contracts/deployments/480.json, unless VERIFIER_KNOCK_BINDING names a network of your own's. Bindings on any other KnockBinding, including staging's and older versions', never count.
Verify from your server
Your page gets the knock, but your server should decide what it means. If the browser asks the verifier and then tells your server "they're in", your server is taking the browser's word for it. Instead, have the browser hand the knock to your server, and your server ask the verifier:
browser -- the knock --> your server -- POST /verify --> verifier
browser <-- in, or why not -- your server <-- the answer --Keep the verifier on 127.0.0.1, where only your server can reach it, and give the browser two routes on your own origin. In Next.js:
const VERIFIER = process.env.KNOCK_VERIFIER ?? 'http://127.0.0.1:8788'
// A challenge for the page, passed through as it is
export async function POST() {
const r = await fetch(`${VERIFIER}/challenge`, { method: 'POST' })
return new Response(r.body, { status: r.status, headers: { 'content-type': 'application/json' } })
}const VERIFIER = process.env.KNOCK_VERIFIER ?? 'http://127.0.0.1:8788'
export async function POST(req: Request) {
const { assertion } = await req.json()
const r = await fetch(`${VERIFIER}/verify`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ assertion, action: 'enter' }),
})
const v = await r.json()
if (!v.ok) return Response.json({ ok: false, check: v.check, error: v.error, why: v.why }, { status: r.status })
// In: one human, bound on World Chain. Their nullifier is the same on every Knock site.
const { name, nullifier } = v.knock
await letIn({ human: nullifier, name }) // your session, vote, claim…
return Response.json({ ok: true, name })
}Check 2 compares the origin inside the knock, the one the Door saw, with VERIFIER_ORIGIN. Where the request to /verify comes from doesn't matter, so your server can make it.
From the browser, for trying things out
A page can ask the verifier itself, because the verifier allows CORS from VERIFIER_ORIGIN. It's fine for a demo, or when nothing on your server depends on the answer. Then the verifier must be reachable from the browser, behind your TLS proxy, at an address such as https://knock.club.example.
One verifier per origin
A verifier checks knocks for one origin, because the origin is part of what the Wedgie signs. Run one for each:
https://club.exampleandhttps://www.club.exampleare two origins, so two verifiers, or one canonical host.- Staging at
https://staging.club.examplegets its own verifier and its own database. - For development, run one with
VERIFIER_ORIGIN=http://localhost:5173or wherever your dev server is.
Its database
The SQLite file at VERIFIER_DB holds four tables:
| Table | Holds | If you lose it |
|---|---|---|
challenges | Each challenge, its origin, when it was given out and whether it's been used. Expired ones are cleared as new ones are given out. | Knocks under way fail check 2. People knock again. |
counters | The highest counter seen for each Wedgie's key. | No harm: old knocks still fail check 2, since their challenges are gone. |
revoked | Keys it has seen revoked in ENS, which stay refused even if their records come back. | A revoked key could get in again if its owner turned it back on. |
entries | For each action, the humans who've done it, with the name they knocked as and when. | Every human can do every action once more. |
Back it up if entries matter to you. It's one SQLite file, so one verifier process uses it at a time.
Its RPCs
The default RPCs are free public endpoints. They're good for trying Knock out, but they limit how often you can call them, and the verifier makes several calls for each knock: the mainnet head, the name's five Knock records at the latest block and again 3 blocks deep (unless it read the same ones in the last minute), and the binding's receipt on World Chain. For a real site, use your own endpoints or a paid provider.
Your RPCs are also what the verifier believes. An RPC that lies could show records or a binding that aren't on chain. Use providers you trust, or your own nodes. The verifier never logs an RPC's URL, since URLs often carry keys.
When an RPC can't be reached, the verifier refuses the knock as unavailable, with status 503. It never lets a knock in on a guess.
Limits
| Limit | Value | When it's hit |
|---|---|---|
| Request body | 16 KiB | Refused at check 1, status 400. A knock is under 1 KiB. |
| Challenges | 600 an hour, per process | busy, status 429 |
| Checks | 600 an hour, per process | busy, status 429 |
| Challenges outstanding | 10,000 at once | busy at check 2, status 429 |
Health and logs
GET /health says what the verifier checks for:
{
"binding": "0x7748d2787dcb6e6024968edc19fca13ccaba1699",
"maxBindingDays": 365,
"ok": true,
"origin": "https://club.example",
"strict": "off"
}It logs each answer, who got in or which check said no, and nothing about who asked:
INFO let in wedgie.itzraj.eth (counter 12)
INFO refused at V8: already_enteredIn your own Rust server
knock-verifier is a thin HTTP layer over the knock-verify crate. If your server is in Rust, you can call the checks directly and skip the extra service.