Skip to main content

zkLogin Integration Guide

To implement zkLogin, first read What is zkLogin? for the underlying concepts, then configure at least one OpenID provider.

The following steps cover zkLogin on its own. Consumer apps usually combine it with sponsored transactions so a new user never has to fund an address before their first action. For that combination end to end, see the Consumer App Cookbook.

Your application generates an ephemeral key pair, then prompts the user to complete an OAuth login flow with a nonce derived from the ephemeral public key. When the provider returns the JSON Web Token (JWT), your application obtains a zero-knowledge proof and a unique user salt, then computes the zkLogin Sui address from the OAuth subject identifier and that salt. Your application signs transactions with the ephemeral private key and submits each transaction with both the ephemeral signature and the zero-knowledge proof.

Production hardening

Harden your integration before you move to production. Run a production salt service with proper availability and backups, because a user who loses a salt permanently loses access to that address. Serve all endpoints over HTTPS and configure Cross-Origin Resource Sharing (CORS) so that only your origins reach your salt and proving backends. Bind the OAuth nonce to both the JWT and the ephemeral public key, and validate it on return to prevent replay and token-substitution attacks. Never log JWTs, zero-knowledge proofs, or salts, because these values are sensitive credentials. Keep separate Testnet and Mainnet configuration for your OAuth clients, salt service, and prover so that test credentials never reach production.

See Security Best Practices.

Install the zkLogin TypeScript SDK​

To use the zkLogin TypeScript SDK in your project, run the following command in your project root:

$ npm install @mysten/sui

If you want to use the latest experimental version:

$ npm install @mysten/sui@experimental

Step 1: Generate an ephemeral key pair and nonce​

Generate a short-lived key pair, set an expiration epoch, and compute a nonce that binds the key to the OAuth session.

import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
import { generateNonce, generateRandomness } from '@mysten/sui/zklogin';
import { SuiGrpcClient } from '@mysten/sui/grpc';

const FULLNODE_URL = '<YOUR_RPC_URL>';
const suiClient = new SuiGrpcClient({ baseUrl: FULLNODE_URL, network: 'testnet' });
const { systemState } = await suiClient.core.getCurrentSystemState();
const { epoch } = systemState;

const maxEpoch = Number(epoch) + 2; // ephemeral key valid for 2 epochs from now
const ephemeralKeyPair = new Ed25519Keypair();
const randomness = generateRandomness();
const nonce = generateNonce(ephemeralKeyPair.getPublicKey(), maxEpoch, randomness);

randomness is a random value from generateRandomness(), and generateNonce derives nonce from the ephemeral public key, maxEpoch, and randomness. You pass the nonce to the OAuth provider in Step 2.

The maxEpoch controls how long this ephemeral key can authorize transactions. The address itself does not expire; only the session does. When the epoch passes maxEpoch, generate a new key pair, send the user through the OAuth login again with the new nonce, and get a new zero-knowledge proof. See Does my address expire? for details.

Step 2: Direct the user to the OAuth login​

Construct the OAuth URL with your configured client ID, redirect URL, and the nonce from the previous step.

The client ID identifies your application, not an individual user, so every user of your app uses the same client ID. The redirect URL is the address in your app where the provider sends the user after login, with the JWT or authorization code attached. Step 3 shows an example.

Google, Facebook, Twitch, Apple, and Microsoft return the JWT directly, with no token exchange. Kakao and Slack use the authorization code flow instead (response_type=code): they return an authorization code that you must exchange for a JWT at the provider's token endpoint.

https://accounts.google.com/o/oauth2/v2/auth
?client_id=$CLIENT_ID
&response_type=id_token
&redirect_uri=$REDIRECT_URL
&scope=openid
&nonce=$NONCE

Google returns the JWT directly in the redirect URL (id_token parameter). You do not need a token exchange.

Step 3: Decode the JWT​

After the OAuth redirect, the JWT appears as a URL parameter. For providers that return it directly, it looks like this:

https://your-app.com/auth?id_token=tokenPartA.tokenPartB.tokenPartC

Decode the JWT to extract the claims you need. The Sui SDK provides a built-in decodeJwt utility:

import { decodeJwt } from '@mysten/sui/zklogin';

const decodedJwt = decodeJwt(encodedJWT);
// decodedJwt.sub — the user's unique identifier
// decodedJwt.iss — the OAuth provider
// decodedJwt.aud — your application's client ID

You can validate the structure of the encoded JWT by pasting it in jwt.io.

Step 4: Manage user salt​

The user salt is a 16-byte value (or integer smaller than 2n**128n) that, combined with the OAuth subject identifier, determines the user's Sui address. If a user loses their salt, they permanently lose access to that address.

Choose a salt management strategy:

StrategyDescriptionTrade-offs
User-providedAsk the user to enter and remember the salt.Basic but poor UX; users forget salts.
Browser or device storageStore in localStorage or mobile secure storage.Easy but lost on device or browser change. Email the salt to the user as a backup.
Database mappingMap user identifier (sub) to a salt in your backend database.Reliable; requires a backend service. Salt is unique per user.
Derived from master seedUse HKDF(ikm = seed, salt = iss || aud, info = sub) to derive salts deterministically from a master seed (|| denotes concatenation).No per-user storage needed, but you cannot rotate the master seed, and you cannot change the client ID (aud) without deriving a different address.

Your salt service identifies users by JWT claims, not by Sui address, because the address does not exist until you have the salt. The database mapping strategy uses sub, and the derived strategy uses iss, aud, and sub.

The Mysten Labs Enoki platform provides a managed salt service (using derived salts). Contact Mysten Labs for access. The service only accepts JWTs authenticated with allowlisted client IDs.

curl -X POST https://salt.api.mystenlabs.com/get_salt \
-H 'Content-Type: application/json' \
-d '{"token": "$JWT_TOKEN"}'

# Response: {"salt":"129390038577185583942388216820280642146"}

The service returns the salt as a decimal string. The SDK functions jwtToAddress and genAddressSeed accept it as a string or a BigInt. Step 7 passes it through BigInt().

The salt's purpose is to disconnect the OAuth identifier (sub) from the onchain Sui address. Leaking the salt enables linking Web2 and Web3 identities but does not compromise fund control.

Step 5: Derive the user's Sui address​

With the JWT and salt, derive the zkLogin address:

import { jwtToAddress } from '@mysten/sui/zklogin';

// The third argument (legacyAddress) is required.
// Pass false for new integrations; pass true only for addresses created before the derivation fix.
const zkLoginUserAddress = jwtToAddress(jwt, userSalt, false);

The same sub + iss + aud + user_salt always produces the same address. A change in any of these values produces a different address, so a user who signs in with 2 different OAuth providers has 2 different zkLogin addresses. See Address definition for the full derivation formula.

Step 6: Get the zero-knowledge proof​

The zero-knowledge proof attests that the ephemeral key pair is valid and bound to the user's OAuth identity, without revealing the JWT onchain. If you cannot use the Mysten Labs hosted prover, run your own prover as described in Option B: Self-hosted proving service.

First, generate the extended ephemeral public key:

import { getExtendedEphemeralPublicKey } from '@mysten/sui/zklogin';

const extendedEphemeralPublicKey = getExtendedEphemeralPublicKey(
ephemeralKeyPair.getPublicKey(),
);

You need a new zero-knowledge proof whenever the ephemeral key pair expires or you lose it. You can reuse the proof for multiple transactions within the same session.

Option A: use the Mysten Labs proving service​

The Mysten Labs hosted proving service generates zero-knowledge proofs on your behalf. Access depends on the environment:

  • Devnet and Testnet: The public prover endpoint is available without registration. Use it for development and testing.
  • Mainnet: Mainnet access requires allowlisting through the Enoki platform. Register your app's client ID in the Enoki Portal and contact Mysten Labs to enable Mainnet proving.

Hosted prover allowlisting​

Before your app can use the Mysten Labs prover on Mainnet, complete the following:

  1. Register your app in the Enoki Portal and obtain an API key.
  2. Add your OAuth client ID to the Enoki Portal configuration. The prover only accepts JWTs from allowlisted client IDs.
  3. Contact Mysten Labs through the Enoki docs to request Mainnet prover access for your registered app.

The hosted prover auto-scales for traffic surges. If you expect a spike in prover requests, reach out on Discord.

You can use BigInt or Base64 encoding for extendedEphemeralPublicKey, jwtRandomness, and salt:

curl -X POST $PROVER_URL -H 'Content-Type: application/json' \
-d '{
"jwt": "$JWT_TOKEN",
"extendedEphemeralPublicKey": "84029355920633174015103288781128426107680789454168570548782290541079926444544",
"maxEpoch": "10",
"jwtRandomness": "100681567828351849884072155819400689117",
"salt": "248191903847969014646285995941615069143",
"keyClaimName": "sub"
}'

The response contains the proof components:

{
"proofPoints": {
"a": ["17267520...", "14650660...", "1"],
"b": [["21139310...", "10547097..."], ["12744153...", "17883388..."], ["1", "0"]],
"c": ["14769767...", "19108054...", "1"]
},
"issBase64Details": {
"value": "wiaXNzIjoiaHR0cHM6Ly9pZC50d2l0Y2gudHYvb2F1dGgyIiw",
"indexMod4": 2
},
"headerBase64": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEifQ"
}

How do I handle CORS errors?​

To avoid CORS errors in frontend apps, proxy the prover call through your backend:

import { getZkLoginSignature } from '@mysten/sui/zklogin';

const proofResponse = await post('/your-internal-api/zkp/get', zkpRequestPayload);

export type PartialZkLoginSignature = Omit<
Parameters<typeof getZkLoginSignature>['0']['inputs'],
'addressSeed'
>;
const partialZkLoginSignature = proofResponse as PartialZkLoginSignature;

The prover response does not include addressSeed, which is why PartialZkLoginSignature omits it. You compute addressSeed from the user salt and the JWT claims sub and aud with genAddressSeed in Step 7.

Option B: Self-hosted proving service​

Self-host the proving service when you need full control over proof generation, want to avoid the hosted prover allowlisting process, or have compliance requirements that prevent sending JWTs to a third-party service. The self-hosted prover uses the same request and response format as the Mysten Labs service, so you can switch between hosted and self-hosted without changing your client code.

  1. Install Git Large File Storage before downloading the zkey.

  2. Download the Groth16 proving key zkey file:

    wget -O - https://raw.githubusercontent.com/sui-foundation/zklogin-ceremony-contributions/main/download-main-zkey.sh | bash

    Verify the hash: b2sum zkLogin-main.zkey

    Expected: 060beb961802568ac9ac7f14de0fbcd55e373e8f5ec7cc32189e26fb65700aa4e36f5604f868022c765e634d14ea1cd58bd4d79cef8f3cf9693510696bcbcbce

  3. Run the prover using Docker Compose. Create a docker-compose.yml:

    services:
    backend:
    image: mysten/zklogin:prover-stable
    volumes:
    - ${ZKEY}:/app/binaries/zkLogin.zkey
    environment:
    - ZKEY=/app/binaries/zkLogin.zkey
    - WITNESS_BINARIES=/app/binaries

    frontend:
    image: mysten/zklogin:prover-fe-stable
    command: '8080'
    ports:
    - '${PROVER_PORT}:8080'
    environment:
    - PROVER_URI=http://backend:8080/input
    - NODE_ENV=production
    - DEBUG=zkLogin:info,jwks
    # Default timeout is 15 seconds. Uncomment to change:
    # - PROVER_TIMEOUT=30

    Set ZKEY to the path of the downloaded zkey file and PROVER_PORT to the host port for the prover, then start the services:

    ZKEY=<path_to_zkLogin.zkey> PROVER_PORT=<port> docker compose up
  4. The service exposes two endpoints:

    • /ping: health check (returns pong)
    • /v1: proof generation (same request and response format as the Mysten Labs service)
Prover resource requirements

The backend service (mysten/zklogin:prover-stable) is compute-heavy. Use at least 16 cores and 16 GB RAM. Weaker instances can cause timeout errors (Call to rapidsnark service took longer than 15s). Adjust with PROVER_TIMEOUT=30 for a 30-second timeout.

Set DEBUG=zkLogin:info,jwks in production. Using DEBUG=* turns on all logs, some of which contain personally identifiable information (PII).

If you need better performance, compile the prover from the rapidsnark fork and launch it in server mode.

Step 7: Assemble the zkLogin signature and submit​

Sign the transaction with the ephemeral private key, then combine it with the zero-knowledge proof to create a zkLogin signature.

The following code uses values from earlier steps:

  • ephemeralKeyPair and maxEpoch from Step 1
  • decodedJwt from Step 3
  • userSalt, the salt value that your salt service returns in Step 4
  • zkLoginUserAddress from Step 5
  • partialZkLoginSignature from Step 6
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
import { SuiGrpcClient } from '@mysten/sui/grpc';
import { Transaction } from '@mysten/sui/transactions';
import { genAddressSeed, getZkLoginSignature } from '@mysten/sui/zklogin';

// 1. Sign the transaction with the ephemeral key
const client = new SuiGrpcClient({ baseUrl: '<YOUR_RPC_URL>', network: 'testnet' });
const txb = new Transaction();
txb.setSender(zkLoginUserAddress);

const { bytes, signature: userSignature } = await txb.sign({
client,
signer: ephemeralKeyPair, // must be the same key pair used for the zero-knowledge proof
});

// 2. Generate the address seed
const addressSeed = genAddressSeed(
BigInt(userSalt!),
'sub',
decodedJwt.sub,
decodedJwt.aud,
).toString();

// 3. Assemble the zkLogin signature
const zkLoginSignature = getZkLoginSignature({
inputs: {
...partialZkLoginSignature,
addressSeed,
},
maxEpoch,
userSignature,
});

// 4. Execute the transaction
const result = await client.executeTransaction({
transaction: bytes,
signatures: [zkLoginSignature],
});

Alternative: Use ZkLoginSigner​

Instead of manually assembling signatures in Step 7, you can use the ZkLoginSigner class to handle proof wrapping automatically. This is useful when you want to sign multiple transactions without repeating the signature assembly code.

import { ZkLoginSigner } from '@mysten/sui/zklogin';

const zkSigner = new ZkLoginSigner({
ephemeralSigner: ephemeralKeyPair,
maxEpoch,
inputs: {
...partialZkLoginSignature,
addressSeed,
},
legacyAddress: false,
client,
});

// Sign and execute — proof wrapping happens automatically
const txb = new Transaction();
txb.setSender(zkLoginUserAddress);
const { bytes, signature } = await zkSigner.signTransaction(await txb.build({ client }));
await client.executeTransaction({ transaction: bytes, signatures: [signature] });

See ZkLoginSigner for the full API reference.

Pass the derived address to a fiat on-ramp provider​

After deriving the zkLogin address, you can use it as the destination address for fiat on-ramp providers. The on-ramp provider sends purchased tokens (SUI, USDC, or other supported assets) directly to the user's zkLogin address. Pass the derived address to the provider's widget or API as the walletAddress or equivalent parameter:

  1. Derive the address with jwtToAddress as shown in Step 5.
  2. Pass zkLoginUserAddress to the on-ramp provider's configuration (for example, Banxa, Stripe, or Meld). Each provider accepts a destination address parameter in their widget SDK or API. See Fiat On-Ramps for provider-specific documentation links.
  3. The provider delivers the purchased tokens to the zkLogin address. The user can then sign transactions with their ephemeral key pair and zero-knowledge proof.

The zkLogin address is deterministic. The same OAuth identity and salt always produce the same address, so the user can receive tokens from an on-ramp provider across multiple sessions without re-deriving.

How do I cache the ephemeral key and zero-knowledge proof?​

Each zero-knowledge proof binds to an ephemeral key pair. You can reuse the proof to sign any number of transactions until the current epoch crosses maxEpoch.

Cache the ephemeral key pair and zero-knowledge proof for the duration of the session. However, treat them as secrets:

  • Use session storage, not local storage on browsers. Session storage clears automatically when the browser session ends.
  • Never persist them in an insecure location on any platform.
  • If an attacker compromises both the ephemeral private key and the zero-knowledge proof, the attacker can sign transactions on the user's behalf.

How much prover capacity do I need?​

zkLogin proofs take longer to generate than traditional signatures. The Mysten Labs prover typically returns a proof in about 3 seconds on a machine with 16 vCPUs and 64 GB RAM.

The right capacity metric is active user sessions, not individual signatures, because you cache and reuse each proof across a session. For example, 1 million daily active sessions with evenly distributed traffic requires roughly 1–2 requests per second (RPS) from the prover.

The Mysten Labs prover auto-scales for traffic surges. If you expect a spike in prover requests, reach out on Discord.

Troubleshooting​

SymptomCauseFix
CORS error when calling proverFrontend calling prover directlyProxy prover calls through your backend
InvalidSignature from networkEphemeral key expired (epoch > maxEpoch)Generate a new key pair, re-authenticate, get a new zero-knowledge proof
InvalidSignature from networkNonce mismatch between JWT and signing keyEnsure you sign with the same ephemeral key pair used to compute the nonce
Prover timeout (took longer than 15s)Prover machine is underpoweredUse at least 16 cores and 16 GB RAM, or increase PROVER_TIMEOUT
Salt service returns different addressaud or iss changedThe address depends on sub + iss + aud + salt. Any change produces a different address.
User locked out of addressSalt lostSalt loss is permanent. Ensure your salt service has backups and recovery procedures.