How ZK Login Works
A developer’s guide to zero-knowledge social authentication on Stellar/Soroban.
1. What is ZK Login?
The Problem
Blockchain accounts are controlled by cryptographic keys. Users must manage seed phrases — 12 or 24 random words that, if lost, mean permanent loss of funds. This is the single biggest UX barrier to blockchain adoption.
The Solution
ZK Login lets users authenticate with a familiar identity provider (Google, Apple, passkeys) and generate a zero-knowledge proof that can be verified on-chain. The blockchain never sees the user’s identity — only a cryptographic commitment that proves “someone who controls this Google account authorized this action.”
The Flow
This is the same pattern as Sui zkLogin and Aptos Keyless, adapted for Stellar/Soroban. The key difference: our circuit is tiny (2,295 constraints vs ~100M) because we use an off-chain attestation model instead of verifying JWTs inside the circuit.
2. Identity Hashing
The first step converts a social identity into a BN254 field element — a number small enough to use inside a ZK circuit. The formula is:
Why 31 bytes?
ZK circuits operate over finite fields. The BN254 curve’s scalar field prime is:
This is approximately 2254. A full 32-byte SHA-256 hash can exceed this prime, which would cause silent modular reduction (the hash wraps around, creating collisions). Truncating to 31 bytes (248 bits) guarantees the value fits safely within the field.
Provider Domain Separation
Each identity provider uses a unique prefix to prevent cross-provider identity collisions:
- gmail:user@gmail.com:107542...:verified:true — Google accounts
- passkey:{credentialId} — WebAuthn passkeys
- apple:user@icloud.com:001234...:verified:true — Apple (future)
Implementation
export async function computeIdentityHash(input: IdentityInput): Promise<string> { const prefix = PROVIDER_PREFIX[input.provider.toLowerCase()]; const identityString = `${prefix}:${input.email}:${input.userId}:verified:${input.emailVerified}`; const data = new TextEncoder().encode(identityString); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashArray = new Uint8Array(hashBuffer); return Array.from(hashArray) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } // Truncate to 31 bytes (248 bits) for BN254 field safety export function hexToFieldElement(hex: string): bigint { return BigInt('0x' + hex.slice(0, 62)); // 62 hex chars = 31 bytes }
3. Poseidon & Server Attestation
Why Poseidon?
Inside a ZK circuit, every operation becomes constraints. Standard hash functions are expensive:
| Hash Function | Constraints | Use Case |
|---|---|---|
SHA-256 | ~25,000 | General purpose |
Keccak-256 | ~150,000 | Ethereum |
Poseidon | ~250 | ZK circuits (100x cheaper) |
Poseidon is an algebraic hash function designed specifically for ZK circuits. It operates natively over the BN254 field, making it orders of magnitude cheaper than bit-oriented hash functions like SHA-256.
The Trust Model
The server validates the user’s OAuth token (or passkey credential) and creates an attestation — a Poseidon hash that binds the identity to a timestamp:
This is the trust dependency. The server attests: “I verified this identity at this time.” This is the same model as Sui’s salt service and Aptos’s pepper service.
The server also publishes a public commitment so the circuit can verify attestations came from an authorized server:
Implementation
import { poseidon2, poseidon3 } from 'poseidon-lite'; // Attestation: binds identity + timestamp + nonce export function computeAttestationHash( identityHash: bigint, timestamp: bigint, nonce: bigint, ): bigint { return poseidon3([identityHash, timestamp, nonce]); } // Server commitment: proves server knowledge without revealing secret export function computeServerPubCommitment( serverSecretField: bigint, ): bigint { return poseidon2([serverSecretField, 1n]); }
serverPubCommitment, which is verified on-chain.
The contract is initialized with a specific commitment. Wrong server = proof fails.
4. The ZK Circuit
The circuit is written in Circom and compiled to a Groth16 proving system on the BN254 curve. It has 2,295 constraints.
Inputs & Outputs
| Signal | Visibility | Description |
|---|---|---|
identityHash | Private | SHA-256 hash of social identity (field element) |
attestationTimestamp | Private | When server created the attestation |
serverNonce | Private | Random nonce from server |
attestationHash | Private | Poseidon(identity, timestamp, nonce) |
blinding | Private | Random factor hiding identity in commitment |
nullifierSecret | Private | Secret seed for replay prevention |
currentTimestamp | Public | Current time (for freshness check) |
maxAttestationAge | Public | Max allowed attestation age (seconds) |
serverPubCommitment | Public | Authorized server’s public commitment |
commitment | Output | Poseidon(identityHash, blinding) |
nullifierHash | Output | Poseidon(identityHash, nullifierSecret) |
Circuit Logic (5 Steps)
Step 1: Verify attestation — Recompute Poseidon(identity, timestamp, nonce) and constrain it equals the provided attestation hash. This proves the server actually attested to this identity.
Step 2: Server binding — Compute Poseidon(attestationHash, serverPubCommitment). This constraint ensures the attestation is bound to the authorized server.
Step 3: Freshness check — Verify currentTimestamp - attestationTimestamp ≤ maxAttestationAge and that the attestation isn’t from the future. Prevents stale attestations from being reused.
Step 4: Commitment — Compute commitment = Poseidon(identityHash, blinding). The blinding factor hides the identity hash. Published on-chain as a public output.
Step 5: Nullifier — Compute nullifierHash = Poseidon(identityHash, nullifierSecret). The contract stores used nullifiers to prevent replay attacks. Same identity + same secret = same nullifier (deterministic).
Full Circuit Code
pragma circom 2.1.0; include "circomlib/circuits/poseidon.circom"; include "circomlib/circuits/comparators.circom"; template IdentityAttestation() { // Private inputs (witness) signal input identityHash; signal input attestationTimestamp; signal input serverNonce; signal input attestationHash; signal input blinding; signal input nullifierSecret; // Public inputs signal input currentTimestamp; signal input maxAttestationAge; signal input serverPubCommitment; // Public outputs signal output commitment; signal output nullifierHash; // Step 1: Verify attestation hash component attestationVerify = Poseidon(3); attestationVerify.inputs[0] <== identityHash; attestationVerify.inputs[1] <== attestationTimestamp; attestationVerify.inputs[2] <== serverNonce; attestationVerify.out === attestationHash; // Step 2: Server binding component serverBinding = Poseidon(2); serverBinding.inputs[0] <== attestationHash; serverBinding.inputs[1] <== serverPubCommitment; signal serverBindingCheck; serverBindingCheck <== serverBinding.out; // Step 3: Freshness check signal timeDiff; timeDiff <== currentTimestamp - attestationTimestamp; component ageCheck = LessEqThan(64); ageCheck.in[0] <== timeDiff; ageCheck.in[1] <== maxAttestationAge; ageCheck.out === 1; component notFuture = LessEqThan(64); notFuture.in[0] <== attestationTimestamp; notFuture.in[1] <== currentTimestamp; notFuture.out === 1; // Step 4: Commitment (hides identity) component commitmentHash = Poseidon(2); commitmentHash.inputs[0] <== identityHash; commitmentHash.inputs[1] <== blinding; commitment <== commitmentHash.out; // Step 5: Nullifier (prevents replay) component nullifierCompute = Poseidon(2); nullifierCompute.inputs[0] <== identityHash; nullifierCompute.inputs[1] <== nullifierSecret; nullifierHash <== nullifierCompute.out; } component main {public [ currentTimestamp, maxAttestationAge, serverPubCommitment ]} = IdentityAttestation();
5. Groth16 Proof System
Groth16 is a non-interactive zero-knowledge proof system. Given a circuit (set of constraints), it produces a proof that the prover knows a valid witness (private inputs) satisfying all constraints, without revealing the witness.
Key Properties
- Constant-size proof: Always 3 elliptic curve points (A, B, C) = 256 bytes, regardless of circuit size
- Fast verification: One pairing check (~2ms in browser, ~10ms on Soroban)
- Trusted setup: Requires a one-time ceremony to generate proving + verification keys
- Curve: BN254 (also called BN128) — same curve as Ethereum’s
ecPairingprecompile
The Verification Equation
A Groth16 verifier checks this bilinear pairing equation:
Where e is the bilinear pairing on BN254, A, B, C are the proof points,
α, β, γ, δ are from the verification key, and xk
are the public inputs. If the equation holds, the proof is valid.
Proof Encoding for Soroban
snarkjs outputs proof coordinates as decimal strings. These must be converted to big-endian bytes for the Soroban contract:
// G1 point: 64 bytes (X:32 || Y:32) export function buildG1Point(point: string[]): Uint8Array { const x = decimalToBytes(point[0], 32); const y = decimalToBytes(point[1], 32); const result = new Uint8Array(64); result.set(x, 0); result.set(y, 32); return result; } // G2 point: 128 bytes (X_c1:32 || X_c0:32 || Y_c1:32 || Y_c0:32) // Note: c1-before-c0 ordering for Soroban BN254 compatibility export function buildG2Point(point: string[][]): Uint8Array { const x_c1 = decimalToBytes(point[0][1], 32); const x_c0 = decimalToBytes(point[0][0], 32); const y_c1 = decimalToBytes(point[1][1], 32); const y_c0 = decimalToBytes(point[1][0], 32); const result = new Uint8Array(128); result.set(x_c1, 0); result.set(x_c0, 32); result.set(y_c1, 64); result.set(y_c0, 96); return result; }
6. On-Chain Verification
Contract Architecture
The identity-auth contract receives the proof and public inputs, delegates
the actual Groth16 verification to groth16-verifier (which performs the real
BN254 bilinear pairing check), then stores the nullifier to prevent replay.
The authorize() Function
pub fn authorize( env: Env, proof_a: BytesN<64>, // G1 point proof_b: BytesN<128>, // G2 point proof_c: BytesN<64>, // G1 point request: AuthorizationRequest, ) -> Result<AuthorizationResult, IdentityAuthError> { // 1. Check nullifier hasn't been used (replay prevention) if Self::is_nullifier_used(env.clone(), request.nullifier_hash.clone()) { return Err(IdentityAuthError::NullifierAlreadyUsed); } // 2. Build public inputs for Groth16 verification let public_inputs = Self::build_public_inputs(env.clone(), &request)?; // 3. Cross-contract call to groth16-verifier let verifier_id: Address = env.storage().persistent() .get(&DataKey::VerifierId).unwrap(); let is_valid: bool = env.invoke_contract( &verifier_id, &Symbol::new(&env, "verify"), args, ); if !is_valid { return Err(IdentityAuthError::InvalidProof); } // 4. Mark nullifier as used (AFTER verification succeeds) env.storage().persistent() .set(&DataKey::Nullifier(request.nullifier_hash.clone()), &true); // 5. Emit authorization event env.events().publish( (Symbol::new(&env, "authorized"),), (request.commitment.clone(), request.nullifier_hash), ); Ok(AuthorizationResult { success: true, commitment: request.commitment, merkle_index: count as u32, }) }
What Goes On-Chain
| Data | On-Chain? | Purpose |
|---|---|---|
| Email / identity | Never | — |
| Identity hash | Never | Hidden inside proof |
| Commitment | Yes | Proves identity ownership (blinded) |
| Nullifier hash | Yes | Prevents replay attacks |
| Proof (A, B, C) | Yes (TX data) | Verified then discarded |
| Verification result | Yes (event) | Authorization event emitted |
7. Integration Guide
The ZK Login contracts are already deployed on Stellar testnet. You can integrate ZK social authentication into your own dApp without deploying anything — just call the existing contracts.
Deployed Contract IDs (Testnet)
| Contract | ID | Role |
|---|---|---|
identity-auth |
CABLST6SHB7F3LNQBFF3BSVNDAPCOMBU2PA5WDKQH4VSOTDA2VWWUVZB |
Orchestrator — submit proofs here |
groth16-verifier |
CCCVOVIW5VS4MBYPB77IX2H4IXVOFJCMBD2APMVWDJDYZFX6DYEB3WZ4 |
BN254 pairing check |
merkle-tree |
CBPU2ABXSPEJ5T4KB2ON2KJ24L6BHWF24Z5NELIAJ42ULC5VILMSZOHQ |
Commitment storage |
Step 1: Get an Attestation
Your app authenticates the user (Google OAuth or WebAuthn passkey), then sends the identity token to the attestation server. The server verifies the token and returns a Poseidon attestation.
// 1. User signs in with Google (you handle OAuth) const idToken = "eyJhbGciOiJSUzI1NiIs..."; // from Google // 2. Send to attestation server const resp = await fetch('https://stellar-zklogin-demo.pages.dev/api/attestation/google', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ idToken }), }); const attestation = await resp.json(); // attestation contains: // { // identityHash: "0x1a2b...", // SHA-256 field element // attestationHash: "0x3c4d...", // Poseidon(identity, timestamp, nonce) // attestationTimestamp: 1709510400, // serverNonce: "0x5e6f...", // serverPubCommitment: "0x1605...", // }
Step 2: Generate a ZK Proof (Browser)
Load the circuit WASM and proving key in the browser, then generate a Groth16 proof. All private inputs stay on the client — only the proof and public signals leave.
// Load snarkjs (UMD) in your HTML: <script src="snarkjs.min.js"></script> // Or import from npm: import * as snarkjs from 'snarkjs'; // Circuit artifacts (download from this demo or build your own) const wasmUrl = 'https://stellar-zklogin-demo.pages.dev/circuits/gmail_attestation.wasm'; const zkeyUrl = 'https://stellar-zklogin-demo.pages.dev/circuits/gmail_attestation.zkey'; // User-generated secrets (KEEP THESE — needed to prove identity later) const blinding = BigInt('0x' + randomHex(31)); // hides identity in commitment const nullifierSecret = BigInt('0x' + randomHex(31)); // replay prevention seed const circuitInputs = { identityHash: attestation.identityHash, attestationTimestamp: attestation.attestationTimestamp.toString(), serverNonce: attestation.serverNonce, attestationHash: attestation.attestationHash, blinding: blinding.toString(), nullifierSecret: nullifierSecret.toString(), currentTimestamp: Math.floor(Date.now() / 1000).toString(), maxAttestationAge: '86400', // 24 hours serverPubCommitment: attestation.serverPubCommitment, }; const { proof, publicSignals } = await snarkjs.groth16.fullProve( circuitInputs, wasmUrl, zkeyUrl ); // publicSignals = [commitment, nullifierHash, currentTimestamp, // maxAttestationAge, serverPubCommitment]
Step 3: Submit Proof On-Chain
Encode the proof and public inputs, then call identity-auth.authorize().
The contract verifies the Groth16 proof via cross-contract call and stores the nullifier.
import { Contract, Server, TransactionBuilder, Networks, nativeToScVal } from '@stellar/stellar-sdk'; const IDENTITY_AUTH = 'CABLST6SHB7F3LNQBFF3BSVNDAPCOMBU2PA5WDKQH4VSOTDA2VWWUVZB'; const server = new Server('https://soroban-testnet.stellar.org'); const contract = new Contract(IDENTITY_AUTH); // Encode proof points (decimal strings → big-endian bytes) const proofA = buildG1Point(proof.pi_a); // 64 bytes const proofB = buildG2Point(proof.pi_b); // 128 bytes const proofC = buildG1Point(proof.pi_c); // 64 bytes // Build AuthorizationRequest struct const request = { commitment: decimalToU256(publicSignals[0]), max_attestation_age: 86400, nullifier_hash: decimalToU256(publicSignals[1]), server_pub_commitment: decimalToU256(publicSignals[4]), timestamp: Number(publicSignals[2]), }; // Build and submit transaction const tx = new TransactionBuilder(account, { fee: '1000000', networkPassphrase: Networks.TESTNET }) .addOperation(contract.call('authorize', proofA, proofB, proofC, request)) .setTimeout(60) .build(); const prepared = await server.prepareTransaction(tx); prepared.sign(keypair); const result = await server.sendTransaction(prepared);
Step 4: Check Authorization Status
After a successful proof, the commitment is stored on-chain. Your dApp can check if a
user is authorized by calling is_nullifier_used() or reading events.
// Check if a specific nullifier has been used (proof was accepted) const isUsed = await contract.call('is_nullifier_used', nullifierHash); // Get total authorization count const count = await contract.call('get_auth_count'); // Listen for authorization events // Event topic: ["authorized"] // Event data: (commitment, nullifier_hash)
buildG1Point(),
buildG2Point(), and decimalToU256() are available in
this project’s src/crypto/encoding.ts. You can copy them into your project or
use the stellar-zklogin SDK package (coming soon).
Architecture for Your dApp
You use our attestation server and deployed contracts. Your frontend handles OAuth, generates proofs in the browser, and submits transactions. No server needed on your side.
8. Self-Hosting Guide
Deploy your own ZK Login infrastructure — your own attestation server, your own Soroban contracts, your own trusted setup. Full sovereignty over the trust model.
Prerequisites
- Rust 1.74+ with
wasm32v1-nonetarget - Stellar CLI 23.4+ —
brew install stellar/tap/stellar-cli - Node.js 18+ and npm
- Circom 2.1.0+ — ZK circuit compiler
- snarkjs 0.7+ — Groth16 toolkit
- A funded Stellar testnet account (~100 XLM for deployment)
Step 1: Clone & Build Contracts
# Clone the monorepo git clone https://github.com/nobak-net/stellar-zklogin.git cd stellar-zklogin # Build all Soroban contracts stellar contract build # Output WASMs in target/wasm32v1-none/release/: # identity_auth.wasm (orchestrator) # groth16_verifier.wasm (BN254 pairing verifier) # merkle_tree.wasm (commitment storage) # poseidon_hash.wasm (primitives) # bn254_basics.wasm (primitives) # commitment_scheme.wasm (primitives) # Run contract tests cargo test --workspace
Step 2: Compile the ZK Circuit
cd circuits/identity-attestation npm install # Compile circuit → R1CS + WASM + symbol file ./scripts/compile.sh # Output: build/identity_attestation.r1cs # build/gmail_attestation_js/gmail_attestation.wasm # Stats: ~2,295 constraints, BN254 curve
Step 3: Trusted Setup
Groth16 requires a trusted setup ceremony. This generates the proving key (used by clients to generate proofs) and the verification key (stored in the contract).
# Run Powers of Tau + circuit-specific setup ./scripts/trusted-setup.sh # Output: # keys/identity_attestation.zkey — proving key (client needs this) # keys/verification_key.json — verification key (for the contract) # keys/pot14_final.ptau — Powers of Tau ceremony # Export verification key to Soroban format node scripts/export-vk-to-soroban.js # Output: keys/verification_key_soroban.json
trusted-setup.sh is fine.
Step 4: Deploy to Stellar Testnet
# Fund a testnet account (if you don't have one) stellar keys generate my-deployer --network testnet --fund # Deploy all contracts ./scripts/deploy-testnet.sh my-deployer # This will: # 1. Deploy groth16-verifier, merkle-tree, identity-auth # 2. Initialize each contract (admin = your deployer) # 3. Load the verification key into groth16-verifier # 4. Save all contract IDs to deployment_ids.env # Load the IDs into your shell source deployment_ids.env echo "Identity Auth: $IDENTITY_AUTH_ID"
Step 5: Initialize identity-auth
The identity-auth contract needs your server public commitment — a Poseidon hash of your attestation server’s secret. This binds the contract to your server.
// In Node.js: const { poseidon2 } = require('poseidon-lite'); // Your attestation server secret (keep this safe!) const serverSecret = 'your-secret-string'; // Convert to field element (same as identity hash: SHA-256 → 31 bytes) const secretField = stringToFieldElement(serverSecret); // Compute public commitment const serverPubCommitment = poseidon2([secretField, 1n]); console.log('Server commitment:', serverPubCommitment.toString());
stellar contract invoke --id $IDENTITY_AUTH_ID --network testnet --source my-deployer -- initialize --admin "$(stellar keys address my-deployer)" --config '{ "server_pub_key": "<64-byte-hex>", "verifier_id": "'$GROTH16_VERIFIER_ID'", "merkle_tree_id": "'$MERKLE_TREE_ID'", "vk_hash": "<verification-key-hash>" }'
Step 6: Run the Attestation Server
The attestation server validates OAuth tokens and produces Poseidon attestations. The demo server runs on Cloudflare Pages, but you can run it anywhere.
cd examples/demo # Install dependencies npm install # Set environment variables export SERVER_SECRET="your-secret-string" # same as step 5 export SOROBAN_RPC_URL="https://soroban-testnet.stellar.org" export STELLAR_NETWORK="testnet" export GMAIL_AUTH_CONTRACT_ID="$IDENTITY_AUTH_ID" export SPONSOR_SECRET_KEY="S..." # funded testnet account export GOOGLE_CLIENT_ID="xxx.apps.googleusercontent.com" # Run locally (Wrangler dev server) npm run dev # Server starts at http://localhost:8788 # Or deploy to Cloudflare Pages npm run deploy
Step 7: Point Your App at Your Infrastructure
Change two URLs in your frontend:
// Instead of the demo server, use yours: const ATTESTATION_URL = 'https://your-server.example.com/api/attestation/google'; // Instead of the demo contracts, use yours: const IDENTITY_AUTH_ID = 'CABC...YOUR_CONTRACT...'; // Circuit artifacts stay the same (unless you modified the circuit) // Host them on your CDN or use the demo ones
Deploy to Mainnet
When ready for production:
# Deploy to mainnet (requires funded mainnet account, ~100 XLM) ./scripts/deploy-mainnet.sh my-mainnet-identity # The script will ask for confirmation before deploying. # Same flow as testnet but with: # --network mainnet # Real XLM for gas # A proper multi-party trusted setup
• Run a multi-party trusted setup ceremony (not single-party)
• Use a strong, randomly generated
SERVER_SECRET• Store sponsor secret key securely (e.g., Cloudflare Workers Secrets)
• Set up monitoring for contract events and attestation server health
• Consider rate-limiting the attestation endpoint
Contract API Reference
| Contract | Key Functions |
|---|---|
identity-auth |
authorize(proof_a, proof_b, proof_c, request) — submit ZK proofis_nullifier_used(hash) — check replayget_auth_count() — total authorizationsis_authorized(commitment, merkle_proof, index) — verify membership
|
groth16-verifier |
verify(proof, public_inputs) — BN254 pairing checkset_verification_key(admin, vk) — one-time VK load (immutable after)get_verification_key() — read stored VK
|
merkle-tree |
insert_leaf(caller, leaf) — add commitmentverify_proof(leaf, index, proof, root) — Merkle inclusionget_root() — current tree rootis_known_root(root) — historical root check
|
[commitment, nullifierHash, currentTimestamp, maxAttestationAge, serverPubCommitment]Circuit outputs come first (commitment, nullifierHash), then public inputs in declaration order.