Skip to main content
The Pinaivu developer console (pinaivu-api/dashboard) authenticates users with Sui zkLogin. Instead of a seed phrase or a browser wallet, a developer signs in with their existing Google account and receives a real, self-custodied Sui address — derived from the OAuth identity through a zero-knowledge proof. The OAuth provider never learns the user’s Sui address, and Pinaivu never sees a private key. This page documents how that flow is wired end-to-end, the exact files and endpoints involved, and the security caveats worth knowing before you ship it.

Why zkLogin

A developer console needs an on-chain identity (so usage, keys, and payments can bind to a Sui address) but the audience is developers who do not want to manage a wallet just to create an API key. zkLogin closes that gap:
  • Seedless — the address is a function of the OAuth sub + a salt, proven in zero knowledge. There is no mnemonic to store or lose.
  • Self-custodied — every transaction is still authorised by an ephemeral key the browser generates and holds; the proof binds that ephemeral key to the user’s address for a bounded number of epochs.
  • Private — the link between the Google account and the Sui address is hidden behind the ZK proof. The chain sees a normal Sui address.
See Why Sui for why this primitive only exists on Sui.

Where it lives

pinaivu-api/dashboard/
├─ lib/zklogin/
│  ├─ zklogin.ts      ← ZkLoginService: nonce, OAuth URL, ZK proof, address, tx signature
│  └─ session.ts      ← SessionManager: localStorage session + cached proof (24h TTL)
├─ app/login/page.tsx          ← "Continue with Google" entry point
├─ app/auth/callback/page.tsx  ← OAuth redirect handler → proof → account
└─ app/DashboardShell.tsx      ← route guard; redirects to /login without a proof
The proving and salt-backed nonce are delegated to Enoki (Mysten Labs’ hosted zkLogin service); the OAuth provider is Google (response_type=id_token).

The flow

/login                       Enoki              Google OAuth          /auth/callback
  │                            │                     │                      │
  │ 1. new ephemeral Ed25519   │                     │                      │
  │──── POST /nonce ──────────▶│                     │                      │
  │   (ephemeralPublicKey)     │                     │                      │
  │◀── nonce, randomness ──────│                     │                      │
  │        maxEpoch            │                     │                      │
  │                                                  │                      │
  │ 2. redirect with nonce ─────────────────────────▶│                      │
  │                                                  │ user consents        │
  │ 3. redirect #id_token=<JWT> ────────────────────────────────────────────▶│
  │                            │                     │                      │
  │                            │ 4. POST /zkp (zklogin-jwt: JWT)            │
  │                            │◀────────────────────────────────────────────│
  │                            │─── zkProof ─────────────────────────────────▶│
  │                                                                         │
  │                            5. derive zkLogin address, cache proof,       │
  │                               POST /api/accounts { email, wallet_addr }  │
  │                            6. redirect to dashboard ────────────────────▶ /
1

Initialize a session (/login)

ZkLoginService.initializeSession() mints a fresh ephemeral Ed25519 keypair, sends its public key to the Enoki nonce endpoint (NEXT_PUBLIC_ENOKI_NONCE_URL) with additionalEpochs: 2, and stores the returned { nonce, randomness, maxEpoch } plus the ephemeral private key in localStorage under pinaivu-zklogin-session.
const ephemeralKeyPair = new Ed25519Keypair();
const ephemeralPublicKey = ephemeralKeyPair.getPublicKey().toSuiPublicKey();

const nonceRes = await fetch(process.env.NEXT_PUBLIC_ENOKI_NONCE_URL!, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.NEXT_PUBLIC_ENOKI_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    network: process.env.NEXT_PUBLIC_SUI_NETWORK || "testnet",
    ephemeralPublicKey,
    additionalEpochs: 2,
  }),
});
const { data } = await nonceRes.json(); // { nonce, randomness, maxEpoch }
maxEpoch is the Sui epoch after which both the nonce and any proof bound to this ephemeral key stop being valid — the practical session lifetime.
2

Redirect to Google

The browser is sent to Google’s OIDC endpoint requesting an id_token (a JWT) whose nonce claim is the value Enoki returned. The nonce cryptographically commits the JWT to this ephemeral key, so a stolen JWT can’t be replayed against a different key.
const params = new URLSearchParams({
  client_id: GOOGLE_CLIENT_ID,
  redirect_uri: `${origin}/auth/callback`,
  response_type: "id_token",
  scope: "openid email profile",
  nonce,
  state: "pinaivu_" + Date.now(),
});
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
3

Fetch the ZK proof (/auth/callback)

Google redirects back with #id_token=<JWT> in the URL fragment. The callback recreates the ephemeral keypair from the saved session and asks Enoki to produce the zkLogin proof, passing the JWT in the zklogin-jwt header.
const zkProof = await ZkLoginService.fetchZkProof({
  jwtToken,
  ephemeralKeyPair,            // recreated from session.ephemeralPrivateKey
  randomness: session.randomness,
  maxEpoch: parseInt(session.maxEpoch),
  userSalt: salt,
});
4

Derive the address and persist the proof

The zkLogin Sui address is derived from the JWT + salt (or from the proof’s addressSeed). The full proof bundle is cached in localStorage under pinaivu-zklogin-proof with a 24-hour TTL.
const address = ZkLoginService.getZkLoginAddress(jwtToken, salt, zkProof.addressSeed);
SessionManager.saveProof({ zkProof, jwtToken, address, userSalt: salt, ... });
5

Link the account

The callback creates (or links) the Pinaivu account, binding the email and Sui address, then redirects into the console.
await fetch("/api/accounts", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email: decodedJWT.email, wallet_addr: address }),
});

Signing Sui transactions

When the user later authorises an on-chain action, the ephemeral key signs the transaction bytes and ZkLoginService.createTransactionSignature wraps that signature together with the ZK proof and the addressSeed into a single zkLogin signature that Sui validators accept:
return getZkLoginSignature({
  inputs: { ...partialZkProof, addressSeed },
  maxEpoch,
  userSignature: ephemeralSignature, // ephemeral key's signature over the tx
});
addressSeed comes straight from the proof when present, otherwise it is recomputed with genAddressSeed(BigInt(userSalt), "sub", sub, aud).

Session & route guarding

SessionManager (lib/zklogin/session.ts) keeps two localStorage keys:
KeyContentsLifetime
pinaivu-zklogin-sessionephemeral private key, randomness, maxEpoch, nonceuntil the callback completes (then cleared)
pinaivu-zklogin-proofzkProof, JWT, address, salt, email24 h TTL, auto-expired on read
DashboardShell treats /login and /auth/callback as public and redirects every other route to /login when no valid cached proof exists. “Sign out” calls SessionManager.clearAll().

Environment variables

These are required by the dashboard for zkLogin to work. The NEXT_PUBLIC_ prefix means they are exposed to the browser (Enoki API keys used here are publishable client keys, not server secrets).
VariablePurpose
NEXT_PUBLIC_GOOGLE_CLIENT_IDGoogle OAuth client id (OIDC provider)
NEXT_PUBLIC_ENOKI_API_KEYEnoki publishable key (Bearer auth to Enoki)
NEXT_PUBLIC_ENOKI_NONCE_URLEnoki nonce endpoint (step 1)
NEXT_PUBLIC_ENOKI_ZKP_URLEnoki ZK proving endpoint (step 3)
NEXT_PUBLIC_SUI_NETWORKtestnet (default) / mainnet
Dependencies: @mysten/sui (/zklogin + /keypairs/ed25519) and jwt-decode.
Salt derivation is a development placeholder. The callback currently derives userSalt deterministically from the JWT sub with a simple non-cryptographic hash:
const hashVal = Array.from(encoder.encode(decodedJWT.sub))
  .reduce((acc, val) => (acc << 5) - acc + val, 0);
const salt = Math.abs(hashVal).toString();
This is deterministic (the same Google account always maps to the same address) which is what you want, but it is not a production salt scheme: it has low entropy and the salt is recomputed client-side rather than held by a salt service. For production, back the salt with a persistent, per-user salt service (Enoki can manage this for you) so the OAuth↔address mapping cannot be brute-forced and stays stable across providers.
The id_token arrives in the URL fragment (#id_token=...), which is never sent to a server. The callback parses it client-side, derives the proof, then calls window.history.replaceState to strip it from the address bar. Keep the proving on the client (or a trusted edge) — never log the raw JWT.

Troubleshooting

  • “Google Client ID not configured.”NEXT_PUBLIC_GOOGLE_CLIENT_ID is unset at build time. NEXT_PUBLIC_ vars are inlined during the build, so rebuild after setting it.
  • Enoki nonce error / Enoki ZKP error — check the NEXT_PUBLIC_ENOKI_API_KEY and that the network matches the key’s configured network. The thrown error includes Enoki’s response body.
  • Redirect loops back to /login — the cached proof expired (24 h) or was cleared; sign in again. DashboardShell redirects whenever SessionManager.getProof() returns null.
  • No active session. in the callback — the user reached /auth/callback without first going through /login (the ephemeral session in localStorage is missing).

Why Sui

Why zkLogin and on-chain settlement are built on Sui specifically

Glossary

zkLogin, ephemeral key, salt, maxEpoch, and other terms