1. Motivation
World ID 4.0 supports multiple authenticators per account. Registering a new authenticator requires an existing Admin Authenticator (WIP-104) to authorize an InsertAuthenticator operation on WorldIDRegistry, and the new authenticator needs the account’s leaf_index, its assigned pubkey_id, and the user’s credentials to generate proofs. This requires:
- An interoperable, end-to-end encrypted channel between two authenticators across devices and providers.
- Explicit, informed user approval on the Approving Authenticator.
- Out-of-band authentication via a short pairing code typed into the Approving Authenticator, so possession of the Pairing URI alone cannot authorize a request or forge a response.
2. Summary
- The Requesting Authenticator generates a random
pairing_secret, a 6-letterpairing_code, and an ephemeral X-Wingresponse_pubkeykeypair. It signs a commitment (registration_digest) to its keys, encrypts a WIP-105worldid_auth_v1_registerrequest under a key derived frompairing_secretandpairing_code, publishes the ciphertext to the message-bridge, and displays aworldid://auth/v1/registerPairing URI (as a QR code or deeplink). - The Approving Authenticator opens the Pairing URI, fetches the single-use encrypted request from the bridge, and prompts the user to enter
pairing_code(which the Requesting Authenticator reveals once the bridge reports the request wasretrieved). - Using
pairing_code, the Approving Authenticator decrypts and validates the request, checks the account state, and asks the user for explicit approval (including a separate opt-in to share the credential vault). - Upon approval, the Approving Authenticator submits
InsertAuthenticatoronWorldIDRegistry(unless the key is already registered as requested), waits for finality, and returns the assigned(leaf_index, pubkey_id)and optionalvaultsealed toresponse_pubkeyand authenticated under the transport key. - The Requesting Authenticator decrypts and unseals the response, verifies on-chain that its key is registered at
(leaf_index, pubkey_id), and imports the vault.
3. Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
Authenticator roles and keys follow WIP-104. Messages MUST conform to WIP-105.
3.1 Terminology
- Requesting Authenticator: The new authenticator to be registered.
- Approving Authenticator: An existing Admin Authenticator registered on the user’s World ID.
- Bridge: An untrusted message-bridge deployment that relays encrypted requests and responses.
- Pairing Secret: 32 random bytes generated per attempt by the Requesting Authenticator and carried in the Pairing URI.
- Pairing Code: A 6-letter random code generated per attempt by the Requesting Authenticator, displayed after the bridge reports
retrieved, and manually entered into the Approving Authenticator.
3.2 Overview
sequenceDiagram
participant u as User
participant r as Requesting Auth (new)
participant b as Bridge
participant a as Approving Auth (admin)
participant g as Gateway / Registry
r->>r: Generate pairing_secret, pairing_code, response keypair
r->>r: Derive request_id and transport_secret, encrypt request
r->>b: POST /request
r->>u: Show QR code / deeplink
par Requesting Authenticator polls
loop until retrieved
r->>b: GET /response/:request_id
b-->>r: status
end
r->>u: Replace QR / deeplink with pairing_code
and Approving Authenticator fetches
u->>a: Scan QR / open deeplink
a->>a: Derive request_id
a->>b: GET /request/:request_id (single use)
b-->>a: Encrypted request
a->>u: Prompt for pairing_code
end
u->>a: Enter pairing_code
a->>a: Derive transport_secret, decrypt and validate request
a->>a: Check account state
u->>a: Approve registration
a->>g: Sign and submit InsertAuthenticator
g-->>a: Finalized
a->>a: Seal response to response_pubkey, encrypt with transport_secret
a->>b: PUT /response/:request_id
r->>b: GET /response/:request_id (single use)
r->>r: Decrypt and unseal response, verify on-chain, import vault
3.3 Pairing Secret, Pairing Code and Derived Values
For each registration attempt, the Requesting Authenticator MUST generate a fresh 32-byte pairing_secret, pairing_code, and response_pubkey keypair using a CSPRNG. The pairing_code MUST be sampled uniformly from six-letter uppercase ASCII strings (A–Z) and revealed only after the bridge status becomes retrieved.
HKDF denotes HKDF-SHA256 (RFC 5869) with an empty salt and 32-byte output. Argon2id (RFC 9106) uses version 0x13, memory m = 65536 KiB (64 MiB), iterations t = 3, parallelism p = 4, empty secret and associated data, and a 32-byte output:
request_id = HKDF(pairing_secret, info = "WORLD-ID/WIP-109/REQUESTID")
code_key = Argon2id(password = pairing_secret || ASCII(pairing_code), salt = request_id,
m = 65536, t = 3, p = 4, version = 0x13, output_length = 32)
transport_secret = HKDF(pairing_secret || code_key, info = "WORLD-ID/WIP-109/TRANSPORTSECRET")
Here request_id is 32 bytes (encoded as lowercase_hex(request_id) in bridge URLs and message id fields), and password is the 32-byte pairing_secret concatenated with the 6-byte ASCII pairing_code (38 bytes).
3.3.1 Registration Digest
The Requesting Authenticator commits to the registration parameters in the Pairing URI over a fixed-width binary layout:
registration_digest = SHA-256(
"WORLD-ID/WIP-109/REGISTER" // ASCII
|| request_id // 32 bytes
|| new_authenticator_pubkey // 32 bytes, WIP-100 §4 wire encoding
|| new_authenticator_address // 20 bytes; 20 zero bytes for a Proving Authenticator
|| response_pubkey // 1216 bytes, X-Wing encapsulation key
)
All fields except name (an unauthenticated display label) and registration_sig are covered.
3.3.2 Registration Signature
The Requesting Authenticator proves possession of new_authenticator_pubkey by hashing registration_digest into a field element and signing it with EdDSA (WIP-100 §3.5):
d_hi, d_lo = registration_digest[0..16], registration_digest[16..32] // big-endian integers
m = H_3(ds("WORLD-ID/WIP-109/REGSIG"); d_hi, d_lo) // WIP-100 §3.3.2
registration_sig = EdDSA-Sign(signing_key, m) // 64 bytes, WIP-100 §3.5.2
3.4 Pairing URI
The Requesting Authenticator encodes the pairing parameters in a WIP-105 deeplink:
worldid://auth/v1/register?s={pairing_secret}&d={registration_digest}&b={bridge_domain}
| Parameter | Required | Description |
|---|---|---|
s | REQUIRED | 32-byte pairing_secret in unpadded base64url (RFC 4648 §5), 43 characters. |
d | REQUIRED | 32-byte registration_digest (§3.3.1) in unpadded base64url, 43 characters. |
b | REQUIRED | Bare domain of the bridge (e.g. bridge.example.org), without scheme, port, path, userinfo, query, or fragment. Always contacted over https. |
The Requesting Authenticator MUST expose the URI as a deeplink and SHOULD render it as a QR code. The Approving Authenticator MUST reject unknown, missing, or duplicate parameters or invalid encodings, and MUST check b against an allowlist of known bridges (or require explicit user confirmation before contacting an unknown host). Opening the URI starts pairing and MUST NOT imply approval.
3.5 Transport
Requests and responses are exchanged through the message-bridge under lowercase_hex(request_id) (§3.3) and follow the bridge’s session lifetime.
- Outer encryption: Both directions use AES-256-GCM under
transport_secretwith a fresh random 12-byte nonce (iv, standard padded base64) and ciphertext with its 16-byte tag (payload, standard padded base64). A nonce MUST NOT be reused under the same key. - Request: Apply outer encryption to the CBOR-encoded request with AAD
ASCII("WORLD-ID/WIP-109/REQUEST") || request_id. - Response: First seal the CBOR-encoded response (success or error) to
response_pubkeyusing WIP-100 §3.9 withinfo = "WORLD-ID/WIP-109/RESPONSE". Then apply outer encryption to the sealed bytes with AADASCII("WORLD-ID/WIP-109/RESPONSE") || request_id.
The encoded CBOR plaintext MUST NOT exceed 4 KiB for a request and 3.5 MiB + 4 KiB for a response. Receivers MUST enforce corresponding size bounds before decryption and MUST authenticate the outer AES-GCM envelope before parsing a request or unsealing a response.
Note
transport_secretauthenticates both directions because unauthenticated HPKE base mode allows anyone holdingresponse_pubkeyto seal a message. The inner WIP-100 §3.9 layer provides post-quantum confidentiality for the vault even if the pairing secret and code are later compromised.
3.6 Messages
Messages use the WIP-105 CBOR envelope (version: "1.0") with id set to lowercase_hex(request_id). Examples below use abbreviated CBOR diagnostic notation (h'…').
3.6.1 worldid_auth_v1_register (Requesting → Approving)
| Member | Required | Description |
|---|---|---|
new_authenticator_pubkey | REQUIRED | Signing public key in WIP-100 §4 compressed wire encoding (32-byte CBOR byte string). |
new_authenticator_address | OPTIONAL | Management key address (20-byte non-zero CBOR byte string). Present for an Admin Authenticator; omitted for a Proving Authenticator. |
response_pubkey | REQUIRED | Per-attempt ephemeral X-Wing encapsulation key (1216-byte CBOR byte string). |
name | OPTIONAL | Self-reported label (CBOR text string, at most 64 UTF-8 bytes). Rejected with invalid_name if not accepted by the Approving Authenticator. |
registration_sig | REQUIRED | Registration Signature (§3.3.2, 64-byte CBOR byte string). |
{
"version": "1.0",
"id": "90f29fad9f9354009a277ce306f1867ff351d7d1844be4e41715ed58c0e676e3",
"method": "worldid_auth_v1_register",
"params": {
"new_authenticator_pubkey": h'119ab03f…',
"response_pubkey": h'420044e7…',
"name": "Chrome on MacBook",
"registration_sig": h'8e41c0…'
}
}
3.6.2 Success response (Approving → Requesting)
Sent only after the registration is final on-chain or confirmed already present (§3.7).
| Member | Required | Description |
|---|---|---|
leaf_index | REQUIRED | Account leaf index (1 .. 2^30 - 1; index 0 is reserved per WIP-100 §3.8, §5.1). |
pubkey_id | REQUIRED | Assigned slot index, an unsigned integer below getMaxAuthenticators(). |
authenticators | OPTIONAL | Known authenticators on the account: array of maps with pubkey_id (uint) and name (text string). |
vault | OPTIONAL | Map with format (format identifier text string, e.g. "walletkit_plaintext_v1") and data (CBOR byte string, at most 3.5 MiB / 3,670,016 bytes) containing the exported credential vault. Included only if the user opts in (§3.7) and the serialized vault fits within 3.5 MiB (otherwise omitted after informing the user). |
{
"version": "1.0",
"id": "90f29fad9f9354009a277ce306f1867ff351d7d1844be4e41715ed58c0e676e3",
"result": {
"leaf_index": 42,
"pubkey_id": 1,
"authenticators": [{ "pubkey_id": 0, "name": "iPhone" }],
"vault": { "format": "walletkit_plaintext_v1", "data": h'53514c69746520666f726d61742033…' }
}
}
3.6.3 Error response (Approving → Requesting)
code | Meaning |
|---|---|
invalid_params | Missing or malformed parameter, digest mismatch, or invalid key, address, or signature. |
invalid_name | The Approving Authenticator rejected name. |
internal_error | Unspecified internal error. |
user_rejected | The user declined approval. |
not_authorized | The Approving Authenticator is not an Admin Authenticator of the account. |
authenticator_conflict | The signing key or management address is already registered with a conflicting account, slot, or class. |
max_authenticators_reached | No free authenticator slot below getMaxAuthenticators(). |
operation_failed | The InsertAuthenticator operation definitively failed. error.data MAY be a map with a detail text string. |
outcome_unknown | The operation was submitted, but its outcome was not known in time to respond. |
{
"version": "1.0",
"id": "90f29fad9f9354009a277ce306f1867ff351d7d1844be4e41715ed58c0e676e3",
"error": { "code": "user_rejected", "message": "Registration declined" }
}
3.7 Approving Authenticator Procedure
- Fetch and decrypt:
- Parse the Pairing URI (§3.4), derive
request_id(§3.3), and fetchGET /request/:request_idbefore prompting forpairing_code(this single-use read sets the bridge status toretrieved). - Prompt the user for
pairing_code, derivecode_keyandtransport_secret(§3.3), and decrypt the request (§3.5). BecauseGET /request/:request_idis single-use, the Approving Authenticator MUST retain the ciphertext locally if it allows retrying a mistyped code, and SHOULD limit the number of attempts. - Validate the CBOR request (
id == lowercase_hex(request_id),method == "worldid_auth_v1_register", and §3.6.1 schema), verify that the recomputedregistration_digestmatchesdfrom the URI, and verifyregistration_sigagainstnew_authenticator_pubkey. On failure, send an error response (if a validresponse_pubkeyis available) or abort locally.
- Parse the Pairing URI (§3.4), derive
- Check account state:
- Refresh account state and verify that the Approving Authenticator is an Admin Authenticator of the account; otherwise respond
not_authorized. - Already registered: If
new_authenticator_pubkeyis already registered on the account, verify that the slot’s class and (for an Admin Authenticator) management address match the request; otherwise respondauthenticator_conflict. User approval (step 3) is still required, after which step 4 skipsInsertAuthenticatorand returns the existingleaf_indexandpubkey_id. - Otherwise, if
new_authenticator_addressis already registered, respondauthenticator_conflict. - Otherwise, select the lowest free
pubkey_idbelowgetMaxAuthenticators(); if none is free, respondmax_authenticators_reached.
- Refresh account state and verify that the Approving Authenticator is an Admin Authenticator of the account; otherwise respond
- Approve:
- Show an explicit consent screen displaying the requested class (Proving vs Admin), the untrusted
name(if present), a warning only to approve trusted authenticators, and a separate unselected opt-in to share the credential vault (noting if it contains biometric data and that without it the new authenticator cannot generate proofs for existing credentials). - The Approving Authenticator SHOULD require local user verification (e.g. device biometric or PIN) before signing
InsertAuthenticatoror exporting the vault. If the user declines, responduser_rejected.
- Show an explicit consent screen displaying the requested class (Proving vs Admin), the untrusted
- Submit and respond:
- Unless already registered, sign and submit
InsertAuthenticator(passing the zero address for a Proving Authenticator per WIP-104) and wait for finality. Do not submit another insertion while one is in flight. If the operation definitively fails, respondoperation_failed(orauthenticator_conflicton an address conflict); if the outcome is not known in time, respondoutcome_unknown. - Seal and encrypt the response (§3.5), and send it via
PUT /response/:request_id.
- Unless already registered, sign and submit
3.8 Requesting Authenticator Procedure
- Reuse the same authenticator key(s) and class across retries of the same registration so a retry after a lost response hits the already-registered case (§3.7 step 2) instead of inserting a second slot.
- Encrypt the request (§3.5) and publish it via
POST /requestbefore showing the Pairing URI. - Display the Pairing URI and poll
GET /response/:request_id:- While
initialized, keep the Pairing URI visible. - Once
retrieved, replace the Pairing URI withpairing_codeand instructions to enter it only on the intended Approving Authenticator. - Once
completed, proceed to step 4.
- While
- Authenticate and decrypt the outer response, unseal the inner HPKE payload (§3.5), and validate the CBOR response (
id == lowercase_hex(request_id)and §3.6 schema). - On
result, verify against on-chain/indexer state thatnew_authenticator_pubkeyis registered at(leaf_index, pubkey_id)with the requested class and management address before completing registration or importingvault, and validate imported credentials against the account before use. Onerror, surface the error (and offer a retry onoutcome_unknown).
Both authenticators MUST discard pairing_secret, pairing_code, derived keys, and the ephemeral response private key when the attempt ends, and MUST NOT log or send the Pairing URI or pairing_code to analytics or link-preview services.
4. Rationale
- Message-bridge transport: Reuses the existing off-chain relay without contract or service changes, keyed by a single
request_idderived frompairing_secretto keep the QR code compact. - Out-of-band pairing code with Argon2id: Entering
pairing_codeafter the request is fetched binds the session to the user’s intended device. Argon2id (salted withrequest_idand keyed withpairing_secret) raises the cost of offline guessing without allowing precomputation. - Dual-layer response encryption: The outer AES-GCM layer under
transport_secretauthenticates both success and error responses, while the inner WIP-100 §3.9 X-Wing seal provides forward and post-quantum confidentiality for the credential vault.
5. Security
- Untrusted bridge and URI interception: The bridge sees metadata (
request_id, sizes, timing, IPs) and can drop messages or spoofretrieved, and any QR scanner orworldid://handler can read the Pairing URI. Withoutpairing_code, an interceptor cannot decrypt the request or forge a valid response, though it can consume the single-use request or response slot and force a retry. - Offline code guessing: The 6-letter
pairing_codehas26^6(~28 bits of entropy) possibilities. Argon2id slows offline brute-force against captured ciphertexts, and the inner X-Wing seal keepsvaultconfidential even iftransport_secretis eventually recovered. - Phishing and malicious handlers: A user tricked into scanning an attacker’s URI and entering the attacker’s code will be prompted to register the attacker’s key, and a malicious app intercepting
worldid://could spoof the code-entry screen. Explicit consent and verifying the target app remain essential.
6. Backwards Compatibility
This WIP requires no changes to existing bridge endpoints or contracts. Proving Authenticator registration requires WorldIDRegistryV2 (WIP-104). Authenticators that do not implement this WIP are unaffected.