Interactive Tutorial
Walk through each step of ZK Login. Expand the “Learn” sections to understand the cryptography.
Identity Provider
OFF-CHAINGoogle: 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.
Identity Hash
OFF-CHAIN
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.
Server Attestation
OFF-CHAIN
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.
ZK Proof Generation
CLIENTYour 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.
Verification
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, δ)