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.
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:
- Time delta: |ts_A − ts_B| ≤ 90 seconds
- Distance: haversine(GPS_A, GPS_B) ≤ 12 metres
- HELLO binding: B's
helloHashandofferHashmatch the original HELLO - Signature validity: both ECDSA signatures verify against their respective public keys
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
| Primitive | Choice | Rationale |
|---|---|---|
| Key algorithm | ECDSA P-256 | Native WebCrypto support across all modern browsers; hardware-backed on devices with a secure enclave |
| Hash | SHA-256 | Standard; used for both payload hashing and signature input |
| Key storage | localStorage (JWK) | Current approach — see limitations below |
| Encoding | base64url (RFC 4648 §5) | URL-safe, no padding issues in QR/hash fragments |
| Distance | Haversine formula | Sufficient precision at ≤12m scale |
4. Security Properties
What the protocol provides
- Co-presence claim (good faith): Both parties sign claims that they were at the same GPS location (±12m) within the same 90-second window. The cryptography verifies that both parties agreed to those claims — it does not independently verify that the location data is genuine. Physical co-presence is an assumption based on mutual good faith, not a cryptographic guarantee.
- Replay resistance: Each HELLO contains a fresh nonce and a timestamp. Responses bind to both the HELLO hash and the signed offer hash. A replayed HELLO will fail the timestamp check after 90 seconds.
- Cryptographic binding: B's response is bound to A's specific signed offer — not just any HELLO. A HELLO cannot be substituted mid-handshake without invalidating B's response.
- No biometric data: The receipt proves physical co-presence without capturing any biometric. However it does contain GPS coordinates from both devices — this is the mechanism, not incidental. The verifier learns that two keypairs were at the same place at the same time, plus those coordinates. This is less invasive than biometric capture but is not zero-knowledge.
- No backend: The entire protocol runs in the browser. Verification is fully client-side.
- Public verifiability: Any party with the combined receipt URL can verify all signatures without trusting a third party.
Known limitations and attack surface
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:
- Is there a practical way to attest GPS integrity without proprietary hardware attestation?
- Should the private key be made non-extractable (accepting no backup) to improve key confinement?
- What does a reasonable revocation mechanism look like for a serverless protocol?
- How should a relying party weight a receipt vs. a chain of receipts when evaluating a trust claim?
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.
Feedback and critique welcome via the contact page.