Getting Started
How it works
Getting started

How it works

The concepts behind LKP, and exactly who learns what at each step.

Credentials#

A credential is a signed statement about a user, such as “born on 15 June 1995, Dutch nationality”. Users get credentials by verifying once with a provider:

  • Government ID and Age Verification: users can scan a passport or ID card, or complete a fast 1 to 2 second passive selfie age check with Didit. Didit processes the capture under its own policy. Our signing enclave fetches the decision server-to-server, signs the credential facts (enforcing a strict 3-year statistical buffer on age estimations) and requests deletion of the session at Didit with privacy erasure. That is verified identity, not current physical location.
  • Phone and work or school email: a one-time code through Didit. The number or address, its country or organisation, and the line or address type are signed.
  • Instagram, X, TikTok, GitHub: Instagram uses a TLSNotary zkTLS proof of a bio code, so the login never leaves the browser; the others use the provider's OAuth sign-in. Usernames, audience, activity and account age are signed.
  • Bank account: open banking through Enable Banking. The IBAN, holder name and balance are signed; transactions are never read.

Credentials are signed with EdDSA over the BabyJubJub curve by an isolated signing service, and bound to the user's secret identity key. A credential stolen from one wallet can't be used from another.

The wallet#

Credentials live in the user's LKP wallet: a JSON document encrypted with a random AES-256-GCM key in the browser. That key is wrapped by a passkey (WebAuthn PRF) and by a 24-word recovery kit. Our servers store only the ciphertext, so we can't read a wallet even if asked to.

Proofs#

When your app asks for “18 or older”, the user's browser runs a zero-knowledge circuit that checks, privately, that the credential:

  • is signed by the LKP issuer,
  • belongs to the user's identity key,
  • has not expired or been revoked, and
  • satisfies your requirement (for example birth date ≤ today minus 18 years).

The output is a Groth16 proof of a few hundred bytes. LKP verifies it against public inputs it rebuilds itself from your session, so a user can't weaken the requirement, and a proof made for one session can't be replayed in another. The proof inherits the issuer's check (Didit for government ID); it does not independently re-examine the original document or, by itself, satisfy a particular regulation.

Anonymous user IDs (nullifiers)#

Every successful verification includes a nullifier: a hash of the wallet's identity key and your app ID. It is not a biometric. It is:

  • stable: the same wallet always produces the same nullifier in your app, even after switching devices or restoring from the recovery kit;
  • unlinkable: a different app gets a completely different value, so nullifiers can't be joined across services;
  • per wallet, not per human worldwide: a second wallet (for example one backed by a different passport) produces a different nullifier. Nullifiers do not uniquely identify a person across every legal identity they might hold.

One credential, one wallet#

Separately, a keyed tag of each verified document, phone number, email address or account binds that credential to one wallet. The same passport number cannot back two wallets. That is one-credential-one-wallet enforcement, not universal biometric deduplication: two different documents can still enrol two wallets.

Modes

  • ANONYMOUS_LOGIN (default): the same wallet can verify as often as you like and you get the same nullifier each time. Use it for sign-in and age gates.
  • ONE_TIME_ACTION: a nullifier can complete an actionId only once. A second attempt by the same wallet fails. Use it for votes, giveaways and referral rewards.

Verification sessions#

A session ties it together: your server creates it with a secret key, the user completes it on the hosted page, and your server reads the result. Sessions expire after 30 minutes. A failed proof can be retried within that window; a reused one-time nullifier ends the session as failed.

Your serverPOST /v1/verification-sessionsLKP API
User's browseropens session URL, unlocks walletLKP hosted page
User's browserproves requirements locally (Groth16)On the device
User's browsersubmits proof + nullifier onlyLKP API
LKP APIverifies proof, marks session verifiedwebhook → your server
Your serverGET /v1/verification-sessions/:idLKP API

Revocation#

If a credential turns out to be fraudulent, our operators can revoke it. Revocation is a public Merkle tree of one bit per credential. The user's browser downloads the whole tree (it's small) and proves non-revocation without revealing which credential it holds.

Who learns what
Your server: the requirements are met, plus a nullifier. LKP's API: the same, plus timing. The signing enclave: the user's ID data once, at issuance, then forgets it. Didit: the document, selfie and its telemetry, under Didit's policy, until we delete the session. Nobody at LKP: names, birth dates, documents, numbers, addresses, usernames, counts and balances after issuance.