Skip to content

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 the POST /api/v4/verify/{rp_id} endpoint accepts the legacy 3.0 shape natively (distinguished by the protocol_version field on the request body — there is no allow_legacy_proofs field 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.

PropertyValue
ProtocolWorld ID 4.0 (the current protocol), in PREVIEW — World’s early-adopter phase before General Availability (GA).
RP signatureProduced 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 modeManaged — 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/verify accepts 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_preset on the World ID config) controls which proof format the widget requestsadmin-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).

The sybil gate has three distinct states, driven by two fields of WorldIdConfig on canister:

Staterp_idenabledEffect on ONS votingWhat the user sees on /verify
Not configuredemptyeitherNo gate — voting is open to any authenticated principal. Verification flow cannot start.Grey banner “Verification is not available yet.” CTA disabled.
OptionalsetfalseNo 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)”.
Requiredsettruevote() 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 touching enabled; 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 .enabledDecideID .enabledONS vote() for unverified caller
falsefalseAllowed — gate is off
truefalseRejected unless caller is in VERIFIED_USERS OR DECIDEID_VERIFIED_USERS
falsetrueRejected unless caller is in VERIFIED_USERS OR DECIDEID_VERIFIED_USERS
truetrueRejected 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)”
  1. User lands on /verify. Page fetches get_world_id_config() anonymously and renders the state banner, plus per-credential info for each method in required_identifier.
  2. User presses Start World ID verification.
  3. Frontend calls request_world_id_rp_signature(action) on canister. The canister signs an rp_context via IC threshold ECDSA and returns { rp_id, nonce_hex, created_at, expires_at, signature_hex }.
  4. Frontend opens the @worldcoin/idkit@4.x widget. What it requests follows the governed request_preset (see §9): the default V4Capable asks for a v4 proof_of_human credential, while the rollback V3Legacy asks for the legacy documentLegacy preset. The widget keeps the allow_legacy_proofs flag on in both cases so 3.0 credential holders still complete. The caller’s principal text is passed as signal and the rp_context from step 3 is forwarded verbatim.
  5. 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_human credential).
  6. IDKit returns a proof to the page. For a 4.0 credential it is a proof_of_human proof whose proof is a 5-element array (plus issuer_schema_id and expires_at_min, no merkle_root); for a legacy 3.0 credential it is the older { identifier, merkle_root, nullifier, proof, signal_hash } shape. The page reads result.protocol_version ("4.0" or "3.0") to tell them apart.
  7. Frontend calls register_world_id_verification on canister, passing the common positional fields (merkle_root, nullifier, proof, signal_hash, identifier) plus a trailing opt 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_hash from keccak256(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 no allow_legacy_proofs field on the verify body — the v4 endpoint accepts the 3.0 shape natively via protocol_version; allow_legacy_proofs is a client-side IDKit widget flag only.
    • On HTTP 200 + success: true + matching identifier, marks the caller as verified and the nullifier as spent.
  8. 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.


The goal of this integration: verify a unique human without ever learning who that human is.

Persistent stateShapeContents
VERIFIED_USERSStableBTreeMap<Vec<u8>, u8>, MemoryId(53){ principal_bytes → 1 }. The universal “is verified” boolean.
USED_NULLIFIERSStableBTreeMap<Vec<u8>, u8>, MemoryId(54){ nullifier_bytes → 1 }. Blocks reuse so the same World ID cannot verify a second wallet.
WORLD_ID_CONFIGStableCell<WorldIdConfig>, MemoryId(55){ rp_id, action, required_identifier, enabled, rp_key_name }.
WORLD_ID_RP_PUBKEY_CACHEStableCell<…>, MemoryId(56)Cached SEC1-compressed public key derived from threshold ECDSA.
WORLD_ID_DAILY_COUNTERStableCell<{day_start_ns, count}>, MemoryId(57)Rolling 24 h counter. Survives upgrades so bots cannot reset it by forcing a redeploy.
WORLD_ID_DAILY_CAPStableCell<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 /verify endpoint receives the ZK proof (which is by construction zero-knowledge), plus the nullifier, merkle_root, signal_hash, action, and identifier. It does not receive the user’s name, date of birth, photo, document number, or country.
  • merkle_root (public commitment to the credential tree at a point in time; present on the legacy 3.0 path only — the 4.0 proof_of_human shape has no merkle_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 plus issuer_schema_id and expires_at_min on 4.0)
  • signal_hash (the canister recomputes and compares it against hash_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, the rp_id, and the proof length. It deliberately does not record caller, nullifier, merkle_root or signal_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 empty details, so the public log records that “a verification happened” without exposing who or how. Escape-hatch events (the admin_* recovery methods) keep their arguments in details so 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.


Two checks turn the is_verified flag into a real sybil gate.

  1. Signal binding (in the canister). The signal parameter IDKit hashes into signal_hash is the caller’s principal text. The canister recomputes this hash with the same hash_to_field function 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.
  2. 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.

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.


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

PropertyValue
Curvesecp256k1 (same as Bitcoin and Ethereum)
SchemeRecoverable ECDSA — 65 bytes: r || s || v
Message encodingEIP-191 ("\x19Ethereum Signed Message:\n" + len + payload)
Hashkeccak256

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.

  • The master private key is split across the nodes of a subnet in a threshold-of-n scheme. No single node holds it; no canister holds it.
  • The canister derives its per-purpose key with the derivation_path b"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.

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)) + payload
msg_hash = keccak256(envelope)
signature = ecdsa_sign(msg_hash) // 65 bytes recoverable

Three subtle rules the implementation enforces:

  • hash_to_field (shared for both signal_hash and action_hash): keccak256(input) shifted right 8 bits, left-padded to 32 bytes. This matches @worldcoin/idkit-core/hashing#hashToField exactly; earlier implementations that masked the top nibble instead produced signatures World could not reconstruct and failed with generic_error.
  • Low-S normalisation (EIP-2): if sign_with_ecdsa ever returns a high-S signature, the canister normalises to s' = n - s before emitting. EIP-2-strict verifiers — which include World’s — reject high-S outputs even if mathematically valid.
  • v recovery. sign_with_ecdsa only returns (r, s). The canister tries both recovery candidates (0 and 1), recovers to the public key with k256::VerifyingKey::recover_from_prehash, and keeps the one that matches the cached public key; exactly one will match.
KeyCycles / signatureUSD (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:

  1. 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_context succeed 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.
  2. Cost is bounded. Each sign_with_ecdsa call on key_1 is 26 B cycles ($0.003). The daily cap on register_world_id_verification is 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.
  3. The code path is identical for Self-Managed. Moving to Self-Managed is a Portal-side operation (registration on World Chain via the RpRegistry contract, 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.
  4. Forward compatibility. The threshold-ECDSA signing stack (EIP-191 wrap, hash_to_field, low-S normalisation, v recovery) 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.

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.


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-only is_any_method_verified admission 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_sons takes no borrowable “voting principal” argument: msg_caller() IS the subject, and the supplied sons_dao_id is 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 arbitrary sons_dao_id cannot bend an authorization outcome. A SONS DAO later learns the attestation through the read-only did_attest_override query (a pull, never a trigger).
  • Same threshold-ECDSA signer, same v3/v4 acceptance. request_override_rp_signature mints the RP signature for the per-override action using the same threshold-ECDSA pipeline as onboarding (override voters are already verified, so there is no cfg.action constraint here). The override /verify outcall accepts both 3.0 and 4.0 proofs exactly like the onboarding path — the same trailing opt WorldIdV4Proof selects 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_for trigger 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 nullifier is 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”.


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:

  • 5 attempts / hour shared between request_world_id_rp_signature and register_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.
  • consume_world_id_daily_cap_slot guards the /verify outcall with a rolling-24 h cap (StableCell<u64>, default 500). 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. Passing 0 pauses the feature instantly: register_world_id_verification rejects with a clear error, no outcall attempted.
  • At ~$0.05 per verification, the default budget bounds daily cycle spend from this feature at ~$25.

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.

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 B cycles. 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:
    1. returns Err("InsufficientCycles: …") to the caller;
    2. emits the standard ic_cdk::println! line (same style as the upload.rs pre-payment cycle check it mirrors);
    3. writes a world_id_cycles_insufficient entry to EVENT_LOG in stable memory (details field carries <context> current=<n> min=<n>) so operators see the failure in get_recent_events without tailing the replica log.

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.


All World ID configuration lives on canister and is changed by ONS proposals. The Create Proposal form exposes two entries under “Governance Configuration”:

MethodArgsPurpose
set_world_id_configrp_id, action, required_identifier, enabled, rp_key_nameFull 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_presetpreset: 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_capcap: nat64Canister-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:

MethodReturns
get_world_id_configThe current WorldIdConfig.
get_world_id_daily_capThe 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_verifiedShortcut 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.


Guarded by is_admin_guard and written to EVENT_LOG with full details for auditability:

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


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_context on every proof request — for both v4 proof_of_human constraints 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 by decodeWorldIdRequestMode in config/worldid.js:
    • V4Capable (the safe default — also the value used when the config is absent or unreadable) → the widget sends a v4 proof_of_human constraints request. Combined with the allow_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 legacy documentLegacy({ 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 /verify endpoint 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_preset is read only by the frontend and by its dedicated setter — it is never consulted on the canister’s verify/accept path. allow_legacy_proofs is 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, with proof_of_human accepted on the 4.0 path). Switch it via set_world_id_config if governance moves to orb, selfie_check, etc.

@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 allows WebAssembly.compile() / WebAssembly.instantiate(). It does not allow JavaScript eval(), so the XSS surface is unchanged. Without it the widget cannot bootstrap and fails with a CompileError before ever reaching the bridge.
  • connect-src — adds https://bridge.worldcoin.org, https://bridge.world.org, https://developer.worldcoin.org, https://developer.world.org, and the wss:// variants.
  • frame-src — adds https://worldcoin.org, https://world.org, https://id.worldcoin.org.

The page fetches get_world_id_config() anonymously on mount (no auth required) and renders different banners + CTA labels per state:

StateBannerCTA
not_configuredGrey, “Verification is not available yet.”Disabled, “Not available”
optionalAmber, “Verification is optional right now.” Mentions LGE tier benefits.Enabled, “Verify anyway (optional)“
requiredCyan, “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.


These hold regardless of whether enabled is true or false:

  1. 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.
  2. No PII. The canister receives no personally identifying information, not even through the /verify response body; response headers are stripped by the transform function for consensus determinism, and no PII fields exist in the body.
  3. No principal ↔ nullifier linkage in persistent state. VERIFIED_USERS keys on principal; USED_NULLIFIERS keys on nullifier; no third map joins them, no log line writes both together.
  4. Proof replay is closed. The signal_hash binding check on canister ensures a proof for principal A cannot be accepted on behalf of principal B, even if /verify itself accepted the proof cryptographically.
  5. 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.
  6. 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.
  7. 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.


Bring-up sequence (mainnet):

  1. Create an app in the World Developer Portal. Note the rp_id (rp_…) and the legacy-format app_id (app_…) — the frontend widget needs the app_… form, the canister uses the rp_… form.
  2. Register an action with the identifier configured in the canister (ohshii-governance-vote-v1 by default).
  3. In the Portal, enable the credentials the canister accepts (e.g. document and secure_document).
  4. 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_context unconditionally in both modes, so this is a registry/Portal-side step performed at GA, with no canister change.
  5. Deploy the canister. (At GA, when switching to Self-Managed) call dfx canister --network ic call ohshii_governance get_world_id_rp_public_key with the bootstrap admin identity and register the returned ethereum_address as the relying-party key with World. Not required while in Managed mode.
  6. Paste the real app_id into src/frontend/src/config/worldid.js and rebuild the frontend.
  7. Configure the canister with the Portal values:
    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")'
    Keep enabled = false initially — this is the “Optional” state so volunteers can test the full flow without blocking normal voting.
  8. End-to-end test: verify with a real World account, confirm is_verified(principal) returns true, confirm the nullifier is recorded, confirm a second attempt with the same World account on a second principal fails.
  9. Flip to enabled = true via 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 with set_world_id_daily_cap if 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_signature and register_world_id_verification reject immediately and emit a world_id_cycles_insufficient event to stable memory. Watch get_recent_events for this event type; it is the earliest signal that the canister needs topping up.
  • Rotate rp_key_name only if strictly needed: doing so invalidates the pubkey cache and requires re-registering the new Ethereum address in the Portal.

ConcernPath
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 gatesrc/ohshii_governance/src/lib.rsregister_world_id_verification, vote, admin_*
Governed v3/v4 request-preset togglesrc/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.rsrequest_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 interfacesrc/ohshii_governance/ohshii_governance.did
Frontend modalsrc/frontend/src/components/Governance/WorldIDVerificationModal.jsx
Verify pagesrc/frontend/src/pages/VerifyPage.jsx
Vote-side gatesrc/frontend/src/components/Governance/VotePanel.jsx
Frontend config (app_id, action, environment)src/frontend/src/config/worldid.js
CSP headerssrc/frontend/.ic-assets.json5
ONS proposal entries (governance controls)src/frontend/src/components/Governance/CreateProposalForm.jsx

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.


  • 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-check type-checking, the canister-types.js + utils/canister.js bridges, Candid wire conventions, auth providers, and the TypeScript version policy.