Relying Party (RP) signatures prove that a proof request genuinely comes from your app, preventing impersonation attacks.
Your backend signs every request with the signing_key from the Developer Portal ,
and World App verifies the signature before generating a proof. RP signatures are enforced for World ID 4.0 requests .
Never expose your signing key to client-side code. If the key leaks, rotate it immediately in the Developer Portal.
Algorithm
Implement it yourself
JavaScript / TypeScript
Go
// IMPORTANT: Use Keccak-256, NOT SHA3-256. They have different padding.
// Most Ethereum libraries (ethers, viem, web3) use Keccak-256.
function hash_to_field(input_bytes) -> bytes32:
h = keccak256(input_bytes) // 32 bytes
n = big_endian_uint256(h) >> 8 // shift right 8 bits
return uint256_to_32bytes_be(n) // always starts with 0x00
function compute_rp_signature_message(nonce_bytes32, created_at_u64, expires_at_u64, action?) -> bytes:
size = 81 if action else 49
msg = new bytes(size)
msg[0] = 0x01 // version byte
msg[1..32] = nonce_bytes32 // 32-byte field element
msg[33..40] = u64_to_be(created_at) // big-endian uint64
msg[41..48] = u64_to_be(expires_at) // big-endian uint64
if action is not null:
msg[49..80] = hash_to_field(utf8_encode(action))
return msg
function sign_request(signing_key_hex, action?, ttl_seconds = 300):
// Accept signing keys with or without 0x prefix
key = parse_hex_32_bytes(signing_key_hex)
// 1. Generate nonce
random = crypto_random_bytes(32)
nonce_bytes = hash_to_field(random)
// 2. Timestamps
created_at = unix_time_seconds()
expires_at = created_at + ttl_seconds
// 3. Build message
msg = compute_rp_signature_message(nonce_bytes, created_at, expires_at, action)
// 4. EIP-191 prefix and hash
// The prefix uses the DECIMAL byte length of the message (e.g. "49" or "81")
prefix = "\x19Ethereum Signed Message:\n" + decimal_string(length(msg))
digest = keccak256(prefix + msg)
// 5. Sign with recoverable ECDSA (secp256k1)
(r, s, recovery_id) = ecdsa_secp256k1_sign(digest, key)
// 6. Encode: r(32) || s(32) || v(1), where v = recovery_id + 27
sig65 = r + s + byte(recovery_id + 27)
return {
sig: "0x" + hex(sig65),
nonce: "0x" + hex(nonce_bytes),
created_at: created_at,
expires_at: expires_at,
}
Test vectors
Use these to verify your implementation. All vectors use deterministic inputs.
hash_to_field
empty string
"test_signal"
raw bytes
"hello"
input: "" (empty)
output: 0x00c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a4
compute_rp_signature_message
without action (49 bytes)
with action "test-action" (81 bytes)
compute_rp_signature_message(
nonce = 0x008ae1aa597fa146ebd3aa2ceddf360668dea5e526567e92b0321816a4e895bd,
created_at = 1700000000,
expires_at = 1700000300,
)
output:
01008ae1aa597fa146ebd3aa2ceddf360668dea5e526567e92b0321816a4e895bd000000006553f100000000006553f22c
sign_request
without action (session proof)
with action "test-action" (uniqueness proof)
sign_request(
signing_key = 0xabababababababababababababababababababababababababababababababab,
random = [0x00, 0x01, ..., 0x1f], // deterministic for testing
created_at = 1700000000, // fixed clock for testing
ttl = 300,
)
nonce: 0x008ae1aa597fa146ebd3aa2ceddf360668dea5e526567e92b0321816a4e895bd
msg length: 49 bytes
sig: 0x14f693175773aed912852a601e9c0fd30f2afe2738d31388316232ce6f64ae9e4edbfb19d81c4229ba9c9fca78ede4b28956b7ba4415f08d957cbc1b3bdaa4021b
Related pages