Proof-of-Personhood Without Biometrics: The IRLid Protocol

With Reddit rolling out mandatory human verification and World ID expanding its iris-scanning Orb into Discord, Shopify, and beyond, proof-of-personhood is suddenly a mainstream problem. Most proposed solutions fall into two camps: behavioural analysis (invisible, low-trust) or biometrics (high-trust, high-privacy cost). This writeup describes a third approach — anchoring proof of personhood to a physical real-world interaction between two devices, with no biometric capture and no centralised identity database. It is not a zero-knowledge protocol: GPS coordinates are part of the receipt by design. The trade-off and its implications are discussed honestly below.

This is a technical writeup intended for developers and security researchers. It describes the protocol honestly, including its known limitations and attack surface. Critique is welcome.

1. The Problem

The core question is: how do you establish that an account is controlled by a unique human, without learning anything else about that human?

Behavioural analysis (reCAPTCHA, Cloudflare Turnstile, Arkose) answers a weaker question: "does this session look like a human?" It fails against sophisticated bots, leaks behavioural telemetry, and produces a probability score rather than a guarantee. Biometric approaches (World ID's iris scan, Face ID) answer a stronger question but require a trusted hardware device, a central database of biometric proofs, and an implicit trust relationship with whoever operates that database.

The specific threat model IRLid addresses: can two parties produce a jointly-signed, time-stamped, location-bound proof that they were physically co-present, using only their existing devices and a QR code exchange? No proprietary hardware. No biometric capture. No always-online server.

2. Protocol Overview

IRLid is a two-scan handshake between two devices. Each device generates a local ECDSA P-256 keypair (via the Web Crypto API) on first use. The private key never leaves the device. The handshake proceeds as follows:

Step 1 — HELLO (Offer)

Person A opens IRLid. The application acquires A's GPS position and generates a signed HELLO object:

{
  "v": 2,
  "type": "hello",
  "pub": { /* A's ECDSA P-256 public key as JWK */ },
  "nonce": 3847261049,
  "ts": 1743750000,
  "offer": {
    "payload": {
      "v": 1,
      "type": "offerPayload",
      "lat": 51.509865,
      "lon": -0.118092,
      "acc": 8.3,
      "ts": 1743750000,
      "nonce": 3847261049
    },
    "hash": "base64url(SHA-256(canonical(offerPayload)))",
    "sig":  "base64url(ECDSA_P256_sign(hash, A_privKey))"
  }
}

This object is base64url-encoded and embedded in a URL, which is rendered as a QR code for B to scan.

Step 2 — ACCEPT (Response)

B scans A's QR. B's device first verifies the HELLO: it checks the offer hash, verifies A's signature, and confirms the timestamp is within the 90-second tolerance window. If the HELLO is valid, B's device acquires its own GPS position and constructs a signed response that binds to both the HELLO hash and the signed offer hash:

{
  "v": 2,
  "type": "response",
  "payload": {
    "v": 1,
    "type": "payload",
    "helloHash":  "base64url(SHA-256(HELLO_bytes))",
    "offerHash":  "base64url(SHA-256(offerPayload_bytes))",
    "lat": 51.509871,
    "lon": -0.118085,
    "acc": 6.1,
    "ts": 1743750042,
    "nonce": 2918476305
  },
  "hash": "base64url(SHA-256(payload_bytes))",
  "sig":  "base64url(ECDSA_P256_sign(hash, B_privKey))",
  "pub":  { /* B's ECDSA P-256 public key as JWK */ }
}

B's response is shown as a QR for A to scan.

Step 3 — Combined Receipt

A scans B's response QR. At this point A's device does two things simultaneously: it creates its own signed response (containing A's GPS coordinates, timestamp, and binding to the original HELLO — mirroring exactly what B did in Step 2), and it verifies B's signature and payload. Both signed responses are then checked against mutual tolerances:

If all checks pass, a combined receipt is produced:

{
  "v": 2,
  "type": "combined",
  "tol": { "dist_m": 12, "ts_s": 90 },
  "hello": { /* original signed HELLO from A */ },
  "a": { /* A's signed response */ },
  "b": { /* B's signed response */ }
}

This combined receipt is itself base64url-encoded and embedded in a URL, rendered as a QR. Anyone with a QR scanner can open the receipt and verify all signatures client-side — no server call required.

3. Cryptographic Primitives

PrimitiveChoiceRationale
Key algorithmECDSA P-256Native WebCrypto support across all modern browsers; hardware-backed on devices with a secure enclave
HashSHA-256Standard; used for both payload hashing and signature input
Key storagelocalStorage (JWK)Current approach — see limitations below
Encodingbase64url (RFC 4648 §5)URL-safe, no padding issues in QR/hash fragments
DistanceHaversine formulaSufficient precision at ≤12m scale

4. Security Properties

What the protocol provides

Known limitations and attack surface

The following are known limitations. This section is here because honest security writeups include them.

GPS and location integrity. The location data in both the HELLO and response comes from navigator.geolocation. This data is not verified by the protocol — there is no mechanism to confirm that the coordinates reflect the device's actual physical location. On desktop, navigator.geolocation typically uses IP geolocation, which is coarse and easily misrepresented. On mobile it uses GNSS/WiFi, but mock location apps are widely available and require no technical skill. More fundamentally, a receipt can be constructed with arbitrary GPS coordinates without any spoofing at all — the protocol cannot enforce that navigator.geolocation was called, or that its output was used unmodified. Physical co-presence is therefore an assumption based on mutual good faith, not a cryptographic guarantee. Hardware attestation (SafetyNet / DeviceCheck) could provide a future layer of location integrity on mobile, but is not currently present.

Key storage. Private keys are stored in localStorage as JWK. This means they are accessible to any JavaScript running on the same origin, and are not protected by a secure enclave at rest. A device compromise means key compromise. Using the WebCrypto extractable: false flag for the private key material (storing only the public JWK, and regenerating from an imported private key) would improve this — but the non-extractable key could not then be backed up. The current tradeoff is usability over key confinement.

Social collusion. Two colluding parties could manufacture a receipt by simply meeting and scanning. The protocol makes no attempt to prevent this — its goal is to prove physical co-presence, not to assert that the meeting was meaningful or authorised. How a relying party uses receipts (e.g. requiring receipts from multiple independent parties, or from parties with established trust graphs) is an application-layer concern.

Sybil resistance is partial. A single person with two phones can produce a receipt. The protocol reduces bot-scale Sybil attacks (you cannot script thousands of receipts without physically moving hardware) but does not prevent a human with multiple devices.

No identity binding. The keypairs are device-local and ephemeral. A receipt proves that two keypairs were co-present — it does not prove that a specific human controls those keypairs, unless that binding is established by a higher-level system.

No key revocation. There is currently no mechanism to revoke a key or invalidate receipts issued by a compromised keypair.

Hash inclusion risk (addressed in v3). The protocol includes the hash in the message alongside the payload — a design pattern that creates an implementation trap, as a careless implementer could trust the provided hash rather than recomputing it. The current IRLid implementation handles this correctly: it always recomputes the hash from the payload and checks it matches before using it, so the check cannot be bypassed. In v3, a.hash and b.hash are no longer transmitted in compact receipts — the verifier always recomputes them from the payload, removing the ambiguity entirely. It remains a concern for any third-party implementation using the v2 format.

JSON malleability (addressed in v3). JSON permits whitespace variation that produces different byte sequences for semantically identical structures. Hashing raw JSON therefore has edge cases. v3 addresses this by replacing JSON.stringify() with a canonical() function that recursively sorts all object keys at every level of nesting before serialisation. This makes all hashes fully order-independent regardless of how objects were constructed or which environment produced them. The canonical() function is now the sole hashing path for all v3 payloads.

Fields outside the signature. The version number, message type, and public key sit outside the signed payload. In practice the public key cannot be swapped without breaking signature verification, and message type is explicitly validated before any cryptographic check. Only the version field is genuinely unchecked, leaving a theoretical downgrade attack vector. A future protocol revision should cover all envelope fields within the signed payload.

Previously addressed: nonce/timestamp duplication. An earlier version of the protocol included the nonce and timestamp redundantly at both the top level of the HELLO and inside the signed offer payload. This was removed in Deploy 76 — they now appear only inside the signed payload where they are covered by the signature.

Previously addressed: JSON malleability and hash transmission. v2 used JSON.stringify() for payload hashing (order-dependent) and included computed hashes in transmitted messages. Both were addressed in v3: canonical() now handles all hashing, and compact receipts no longer transmit a.hash or b.hash — the verifier recomputes them. Credit to community feedback from r/netsec for pushing these forward.

5. Comparison to Alternatives

Approach Biometric data Backend required Hardware required Bot resistance
reCAPTCHA / Turnstile No Yes No Probabilistic
World ID (iris scan) Yes (iris code) Yes Yes (Orb) Strong
Passkeys (FIDO2) Optionally (Face ID / fingerprint) Partial No Moderate (device-bound)
Government ID Yes Yes No Strong (but invasive)
IRLid No No No Moderate (physical presence required)

IRLid occupies the gap between "no friction, low trust" (CAPTCHA) and "high friction, high privacy cost" (iris scan / government ID). It is not a drop-in replacement for either — its value is in contexts where physical co-presence is meaningful: in-person event check-ins, peer-to-peer trust bootstrapping, local community verification, and hybrid systems where receipts accumulate into a trust graph over time.

6. Open Questions

A few areas where input from the security community would be genuinely useful:

7. Try It

The application runs entirely in your browser. Open IRLid on one device, scan with a second device, and the receipt is produced locally. No account required. The source is visible in the browser.

Open IRLid →

Feedback and critique welcome via the contact page.