Interactive Tutorial

Walk through each step of ZK Login. Expand the “Learn” sections to understand the cryptography.

1

Identity Provider

OFF-CHAIN
Learn: How identity providers work

Google: Returns a signed JWT (id_token) containing your email, user ID, and verification status. The JWT is validated by the server using Google’s public keys. RSA JWT verification inside a ZK circuit would require ~200K constraints — so we verify it off-chain (same tradeoff as Sui zkLogin).

Passkey: Creates a WebAuthn credential bound to this origin. The credential ID becomes your identity anchor — no email, no OAuth, no PII. The tradeoff: a lost credential = a new identity (unlike Google, which can be recovered from any device).

Identity hash: SHA-256("{provider}:{email}:{userId}:verified:{emailVerified}") truncated to 31 bytes (248 bits) to fit the BN254 scalar field. The circuit is identity-agnostic — it only sees a field element.

1b

Identity Hash

OFF-CHAIN
Learn: SHA-256 to BN254 field element

Formula: SHA-256("gmail:{email}:{sub}:verified:true") produces a 32-byte hash. We take the first 31 bytes (248 bits) and interpret them as a big-endian integer. This guarantees the value is less than the BN254 field prime (~2254), avoiding silent modular reduction.

This hash is deterministic — the same Google account always produces the same identity hash. But it’s one-way — you can’t recover the email from the hash.

2

Server Attestation

OFF-CHAIN
Learn: Poseidon hashing and the trust model

The server generates a Poseidon hash binding your identity to a timestamp and random nonce: Poseidon(identityHash, timestamp, nonce).

Why Poseidon? SHA-256 costs ~25,000 constraints inside a ZK circuit. Poseidon costs ~250 — a 100x reduction. It’s an algebraic hash designed specifically for finite field arithmetic.

Trust model: The server attests “I verified this identity at this time.” The attestation includes a serverPubCommitment = Poseidon(serverSecret, 1), which is verified on-chain. A different server = different commitment = proof fails.

Read the full explanation →

3

ZK Proof Generation

CLIENT
Learn: What Groth16 proves and what stays private

Your browser generates a Groth16 proof using the identity attestation circuit (2,295 constraints on BN254). The proof is 3 elliptic curve points (A, B, C) = 256 bytes.

Private inputs (never leave your device): identityHash, blinding, nullifierSecret, attestation data, serverPubCommitment.

Public outputs (go on-chain):
commitment = Poseidon(identity, blinding) — hides your identity
nullifierHash = Poseidon(identity, nullifierSecret) — prevents replay

The circuit is identity-agnostic — same WASM, zkey, and verification key for both Google and passkey providers.

See the full circuit code →

4

Verification

ON-CHAIN
Learn: BN254 pairing checks, off-chain vs on-chain

Off-chain: snarkjs.groth16.verify() runs the BN254 pairing check entirely in your browser. This is real cryptographic verification — the same math the contract uses.

On-chain: The proof is encoded to Soroban bytes and submitted as a transaction. The identity-auth contract calls groth16-verifier for the BN254 pairing check, then stores the nullifier to prevent replay.

The verification equation: e(A, B) = e(α, β) · e(Σ vk_k · x_k, γ) · e(C, δ)

See the contract code →

Off-Chain (browser)
On-Chain (Soroban testnet)