Skip to main content
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

// 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

input:  "" (empty)
output: 0x00c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a4

compute_rp_signature_message

compute_rp_signature_message(
  nonce      = 0x008ae1aa597fa146ebd3aa2ceddf360668dea5e526567e92b0321816a4e895bd,
  created_at = 1700000000,
  expires_at = 1700000300,
)

output:
01008ae1aa597fa146ebd3aa2ceddf360668dea5e526567e92b0321816a4e895bd000000006553f100000000006553f22c

sign_request

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