World ID Verification
ohshii_governance can gate ONS voting behind a World ID proof-of-personhood
check. This document is the single reference for end users (“what happens to
my data?”), developers (“how is this wired?”) and operators (“how do I
configure or pause it?”). It covers the verification flow, the three
configuration states, the privacy model, the cryptography behind the
on-chain RP signatures, the governed v3/v4 request-preset toggle, the
veto-override re-attestation round, the rate-limit / cycle-budget posture and
the governance handles exposed for operations.
Protocol version. OhShii integrates World ID 4.0 via
@worldcoin/idkit@4.x. World is migrating credential holders from World ID 3.0 to 4.0, so the canister accepts both wire shapes: a new World App user produces a 4.0 (proof_of_human) proof and a pre-migration holder produces a legacy 3.0 proof, and thePOST /api/v4/verify/{rp_id}endpoint accepts the legacy 3.0 shape natively (distinguished by theprotocol_versionfield on the request body — there is noallow_legacy_proofsfield on that body). Which shape the in-browser widget requests is a governed toggle (see §9); what the canister accepts is always both. See the live 3.0 → 4.0 migration guide.
Scope. The onboarding gate described here applies to ONS proposals (the OhShii ecosystem governance canister); ordinary SONS proposals — one DAO per LGE campaign — do not go through this onboarding gate today. The separate veto-override re-attestation round (see §5.5) also serves SONS-family DAOs, but always with the human attesting a fresh proof directly on ONS — ONS trusts no SONS assertion.
Integration model & current state (at a glance)
Section titled “Integration model & current state (at a glance)”A developer- and auditor-facing summary of where OhShii stands with World ID and why. The detailed mechanics are in the numbered sections below; this is the orientation a reader can skim.
Current state.
| Property | Value |
|---|---|
| Protocol | World ID 4.0 (the current protocol), in PREVIEW — World’s early-adopter phase before General Availability (GA). |
| RP signature | Produced on-chain by the canister via IC threshold ECDSA: the canister signs each proof request itself, and the signing key exists only as shares distributed across the subnet’s nodes — no off-canister server, no exported secret. See §5. |
| Management mode | Managed — World currently holds the key it validates the canister’s signature against. Verification works fully under Managed. |
The decentralization-relevant fact: the relying-party signature is a canister capability, identical in Managed and Self-Managed mode. The canister produces the same signature either way; the only difference is what World validates it against.
The v3→v4 migration is done — and is independent of Managed/Self-Managed.
- Moving from the legacy v3/3.0 proof format to v4 was required: World App users after the 4.0 cutover produce 4.0-only proofs. This migration is complete.
- World ID 4.0 does not require Self-Managed mode. Managed and Self-Managed are both valid under 4.0 — the protocol version (v3 vs v4) and the key-custody mode (Managed vs Self-Managed) are orthogonal. (This is the most common misconception; going to 4.0 does not force Self-Managed.)
- No existing verified user has to re-verify. Legacy 3.0 proofs are still accepted
during the transition — the in-browser widget’s legacy flag lets a pre-migration
holder produce a 3.0 proof, and
/api/v4/verifyaccepts the 3.0 shape natively. This preservation is load-bearing and holds regardless of the request toggle below.
The governed v3/v4 request toggle (what the DAO controls).
- A governed setting (
request_preseton the World ID config) controls which proof format the widget requests — admin-settable now (transition phase) and DAO-proposal-settable later (end-state, via a normal-lane proposal). The default requests v4-capable while still accepting legacy v3. See §7 and §9. - Scope, precisely: the toggle controls only what the widget requests, never what the canister accepts. Legacy acceptance stays on regardless, so the toggle can never lock out an existing verified user.
Managed vs Self-Managed — a deferred, optional decentralization upgrade (not a functional requirement).
- Managed = World holds the validation key. Self-Managed = the canister’s own on-chain-registered key is the validation target (World holds none of OhShii’s keys).
- Why switch: Self-Managed is the more decentralized posture — OhShii controls its signing identity on-chain, with no trust in a World-held key. It is a coherence/decentralization improvement, not a functional necessity: verification works identically either way.
- When: deferred to World ID GA. Switching during PREVIEW risks paying the on-chain registration fee twice if World forces a key rotation at GA; deferring buys one clean registration once the protocol is stable. Zero canister code change either way (the canister already signs correctly). See §5 “Self-Managed is deferred to GA”.
- Who: it is an operational action (a Portal switch + an on-chain RpRegistry
registration), not a canister action — so, unlike the request toggle, it is
not a DAO on-chain proposal. This is the one point where OhShii’s on-chain
decentralization meets an external service (World) with its own controls: authority
over the World account is external to the DAO’s governance.
[VERIFY-WITH-OWNER]the end-state custody of the World account at full decentralization (who holds it / who performs the GA switch).
What happens at GA.
GA = World ID 4.0 leaving preview and becoming the stable, final version (no
announced date yet — [VERIFY-WITH-OWNER]). At GA: (a) World may require a
signer-key rotation — the design tolerates it, because the canister’s key derives from
a stable derivation tag, so a rotation is a re-read + re-register, never a key loss;
(b) if OhShii wants the full decentralization posture, GA is the moment to switch
to Self-Managed, once and cleanly. The step-by-step lives in the private operator
runbook (see §11); the dev-facing summary is this paragraph.
Honest residuals (consistent with Capture Resistance).
- Managed mode is a small, disclosed trust in a World-held validation key — removable at GA via Self-Managed. A trade-off, not a flaw.
- A superseded legacy attestation trigger is retained callable during the transition; its hard-removal is a deferred follow-up (described in Capture Resistance, not re-explained here).
1. Configuration states
Section titled “1. Configuration states”The sybil gate has three distinct states, driven by two fields of
WorldIdConfig on canister:
| State | rp_id | enabled | Effect on ONS voting | What the user sees on /verify |
|---|---|---|---|---|
| Not configured | empty | either | No gate — voting is open to any authenticated principal. Verification flow cannot start. | Grey banner “Verification is not available yet.” CTA disabled. |
| Optional | set | false | No gate — voting is open. Verification is still possible; useful because several LGE participation tiers key off is_verified. | Amber banner “Verification is optional right now.” CTA reads “Verify anyway (optional)”. |
| Required | set | true | vote() rejects unless is_verified(caller) == true. Both the canister and the frontend enforce this. | Cyan banner “Verification required to vote on ONS proposals.” |
Transitions between states are governed:
- Not configured → Optional / Required: ONS proposal
set_world_id_config. - Optional ↔ Required: same proposal, flipping
enabled. - Emergency pause: ONS proposal
set_world_id_daily_cap(0)halts new verifications without touchingenabled; existing verifications remain valid.
The flag enabled only controls the vote gate. Users can still verify
voluntarily while enabled = false (as long as rp_id is set) so the
Human / Fish / Shark / Whale LGE tiers, which also read is_verified,
continue to work independently from the vote gate.
Two-method gate: combined semantics with DecideID
Section titled “Two-method gate: combined semantics with DecideID”World ID is one of two independent verification methods on
ohshii_governance. The other is DecideID — see
docs/DECIDEID_VERIFICATION.md. The
combined vote-gate condition in vote() is:
(world_id_cfg.enabled || decideid_cfg.enabled) && !is_any_method_verified(caller)Persistence on disable: a verification, once granted by either
method, is never cleared by a config change. Disabling World ID does NOT
remove existing principals from VERIFIED_USERS; disabling DecideID
does NOT remove them from DECIDEID_VERIFIED_USERS. The
is_any_method_verified helper reads the verified-user sets
directly, NOT the per-method enabled flags. A user verified via World
ID continues to pass the gate even after World ID is set back to
“Not Configured”, and they get the Human / Fish / Shark / Whale LGE
benefits from is_verified = true regardless of which method’s
enabled flag flipped.
What happens when BOTH methods are disabled: the OR of the two
enabled flags is false, so the gate does not fire and ONS voting
becomes open to any authenticated wallet — no verification required,
no per-method check. Anonymous principals are still rejected by the
reject_anonymous guard. All other voting rules (rate limit, voting
power, proposal status) still apply. A DAO that wants to halt voting
without lifting the sybil filter must leave at least one method
enabled = true and use set_world_id_daily_cap(0) /
set_decideid_daily_cap(0) to block new verifications instead.
WorldID .enabled | DecideID .enabled | ONS vote() for unverified caller |
|---|---|---|
| false | false | Allowed — gate is off |
| true | false | Rejected unless caller is in VERIFIED_USERS OR DECIDEID_VERIFIED_USERS |
| false | true | Rejected unless caller is in VERIFIED_USERS OR DECIDEID_VERIFIED_USERS |
| true | true | Rejected unless caller is in VERIFIED_USERS OR DECIDEID_VERIFIED_USERS |
2. End-user flow (the “Required” case)
Section titled “2. End-user flow (the “Required” case)”- User lands on
/verify. Page fetchesget_world_id_config()anonymously and renders the state banner, plus per-credential info for each method inrequired_identifier. - User presses Start World ID verification.
- Frontend calls
request_world_id_rp_signature(action)on canister. The canister signs anrp_contextvia IC threshold ECDSA and returns{ rp_id, nonce_hex, created_at, expires_at, signature_hex }. - Frontend opens the
@worldcoin/idkit@4.xwidget. What it requests follows the governedrequest_preset(see §9): the defaultV4Capableasks for a v4proof_of_humancredential, while the rollbackV3Legacyasks for the legacydocumentLegacypreset. The widget keeps theallow_legacy_proofsflag on in both cases so 3.0 credential holders still complete. The caller’s principal text is passed assignaland therp_contextfrom step 3 is forwarded verbatim. - User completes verification in their World App with the credential
World holds for them (e.g. an NFC-chipped document, or a 4.0
proof_of_humancredential). - IDKit returns a proof to the page. For a 4.0 credential it is a
proof_of_humanproof whoseproofis a 5-element array (plusissuer_schema_idandexpires_at_min, nomerkle_root); for a legacy 3.0 credential it is the older{ identifier, merkle_root, nullifier, proof, signal_hash }shape. The page readsresult.protocol_version("4.0"or"3.0") to tell them apart. - Frontend calls
register_world_id_verificationon canister, passing the common positional fields (merkle_root,nullifier,proof,signal_hash,identifier) plus a trailingopt WorldIdV4Proof:[{…}]carries the 5-element proof bundle for a 4.0 proof, or[]for a legacy 3.0 proof. The presence of that optional argument selects the verify path. The canister:- Validates input size / format.
- Recomputes the expected
signal_hashfromkeccak256(caller.to_text())and rejects any mismatch (binds the proof to the caller). - Rejects if the nullifier was already spent.
- Acquires a per-caller RAII lock to prevent the double-POST race.
- Consumes one slot of the canister-wide daily cap.
- POSTs the proof to
https://developer.world.org/api/v4/verify/{rp_id}in the matching 3.0 or 4.0 wire shape, via a single non-replicated HTTPS outcall. There is noallow_legacy_proofsfield on the verify body — the v4 endpoint accepts the 3.0 shape natively viaprotocol_version;allow_legacy_proofsis a client-side IDKit widget flag only. - On HTTP 200 +
success: true+ matchingidentifier, marks the caller as verified and the nullifier as spent.
- Frontend refreshes
am_i_verified(), closes the modal. User can now vote on ONS proposals.
When the VotePanel detects an ONS proposal and the gate is enabled, it
short-circuits handleVote into the verification modal if the caller is
not yet verified, instead of sending the (guaranteed-to-fail) vote call.
3. Privacy model
Section titled “3. Privacy model”The goal of this integration: verify a unique human without ever learning who that human is.
What the canister stores
Section titled “What the canister stores”| Persistent state | Shape | Contents |
|---|---|---|
VERIFIED_USERS | StableBTreeMap<Vec<u8>, u8>, MemoryId(53) | { principal_bytes → 1 }. The universal “is verified” boolean. |
USED_NULLIFIERS | StableBTreeMap<Vec<u8>, u8>, MemoryId(54) | { nullifier_bytes → 1 }. Blocks reuse so the same World ID cannot verify a second wallet. |
WORLD_ID_CONFIG | StableCell<WorldIdConfig>, MemoryId(55) | { rp_id, action, required_identifier, enabled, rp_key_name }. |
WORLD_ID_RP_PUBKEY_CACHE | StableCell<…>, MemoryId(56) | Cached SEC1-compressed public key derived from threshold ECDSA. |
WORLD_ID_DAILY_COUNTER | StableCell<{day_start_ns, count}>, MemoryId(57) | Rolling 24 h counter. Survives upgrades so bots cannot reset it by forcing a redeploy. |
WORLD_ID_DAILY_CAP | StableCell<u64>, MemoryId(58) | Ceiling on successful verifications per 24 h. Default 500. |
No timestamp is stored next to a principal’s verified = true flag. No
credential type is stored. No linkage between principal and nullifier is
stored in any single record: the two maps are indexed on disjoint keys.
The rate-limit counter (WORLD_ID_OP_RATE_LIMITS) and the in-flight
register lock (WORLD_ID_REGISTER_LOCKS) live in heap memory on purpose —
resetting both on upgrade is desirable (stale lock release, rate-limit
fairness) and the canister-wide daily counter in stable memory continues
to bound the economic exposure.
What World Foundation / Tools for Humanity hold
Section titled “What World Foundation / Tools for Humanity hold”Paraphrased from the official sources listed at the bottom of this doc and
on the /verify page:
- Document scans and biometric data never leave the user’s phone. They are held in the World App’s encrypted “Personal Custody Package”.
- World Foundation and Tools for Humanity state they do not retain images or data related to government IDs.
- The
/verifyendpoint receives the ZK proof (which is by construction zero-knowledge), plus thenullifier,merkle_root,signal_hash,action, andidentifier. It does not receive the user’s name, date of birth, photo, document number, or country.
What the OhShii canister sees
Section titled “What the OhShii canister sees”merkle_root(public commitment to the credential tree at a point in time; present on the legacy 3.0 path only — the 4.0proof_of_humanshape has nomerkle_root)nullifier(one-way hash of(app_id, action, human))proof(ZK proof bytes — a single hex blob on 3.0, a 5-element Groth16 array plusissuer_schema_idandexpires_at_minon 4.0)signal_hash(the canister recomputes and compares it againsthash_to_field(caller.to_text()); if they differ, the proof is rejected as a replay)
None of these fields is personally identifying on its own, on either protocol
version. We retain verified=true and the nullifier only; we never store
merkle_root, proof, signal_hash, action, or the credential type
persistently.
Two kinds of logs exist on IC canisters and both are redacted to avoid undoing the privacy properties above:
- Replica logs (
ic_cdk::println!) — the registration log line records the action name, the credential identifier, the configured allow-list, therp_id, and the proof length. It deliberately does not recordcaller,nullifier,merkle_rootorsignal_hash, so canister operators reading the replica log cannot reconstruct “who verified when”. - Event log in stable memory (
EVENT_LOG) — successful user verifications write an event with emptydetails, so the public log records that “a verification happened” without exposing who or how. Escape-hatch events (theadmin_*recovery methods) keep their arguments indetailsso those privileged operations are auditable.
Anyone calling get_recent_events can tell that “verifications are
happening at roughly this rate”; they cannot tell which principal
verified at any given time.
4. Sybil resistance: the two bindings
Section titled “4. Sybil resistance: the two bindings”Two checks turn the is_verified flag into a real sybil gate.
- Signal binding (in the canister). The
signalparameter IDKit hashes intosignal_hashis the caller’s principal text. The canister recomputes this hash with the samehash_to_fieldfunction World’s IDKit uses (keccak256(utf8) >> 8, 32-byte padded) and rejects any proof whose signal_hash does not match. A proof captured from principal A cannot be replayed on behalf of principal B because B’s expected signal_hash differs. - Nullifier uniqueness (on canister + at World). World’s verify
endpoint refuses to re-verify a nullifier it already saw. Independently,
the canister tracks
USED_NULLIFIERS— so even if World’s server behaviour ever loosened, one World ID would still map to one IC principal here.
The combined effect: a valid verification requires both (a) a real credential owned by a real human in the World App, and (b) that the credential has never verified another principal on this canister before.
Downstream consumers of is_verified
Section titled “Downstream consumers of is_verified”is_verified is the OR over World ID and DecideID and is consumed beyond ONS
vote gating. In particular, the Sponsor/referral system is verification-gated:
a sponsor↔user referral connection is only persisted on-canister, and a sponsor
only earns, once the invited user is verified. Until then the referrer code stays
pending client-side (no canister storage) and the referral reward stays with
the OhShii treasury. The check is made at purchase time and is not retroactive
to that buyer’s earlier unverified purchases — verifying mid-campaign does not
re-attribute rewards already routed to the treasury. See
SPONSOR_SYSTEM.md → Referral registration & verification gating.
5. RP signing via IC threshold ECDSA
Section titled “5. RP signing via IC threshold ECDSA”@worldcoin/idkit@4.x requires every proof request to carry an rp_context
signed with the relying party’s key. The signature algorithm is fixed:
| Property | Value |
|---|---|
| Curve | secp256k1 (same as Bitcoin and Ethereum) |
| Scheme | Recoverable ECDSA — 65 bytes: r || s || v |
| Message encoding | EIP-191 ("\x19Ethereum Signed Message:\n" + len + payload) |
| Hash | keccak256 |
Ed25519, BLS, Schnorr, HMAC-SHA256 and other algorithms would all have
required a different approach (a different chain-key primitive, or
custody of a shared secret). Because the scheme is standard Ethereum
personal_sign, IC’s native sign_with_ecdsa primitive matches exactly
and the canister acts as a legitimate relying party without ever touching
secret material.
IC threshold ECDSA in brief
Section titled “IC threshold ECDSA in brief”- The master private key is split across the nodes of a subnet in a
threshold-of-
nscheme. No single node holds it; no canister holds it. - The canister derives its per-purpose key with the
derivation_pathb"ohshii-governance/world-id/rp-signing/v1". - To sign, the canister calls
sign_with_ecdsa(message_hash, derivation_path, key_id). The subnet nodes run the threshold protocol and return a signature bit-for-bit identical to what an off-chain signer with the raw private key would produce. - The private key is not backed up, not exported and not cached anywhere. A malicious upgrade cannot extract it because the canister has never seen it.
The corresponding public key / Ethereum address is fetched once via
ecdsa_public_key (a free query) and cached in
WORLD_ID_RP_PUBKEY_CACHE in stable memory. During bring-up it is read
back with the get_world_id_rp_public_key method and the Ethereum address
is pasted into the signing-key field of the World Developer Portal.
EIP-191 in the canister
Section titled “EIP-191 in the canister”payload = version(1) || nonce(32) || created_at_u64_be(8) || expires_at_u64_be(8) || hashed_action(32)?envelope = "\x19Ethereum Signed Message:\n" + ascii(len(payload)) + payloadmsg_hash = keccak256(envelope)signature = ecdsa_sign(msg_hash) // 65 bytes recoverableThree subtle rules the implementation enforces:
hash_to_field(shared for bothsignal_hashandaction_hash):keccak256(input)shifted right 8 bits, left-padded to 32 bytes. This matches@worldcoin/idkit-core/hashing#hashToFieldexactly; earlier implementations that masked the top nibble instead produced signatures World could not reconstruct and failed withgeneric_error.- Low-S normalisation (EIP-2): if
sign_with_ecdsaever returns a high-S signature, the canister normalises tos' = n - sbefore emitting. EIP-2-strict verifiers — which include World’s — reject high-S outputs even if mathematically valid. vrecovery.sign_with_ecdsaonly returns(r, s). The canister tries both recovery candidates (0 and 1), recovers to the public key withk256::VerifyingKey::recover_from_prehash, and keeps the one that matches the cached public key; exactly one will match.
| Key | Cycles / signature | USD (approx.) |
|---|---|---|
key_1 (production) | ~26 B | ~$0.003 |
test_key_1 (dev + mainnet testing) | ~10 B | ~$0.001 |
Setting rp_key_name to a different value invalidates the pubkey cache;
the new Ethereum address must be re-registered in the Portal before
signatures start working again.
Why we sign unconditionally (even in Managed mode)
Section titled “Why we sign unconditionally (even in Managed mode)”The canister invokes sign_with_ecdsa on every
request_world_id_rp_signature call, regardless of whether the World
Developer Portal is in Managed or Self-Managed mode. The
production app currently runs in Managed mode; the transition to
Self-Managed is deliberately deferred to World ID GA (see the
deferral note below). We did not add a config flag to skip the signing
when the Portal is Managed. The reasoning:
- The Portal in Managed mode neither validates nor rejects our
signature cryptographically. Our canister-derived public key is
not registered anywhere — Managed mode means the Portal holds its
own key. Empirically (verified end-to-end on mainnet), flows with
our canister-signed
rp_contextsucceed in Managed mode; this is consistent with the widget either discarding our signature after a structural check or replacing it with a Portal-provided one before sending to the bridge. We cannot distinguish these two behaviours from the outside because the relevant logic is inside a WASM binary. - Cost is bounded. Each
sign_with_ecdsacall onkey_1is26 B cycles ($0.003). The daily cap onregister_world_id_verificationis 500 by default; signing requests are rate-limited to 5/hour/principal and short-circuit for already- verified principals. Worst-case spend from this wasted signing in Managed mode is ~$0.17/day — small enough that the simplicity argument wins. - The code path is identical for Self-Managed. Moving to
Self-Managed is a Portal-side operation (registration on World Chain
via the
RpRegistrycontract, see §11). The canister produces exactly the same signature in both modes; what changes is whether World’s bridge validates it against a registered public key. Keeping the signing always-on means the transition to Self-Managed requires zero canister code changes. - Forward compatibility. The threshold-ECDSA signing stack
(EIP-191 wrap,
hash_to_field, low-S normalisation,vrecovery) is the same infrastructure needed for any future feature that signs Ethereum-style messages (e.g. treasury flows on EVM chains). Adding a “skip in Managed” toggle would introduce a conditional that could diverge over time and would need to be removed before Self-Managed.
If the worst-case cycle cost ever becomes material (e.g. the daily cap
is raised 10×), revisit this decision. A small config flag
skip_rp_signature: bool could be added, paired with a hardcoded
placeholder signature that the IDKit WASM accepts structurally — but
the placeholder would need to be empirically validated against a
current IDKit build before shipping.
Self-Managed is deferred to GA (deliberate)
Section titled “Self-Managed is deferred to GA (deliberate)”Today the integration runs in Managed mode. Switching to Self-Managed means registering the canister’s threshold-ECDSA Ethereum address with World as the relying-party signing key, so World validates the signatures the canister already produces (the integration code’s path for this is World’s on-chain RP registry). We have deliberately deferred that registration to World ID GA rather than doing it now, to avoid a double registration: if World rotates or re-keys the relying-party registry at GA — a real possibility while the 4.0 protocol is still stabilising — an address registered today would have to be torn down and re-registered, opening a window where the registered key and the canister’s live key disagree and signatures fail closed.
The canister code path is identical either way — it always signs — so making the switch at GA is a registry/Portal-side operation with zero canister code changes. Until then, Managed mode is the supported posture and all seven security properties in §10 hold.
[VERIFY-WITH-OWNER] The exact GA registration mechanism (World’s on-chain RP registry vs a Developer-Portal signing-key field) and the precise GA timing are owner/operations decisions; the rationale above (defer to avoid a double registration across a possible GA key rotation) is the design intent.
Forward use
Section titled “Forward use”The same infrastructure signs any EIP-191 message under any
derivation_path. Future features that need Ethereum-style signatures
(e.g. a treasury flow interacting with an EVM DEX) can reuse it without
introducing new secrets.
5.5 Veto-override re-attestation
Section titled “5.5 Veto-override re-attestation”When a Guardian vetoes a proposal, an overridable veto opens a second
override voting round. Voting in that round requires a fresh World ID
personhood proof — completed onboarding (the standing is_verified flag) is
not enough. This is by design: a fresh proof-of-personhood per override
round is the whole point of the round.
Key properties (all enforced on canister):
- Fresh proof per round. Each override round has its own World action,
ohshii-ovr:{dao}:{proposal_id}. World binds the proof to that action, and the canister records the proof’s nullifier in a per-override map keyed by(dao, proposal_id, nullifier). A human’s nullifier therefore differs across overrides, and a proof for one override cannot satisfy another. The override path never reads or writes the onboarding sets (VERIFIED_USERS/USED_NULLIFIERS), except a read-onlyis_any_method_verifiedadmission check that rejects callers who have not completed onboarding before any cycle-spending step. - One human, one ballot. The per-override nullifier map enforces that the same World ID re-attests at most once per override round; a second principal presenting the same nullifier for the same override is rejected.
- Caller-driven — ONS trusts no SONS assertion. The override round also
serves SONS-family DAOs, but a human always attests their own personhood
directly on ONS. The endpoint
attest_fresh_override_personhood_for_sonstakes no borrowable “voting principal” argument:msg_caller()IS the subject, and the suppliedsons_dao_idis inert key material — it only builds the World action string, the nullifier key and a DAO-dimensioned marker. It never feeds an eligibility, voting-power, authorization, budget or rate decision. Per-DAO eligibility is checked locally in SONS, never on ONS, so a caller naming an arbitrarysons_dao_idcannot bend an authorization outcome. A SONS DAO later learns the attestation through the read-onlydid_attest_overridequery (a pull, never a trigger). - Same threshold-ECDSA signer, same v3/v4 acceptance.
request_override_rp_signaturemints the RP signature for the per-override action using the same threshold-ECDSA pipeline as onboarding (override voters are already verified, so there is nocfg.actionconstraint here). The override/verifyoutcall accepts both 3.0 and 4.0 proofs exactly like the onboarding path — the same trailingopt WorldIdV4Proofselects the wire shape. - Bounded availability residual (honest caveat). The override flow is
rate-limited (a per-caller, per-proposal cap) and bounded by a canister-wide
outcall budget and the 2 T cycle floor. The worst case under pressure is a
delayed override window, not capture: there is no drain-to-freeze. A
guardian-removal proposal is vetoable-but-overridable, so a contested
removal (one the guardian vetoes) completes through this same override and
shares the bounded delay — but the guardian’s only power is the overridable
veto (it cannot approve, spend, or alter a result), and the removal of a
guardian that does not veto is decided in round 1, off this path; so the
worst case stays a delayed contested override, not capture. A separate
hard-removal of
the deprecated
verify_fresh_override_proof_fortrigger ICC is a deferred follow-up; until then the caller-driven path and that legacy ICC coexist (replacement-before-removal). These residuals are bounded, not fully closed.
Why the override is World-ID-only (not DecideID)
Section titled “Why the override is World-ID-only (not DecideID)”Normal voter eligibility accepts World ID OR DecideID (see
§1 and
docs/DECIDEID_VERIFICATION.md); the override’s
fresh re-attestation is World-ID-only. The reason is the threat the override
exists to annul: acquired or farmed verified identities. An attacker need not
verify many humans — they can control a stockpile of already-verified principals
(the verified set is on-chain) and aim that bloc at a Critical proposal. A static
“is this principal verified?” check counts the whole stockpile; only forcing a
fresh, live proof of personhood collapses it onto the real humans behind it.
- World ID’s
nullifieris a per-human anchor. It is deterministic in(app_id, action, the human's World account)and stable across principals — the same human re-attesting from a different wallet yields the same nullifier for a given action. With a fresh per-(DAO, proposal)action the override de-duplicates on that nullifier, giving one human → one override ballot however many verified principals the human holds, and an attacker cannot forge a fresh proof for a human they do not control. - DecideID is principal-bound. Its verification sets a per-principal flag with per-presentation anti-replay; there is no cross-principal human anchor. A human controlling N bought principals has N independent DecideID verifications, so re-verifying with DecideID would just re-confirm each principal — it would not collapse the bloc to one human, i.e. it would empty the weapon. DecideID is a fine eligibility method but cannot power the override.
This raises the cost of a bought-identity take-over and makes it annullable by the community; it does not eliminate a coalition of enough distinct, real humans each casting a fresh proof — it prices that up. Full threat → defense narrative: Governance & Capture Resistance → “Why World ID — and why not DecideID — for the override”.
6. Rate limits and cycle budget
Section titled “6. Rate limits and cycle budget”The /verify outcall costs ~100 B cycles and each threshold-ECDSA signing
costs ~26 B. Bots can mint unlimited IC principals, so two layered
defences keep the worst case bounded:
Per-principal (heap, non-persistent)
Section titled “Per-principal (heap, non-persistent)”- 5 attempts / hour shared between
request_world_id_rp_signatureandregister_world_id_verification. - 30 s cooldown between consecutive attempts from the same principal.
- Already-verified principals are short-circuited out of
request_world_id_rp_signature, so no cycles are spent on them.
Canister-wide (stable, persistent)
Section titled “Canister-wide (stable, persistent)”consume_world_id_daily_cap_slotguards the/verifyoutcall with a rolling-24 h cap (StableCell<u64>, default500). Slot consumption happens only after the cheap quick-fail checks (nullifier already spent, invalid inputs), so replay attempts do not eat the budget.- The cap is configurable via
set_world_id_daily_cap. Passing0pauses the feature instantly:register_world_id_verificationrejects with a clear error, no outcall attempted. - At ~$0.05 per verification, the default budget bounds daily cycle spend from this feature at ~$25.
Race protection
Section titled “Race protection”WorldIdRegisterLockGuard (RAII lock, Drop-released) prevents two
concurrent register_world_id_verification calls from the same principal
from both passing the is_world_id_verified check and both paying the
outcall. The lock releases even if the callback traps — required, because
IC rolls back post-await state on trap but pre-await state persists, and
a manually released lock would stick forever on a trap.
Cycle floor pre-check (2 T)
Section titled “Cycle floor pre-check (2 T)”Both request_world_id_rp_signature and register_world_id_verification
abort immediately if
ic_cdk::api::canister_cycle_balance() < MIN_CYCLES_FOR_WORLD_ID
(memory::MIN_CYCLES_FOR_WORLD_ID = 2_000_000_000_000). Rationale:
- Each end-to-end flow spends roughly
26 B(threshold ECDSA) +100 B(HTTPS outcall) =126 Bcycles. A 2 T floor leaves ~16× headroom plus reserve for the freezing-threshold margin (~30 days of idle burn). - The pre-check fires before the rate-limit bump, the daily-cap slot consumption, and the per-caller register lock — a cycle-starved call wastes none of those resources.
- On failure, the canister:
- returns
Err("InsufficientCycles: …")to the caller; - emits the standard
ic_cdk::println!line (same style as theupload.rspre-payment cycle check it mirrors); - writes a
world_id_cycles_insufficiententry toEVENT_LOGin stable memory (detailsfield carries<context> current=<n> min=<n>) so operators see the failure inget_recent_eventswithout tailing the replica log.
- returns
This behaviour uses the same primitive (canister_cycle_balance()) as
the rest of the codebase’s cycle guards; the constant is deliberately
separate from MIN_REQUIRED_CYCLES (1 T) so tuning one does not
silently shift the other. Operators monitoring SystemLifecycle events
for world_id_cycles_insufficient get an early warning that the
canister needs topping up before the gate silently starts rejecting
users.
7. Governance control
Section titled “7. Governance control”All World ID configuration lives on canister and is changed by ONS proposals. The Create Proposal form exposes two entries under “Governance Configuration”:
| Method | Args | Purpose |
|---|---|---|
set_world_id_config | rp_id, action, required_identifier, enabled, rp_key_name | Full config update (preserves request_preset, which has its own setter). required_identifier is comma-separated: a single proposal can allow multiple credential types (e.g. "document,secure_document"). Bumping action burns every existing nullifier and is a soft “reset everyone” lever; use the version suffix deliberately (-vote-v1, -vote-v2). |
set_world_id_request_preset | preset: variant { V4Capable; V3Legacy } | Toggles ONLY what the in-browser widget requests: V4Capable (the safe default) requests a v4-capable proof_of_human credential; V3Legacy is an emergency narrowing rollback to the legacy documentLegacy request. Never changes what the canister accepts — the v4 /verify endpoint accepts legacy 3.0 proofs natively, so v3 holders keep verifying in every preset state. Settable by an admin during the pre-DAO transition or by a NORMAL ExecuteAdminMethod proposal after decentralization (guarded by is_admin_or_self_guard). |
set_world_id_daily_cap | cap: nat64 | Canister-wide ceiling on verifications per 24 h. 0 pauses without touching anything else. |
Both methods are guarded by is_admin_or_self_guard, so they are callable
either by an admin principal during bootstrap or by governance itself via
an ExecuteAdminMethod proposal (the canister executes the call on
itself via call_raw(canister_self(), ...)).
Read-only queries for observability:
| Method | Returns |
|---|---|
get_world_id_config | The current WorldIdConfig. |
get_world_id_daily_cap | The u64 ceiling. |
get_world_id_daily_counter | (day_start_ns, count_in_current_window). |
is_verified(principal) | Universal boolean flag. O(log n) scan on stable memory, usable by any caller. |
am_i_verified | Shortcut for is_verified(msg_caller()). |
The get_world_id_rp_public_key method is admin/self-gated (needs the
inter-canister call to the management canister) and is used only during
bring-up or after an rp_key_name change to fetch the Ethereum address
for the Developer Portal.
8. Admin escape hatches
Section titled “8. Admin escape hatches”Guarded by is_admin_guard and written to EVENT_LOG with full details
for auditability:
| Method | When to use |
|---|---|
admin_reset_world_id_rate_limit(opt principal) | Clears the heap rate-limit counter for one principal, or for everyone if null. Heap map is already upgrade-cleared; this is the no-upgrade equivalent. |
admin_clear_world_id_verified(principal) | Removes the universal verified flag for a principal. Does not free the nullifier. |
admin_clear_world_id_nullifier(text) | Frees a spent nullifier so it can verify again. Use alongside the one above. |
debug_world_id_rp_signature(opt text) | Same signing path as production, bypassing enabled / verified / rate-limit gates. For byte-diffing against a JS reference run in a generic_error incident. |
Because these are privileged recovery calls, the EVENT_LOG details field records
the target principal or nullifier verbatim — that is the audit trail.
9. Frontend integration
Section titled “9. Frontend integration”IDKit version and the governed request preset
Section titled “IDKit version and the governed request preset”- Package:
@worldcoin/idkit@^4.1.1(only stable 4.x line; 1.x/2.x are deprecated by World). - The widget requires
rp_contexton every proof request — for both v4proof_of_humanconstraints and legacy presets. The canister provides it; the frontend does not. - Request preset (governed). What the widget requests is driven by
WorldIdConfig.request_preset, decoded bydecodeWorldIdRequestModeinconfig/worldid.js:V4Capable(the safe default — also the value used when the config is absent or unreadable) → the widget sends a v4proof_of_humanconstraints request. Combined with theallow_legacy_proofs={true}widget flag, this single request satisfies BOTH new 4.0-only World App users AND existing 3.0 credential holders — it locks no one out.V3Legacy→ the widget falls back to the legacydocumentLegacy({ signal })request. This is a deliberate emergency narrowing / rollback state, NOT a steady state while 4.0-only users exist: it would lock out new World App users who can only produce a 4.0 proof.
- The preset only changes what the widget REQUESTS, never what the canister
ACCEPTS. The v4
/verifyendpoint accepts legacy 3.0 proofs natively (the canister sends the matching 3.0 wire shape), so v3 holders keep verifying in every preset state.request_presetis read only by the frontend and by its dedicated setter — it is never consulted on the canister’s verify/accept path.allow_legacy_proofsis likewise a client-side IDKit widget flag, not a field of the verify request body. - The accepted credential allow-list is the separate
WorldIdConfig.required_identifier(production:document,secure_document, withproof_of_humanaccepted on the 4.0 path). Switch it viaset_world_id_configif governance moves toorb,selfie_check, etc.
Content-Security-Policy
Section titled “Content-Security-Policy”@worldcoin/idkit instantiates a WebAssembly module and opens a WebSocket
session against the World bridge. The asset canister’s
.ic-assets.json5 CSP includes:
script-src 'self' 'wasm-unsafe-eval' https://challenges.cloudflare.com—'wasm-unsafe-eval'only allowsWebAssembly.compile()/WebAssembly.instantiate(). It does not allow JavaScripteval(), so the XSS surface is unchanged. Without it the widget cannot bootstrap and fails with aCompileErrorbefore ever reaching the bridge.connect-src— addshttps://bridge.worldcoin.org,https://bridge.world.org,https://developer.worldcoin.org,https://developer.world.org, and thewss://variants.frame-src— addshttps://worldcoin.org,https://world.org,https://id.worldcoin.org.
Three-state UX on /verify
Section titled “Three-state UX on /verify”The page fetches get_world_id_config() anonymously on mount (no auth
required) and renders different banners + CTA labels per state:
| State | Banner | CTA |
|---|---|---|
not_configured | Grey, “Verification is not available yet.” | Disabled, “Not available” |
optional | Amber, “Verification is optional right now.” Mentions LGE tier benefits. | Enabled, “Verify anyway (optional)“ |
required | Cyan, “Verification required to vote on ONS proposals.” | Enabled, “Start World ID verification” |
The same signal flows into the vote flow: VotePanel.handleVote only
short-circuits into the verification modal when the proposal is on ONS
and gateEnabled and the caller is not yet verified.
10. Security properties
Section titled “10. Security properties”These hold regardless of whether enabled is true or false:
- No private key custody. The canister never sees the signing key. A malicious upgrade cannot exfiltrate it. Dumping canister memory yields no secret material relevant to RP signing.
- No PII. The canister receives no personally identifying
information, not even through the
/verifyresponse body; response headers are stripped by the transform function for consensus determinism, and no PII fields exist in the body. - No principal ↔ nullifier linkage in persistent state.
VERIFIED_USERSkeys on principal;USED_NULLIFIERSkeys on nullifier; no third map joins them, no log line writes both together. - Proof replay is closed. The
signal_hashbinding check on canister ensures a proof for principal A cannot be accepted on behalf of principal B, even if/verifyitself accepted the proof cryptographically. - Bot cycle drain is bounded. Per-principal rate limit × daily cap caps the worst-case daily spend at ~$25 / day regardless of how many principals the attacker controls.
- Governance can pause instantly.
set_world_id_daily_cap(0)halts new verifications immediately.set_world_id_config(..., enabled=false, ...)removes the vote requirement without touching anything else. - Replica logs do not enumerate verified users. No combination of replica log + stable event log reveals “principal X verified at time T”.
Nothing above depends on Managed vs Self-Managed mode in the World Developer Portal. Managed mode means World holds/validates the signing key; Self-Managed lets the canister’s own threshold-ECDSA public key be the registered relying-party key. The app runs in Managed mode today; the move to Self-Managed is the planned end-state but is deferred to World ID GA (see §5). It removes the residual dependency on Portal key custody, but it does not affect any of the 7 properties above.
Why Self-Managed strengthens (without adding) the properties
Section titled “Why Self-Managed strengthens (without adding) the properties”In Managed mode the World Developer Portal generates and holds the RP signing key server-side. A compromise of Portal infrastructure (insider, breach, key leak) would let an attacker craft RP signatures for our app in our name. The proof itself would still need to come from a real World App on a real device — so the worst case is “attacker can trigger verification flows pretending to be us”, not “attacker can mint verifications” — but the trust footprint includes Worldcoin’s key custody.
In Self-Managed mode the only key that can sign valid RP requests is the canister’s threshold-ECDSA key, held distributively across an IC subnet. No Worldcoin server, no OhShii admin, and no single IC node ever sees the full key. A compromise of either Worldcoin or our admin identities cannot forge RP signatures for this app. Property 1 (“no private key custody”) becomes literally true on both ends of the connection — not just on our canister.
The on-canister code path is identical in both modes: the canister
signs every request_world_id_rp_signature via sign_with_ecdsa. In
Managed mode the bridge ignores that signature and uses the World-side
key; in Self-Managed mode the bridge validates that signature against
the canister’s registered Ethereum address. Switching modes is a
registry/Portal-side operation, not a canister upgrade, and is
deferred to GA (see §5) — see
operator checklist (§11) for the bring-up sequence.
11. Operator checklist
Section titled “11. Operator checklist”Bring-up sequence (mainnet):
- Create an app in the World Developer Portal. Note the
rp_id(rp_…) and the legacy-formatapp_id(app_…) — the frontend widget needs theapp_…form, the canister uses therp_…form. - Register an action with the identifier configured in the canister
(
ohshii-governance-vote-v1by default). - In the Portal, enable the credentials the canister accepts (e.g.
documentandsecure_document). - Leave the Portal in Managed mode. The transition to Self-Managed
(registering the canister’s threshold-ECDSA Ethereum address as the
relying-party signing key) is deferred to World ID GA, to avoid a
double registration across a possible GA key rotation — see
§5. The canister signs the
rp_contextunconditionally in both modes, so this is a registry/Portal-side step performed at GA, with no canister change. - Deploy the canister. (At GA, when switching to Self-Managed) call
dfx canister --network ic call ohshii_governance get_world_id_rp_public_keywith the bootstrap admin identity and register the returnedethereum_addressas the relying-party key with World. Not required while in Managed mode. - Paste the real
app_idinto src/frontend/src/config/worldid.js and rebuild the frontend. - Configure the canister with the Portal values:
Keep
Terminal window dfx canister --network ic call ohshii_governance set_world_id_config \'("rp_REAL", "ohshii-governance-vote-v1", "document,secure_document", false, "key_1")'enabled = falseinitially — this is the “Optional” state so volunteers can test the full flow without blocking normal voting. - End-to-end test: verify with a real World account, confirm
is_verified(principal)returnstrue, confirm the nullifier is recorded, confirm a second attempt with the same World account on a second principal fails. - Flip to
enabled = truevia an ONS proposal (or temporarily via admin call during bootstrap) once the community is ready.
Ongoing operations:
- Monitor
get_world_id_daily_counter(); raise the cap withset_world_id_daily_capif legitimate demand approaches the ceiling. - Monitor canister cycles. The canister enforces a hard floor of 2 T
cycles on every World ID entry-point — below that threshold
request_world_id_rp_signatureandregister_world_id_verificationreject immediately and emit aworld_id_cycles_insufficientevent to stable memory. Watchget_recent_eventsfor this event type; it is the earliest signal that the canister needs topping up. - Rotate
rp_key_nameonly if strictly needed: doing so invalidates the pubkey cache and requires re-registering the new Ethereum address in the Portal.
12. Implementation map
Section titled “12. Implementation map”| Concern | Path |
|---|---|
Signing pipeline (EIP-191 wrap, hash_to_field, low-S normalisation, v recovery) | src/ohshii_governance/src/worldid_signing.rs |
| All stable storage (config, verified set, nullifiers, daily cap/counter, pubkey cache) | src/ohshii_governance/src/memory.rs |
| Verification flow, admin escape hatches, RAII lock, vote gate | src/ohshii_governance/src/lib.rs — register_world_id_verification, vote, admin_* |
| Governed v3/v4 request-preset toggle | src/ohshii_governance/src/types.rs (WorldIdRequestPreset, WorldIdConfig::request_preset + getter), lib.rs set_world_id_request_preset, src/frontend/src/config/worldid.js decodeWorldIdRequestMode |
| Veto-override re-attestation (fresh personhood per round, caller-driven SONS path) | src/ohshii_governance/src/lib.rs — request_override_rp_signature, attest_fresh_override_personhood, attest_fresh_override_personhood_for_sons, verify_fresh_override_proof_for, did_attest_override, has_attested_fresh_override; per-override nullifier map in memory.rs |
| External HTTPS + request/response types (v3 + v4 verify bodies) | src/ohshii_governance/src/external_types.rs pub mod worldid |
| Candid interface | src/ohshii_governance/ohshii_governance.did |
| Frontend modal | src/frontend/src/components/Governance/WorldIDVerificationModal.jsx |
| Verify page | src/frontend/src/pages/VerifyPage.jsx |
| Vote-side gate | src/frontend/src/components/Governance/VotePanel.jsx |
| Frontend config (app_id, action, environment) | src/frontend/src/config/worldid.js |
| CSP headers | src/frontend/.ic-assets.json5 |
| ONS proposal entries (governance controls) | src/frontend/src/components/Governance/CreateProposalForm.jsx |
References
Section titled “References”- World Help Center · What are World ID Credentials and how do I use them?
- World Foundation · Privacy FAQs
- World ID technical docs · Core concepts
- World ID technical docs · 3.0 → 4.0 migration guide
- World ID API reference · Developer Portal
verifyendpoint - World ID technical docs · IDKit integration guide
- World ID technical docs · RP signatures specification
- World ID technical docs · Legacy presets
The privacy and data-handling claims in this document are paraphrased from the sources above as of the date this document was last updated. World Foundation and Tools for Humanity may revise their policies, product behaviour or documentation at any time — consult the current official sources before drawing hard conclusions.
See also
Section titled “See also”- ARCHITECTURE.md — Canister map and governance scope table.
- GOVERNANCE.md — ONS vs SONS, proposal categories, snapshot/restore.
- WORKFLOWS.md — Create LGE, token, vote, create/execute proposal, backend vs frontend upgrade, self-snapshot flows.
- LOGGING_SYSTEM.md — Three-tier logging architecture, stable memory log details, frontend monitoring.
- FRONTEND.md — Frontend architecture: React + JSX + JSDoc
@ts-checktype-checking, the canister-types.js + utils/canister.js bridges, Candid wire conventions, auth providers, and the TypeScript version policy.