Integration Guide

This guide covers multiple integration paths for adding zero-knowledge identity verification to your application, from quick embeds to full custom implementations.

Table of Contents

  1. 5-Minute Age Verification Embed
  2. Server-Side Verifier Setup
  3. Nationality Set-Membership Verification
  4. OpenID4VP Integration Path
  5. Mobile SDK Integration
  6. Configuration Options
  7. Troubleshooting

5-Minute Age Verification Embed {#5-minute-embed}

The fastest way to add age verification to any website.

Step 1: Install Dependencies

npm install @zk-id/sdk @zk-id/core

See the age-gate-widget example for a complete implementation.

Step 2: Add to Your Page

<button id="verify-age">Enter Site (18+ Required)</button>

<script type="module">
  import { ZkIdAgeGateWidget } from '@zk-id/age-gate-widget';

  document.getElementById('verify-age').addEventListener('click', () => {
    ZkIdAgeGateWidget.init({
      verificationEndpoint: 'https://your-verifier.com/auth/request',
      minAge: 18,
      onVerified: () => {
        // User verified! Show restricted content
        document.getElementById('restricted-content').classList.remove('hidden');
      },
      onRejected: (reason) => {
        console.log('Verification failed:', reason);
      },
    });
  });
</script>

Step 3: Deploy a Verifier

See Server-Side Setup below for deploying your verification endpoint.

Widget Options

interface ZkIdAgeGateConfig {
  // Required
  verificationEndpoint: string; // Your OpenID4VP verifier endpoint
  minAge: number; // Minimum age (e.g., 18, 21)
  onVerified: () => void; // Success callback

  // Optional
  onRejected?: (reason: string) => void; // Failure callback
  issuerEndpoint?: string; // Custom issuer URL

  circuitPaths?: {
    ageWasm: string; // Defaults to CDN
    ageZkey: string;
  };

  branding?: {
    title?: string; // Modal title
    primaryColor?: string; // Hex color (e.g., "#667eea")
    logo?: string; // Logo URL
  };
}

Example: Custom Branding

ZkIdAgeGateWidget.init({
  verificationEndpoint: 'https://your-verifier.com/auth/request',
  minAge: 21,
  onVerified: () => unlockContent(),
  branding: {
    title: 'Verify Your Age',
    primaryColor: '#ff6b6b',
    logo: 'https://your-site.com/logo.png',
  },
});

Server-Side Verifier Setup {#server-side-setup}

Every integration requires a verifier server to validate proofs.

Quick Start (Express)

npm install @zk-id/sdk express cors
import express from 'express';
import cors from 'cors';
import { ZkIdServer, OpenID4VPVerifier, InMemoryIssuerRegistry } from '@zk-id/sdk';

const app = express();
app.use(cors());
app.use(express.json());

// Initialize ZkIdServer
const zkIdServer = new ZkIdServer({
  verificationKeyPath: './verification_key.json',
  issuerRegistry: new InMemoryIssuerRegistry([
    // Add trusted issuer public keys here
  ]),
});

// Wrap with OpenID4VP
const verifier = new OpenID4VPVerifier({
  zkIdServer,
  verifierUrl: 'https://your-verifier.com',
  verifierId: 'your-verifier-id',
  callbackUrl: 'https://your-verifier.com/openid4vp/callback',
});

// Authorization endpoint
app.get('/auth/request', async (req, res) => {
  const minAge = parseInt(req.query.minAge as string) || 18;
  const authRequest = verifier.createAgeVerificationRequest(minAge);

  res.json({
    authRequest,
    qrCode: await generateQRCode(authRequest), // Optional
  });
});

// Callback endpoint
app.post('/openid4vp/callback', async (req, res) => {
  const result = await verifier.verifyPresentation(req.body, req.ip);

  if (result.verified) {
    res.json({ verified: true, message: 'Age verified' });
  } else {
    res.status(400).json({ verified: false, error: result.error });
  }
});

app.listen(5002, () => {
  console.log('Verifier running on http://localhost:5002');
});

Issuer Registry Setup

Add trusted issuer public keys to validate credentials:

import { InMemoryIssuerRegistry } from '@zk-id/sdk';
import { createPublicKey } from 'crypto';

// Load issuer public keys
const issuer1PublicKey = createPublicKey({
  key: Buffer.from('base64-encoded-public-key', 'base64'),
  type: 'spki',
  format: 'der',
});

const issuer2PublicKey = createPublicKey({
  key: Buffer.from('base64-encoded-public-key', 'base64'),
  type: 'spki',
  format: 'der',
});

const issuerRegistry = new InMemoryIssuerRegistry([
  {
    issuer: 'Government ID Provider',
    publicKey: issuer1PublicKey,
    status: 'active',
  },
  {
    issuer: 'Bank KYC Service',
    publicKey: issuer2PublicKey,
    status: 'active',
  },
]);

Environment Variables

# .env
VERIFIER_URL=https://your-verifier.com
VERIFIER_ID=your-verifier-id
VERIFICATION_KEY_PATH=./verification_key.json
PORT=5002

Deployment

Railway / Render / Fly.io:

# Install dependencies
npm install

# Build
npm run build

# Start
npm start

Docker:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 5002
CMD ["npm", "start"]

Nationality Set-Membership Verification {#nationality-set-path}

Prove a user’s nationality is in an allowed set (e.g., β€œis an EU citizen”) without revealing the specific country. This uses the nationality-set-verify circuit with up to 32 allowed ISO 3166-1 numeric codes.

Quick Example

import { generateNationalitySetProofAuto, verifyNationalitySetProof, REGION_EU } from '@zk-id/core';
import { readFileSync } from 'fs';

// Client: generate proof that user is an EU citizen
const proof = await generateNationalitySetProofAuto(
  credential,
  REGION_EU, // allowedCodes: all 27 EU member states
  'nonce-from-server',
  Date.now(),
);

// Server: verify the proof
const verificationKey = JSON.parse(readFileSync('./nationality-set-verify_vkey.json', 'utf-8'));
const isValid = await verifyNationalitySetProof(proof, verificationKey, REGION_EU);
console.log('EU citizen verified:', isValid); // true β€” nationality is in REGION_EU

Available Region Constants

import {
  REGION_EU,
  REGION_US,
  REGION_US_EU,
  REGION_EEA,
  NATIONALITY_SET_MAX_SIZE,
} from '@zk-id/core';

// REGION_EU   β€” all 27 EU member states
// REGION_US   β€” [840] (United States only)
// REGION_US_EU β€” REGION_US + REGION_EU (28 codes)
// REGION_EEA  β€” REGION_EU + Iceland (352), Liechtenstein (438), Norway (578)
// NATIONALITY_SET_MAX_SIZE β€” 32 (maximum allowed codes per proof)

Server-Side Setup for Set-Membership Verification

import express from 'express';
import { verifyNationalitySetProof, REGION_EU } from '@zk-id/core';
import { readFileSync } from 'fs';

const nationalitySetVKey = JSON.parse(
  readFileSync('./nationality-set-verify-verification_key.json', 'utf-8'),
);

app.post('/api/verify-eu-citizenship', async (req, res) => {
  const { proof } = req.body;

  // Verify the proof matches REGION_EU
  const isValid = await verifyNationalitySetProof(proof, nationalitySetVKey, REGION_EU);

  if (isValid) {
    res.json({ verified: true, message: 'EU citizenship confirmed' });
  } else {
    res.status(400).json({ verified: false, error: 'Nationality not in EU member states' });
  }
});

Custom Allowed Sets

You can specify any set of up to 32 ISO 3166-1 numeric codes:

// UK + EU (post-Brexit cross-border scenario)
const UK_AND_EU = [826, ...REGION_EU]; // 826 = United Kingdom

// Nordic countries only
const NORDIC = [208, 246, 352, 578, 752]; // Denmark, Finland, Iceland, Norway, Sweden

OpenID4VP Integration Path {#openid4vp-path}

Standards-compliant integration for interoperability with other wallets.

Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Verifier   β”‚                  β”‚    Wallet    β”‚                 β”‚    Issuer    β”‚
β”‚ (Your App)   β”‚                  β”‚  (User's)    β”‚                 β”‚  (Trusted)   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚                                 β”‚                                 β”‚
       β”‚ 1. Authorization Request        β”‚                                 β”‚
       β”‚    (Presentation Definition)    β”‚                                 β”‚
       β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚                                 β”‚
       β”‚                                 β”‚  2. Fetch credential            β”‚
       β”‚                                 │────────────────────────────────>β”‚
       β”‚                                 β”‚  3. Return credential           β”‚
       β”‚                                 β”‚<─────────────────────────────────
       β”‚                                 β”‚  4. Generate ZK proof           β”‚
       β”‚                                 β”‚     (locally, private)          β”‚
       β”‚  5. Submit Presentation         β”‚                                 β”‚
       β”‚<─────────────────────────────────                                 β”‚
       β”‚  6. Verify proof βœ“              β”‚                                 β”‚

Verifier Implementation

import { OpenID4VPVerifier } from '@zk-id/sdk';

const verifier = new OpenID4VPVerifier({
  zkIdServer,
  verifierUrl: 'https://your-app.com',
  verifierId: 'your-app',
  callbackUrl: 'https://your-app.com/openid4vp/callback',
});

// Create authorization request
app.get('/auth/request', (req, res) => {
  const authRequest = verifier.createAgeVerificationRequest(18);

  // For mobile wallets, generate QR code
  const qrCode = generateQRCode(
    `openid4vp://?${new URLSearchParams({
      presentation_definition: JSON.stringify(authRequest.presentation_definition),
      response_uri: authRequest.response_uri,
      nonce: authRequest.nonce,
      client_id: authRequest.client_id,
      state: authRequest.state,
    })}`,
  );

  res.json({ authRequest, qrCode });
});

// Handle presentation submission
app.post('/openid4vp/callback', async (req, res) => {
  const result = await verifier.verifyPresentation(req.body, req.ip);
  res.json({ verified: result.verified });
});

Browser Wallet Implementation

import { OpenID4VPWallet, InMemoryCredentialStore } from '@zk-id/sdk';

const wallet = new OpenID4VPWallet({
  credentialStore: new InMemoryCredentialStore(),
  circuitPaths: {
    ageWasm: 'https://cdn.example.com/age.wasm',
    ageZkey: 'https://cdn.example.com/age.zkey',
  },
  walletId: 'my-wallet',
});

// Fetch authorization request from verifier
const response = await fetch('https://verifier.com/auth/request');
const { authRequest } = await response.json();

// Generate presentation
const presentation = await wallet.generatePresentation(authRequest);

// Submit to callback
await fetch(authRequest.response_uri, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(presentation),
});

Custom Presentation Definitions

Create custom verification requests:

const customRequest = verifier.createAuthorizationRequest({
  presentation_definition: {
    id: 'nationality-verification',
    name: 'Nationality Verification',
    purpose: 'Verify citizenship',
    input_descriptors: [
      {
        id: 'nationality-proof',
        constraints: {
          fields: [
            {
              path: ['$.credentialSubject.nationality'],
              filter: {
                type: 'string',
                const: 'US',
              },
            },
          ],
        },
      },
    ],
  },
});

Mobile SDK Integration {#mobile-sdk-path}

Native mobile wallet implementation for React Native and Expo.

Installation

npm install @zk-id/mobile @zk-id/core
npm install @react-native-async-storage/async-storage

Basic Setup (React Native)

import AsyncStorage from '@react-native-async-storage/async-storage';
import { MobileWallet, MobileCredentialStore, SecureStorageAdapter } from '@zk-id/mobile';

// Create storage adapter
const storageAdapter: SecureStorageAdapter = {
  getItem: (key) => AsyncStorage.getItem(key),
  setItem: (key, value) => AsyncStorage.setItem(key, value),
  removeItem: (key) => AsyncStorage.removeItem(key),
  getAllKeys: () => AsyncStorage.getAllKeys(),
};

// Initialize wallet
const wallet = new MobileWallet({
  credentialStore: new MobileCredentialStore(storageAdapter),
  circuitPaths: {
    ageWasm: 'https://cdn.example.com/age.wasm',
    ageZkey: 'https://cdn.example.com/age.zkey',
  },
});

Credential Management

// Add credential from issuer
const credential = await fetchCredentialFromIssuer();
await wallet.addCredential(credential);

// List all credentials
const credentials = await wallet.listCredentials();

// Export for backup
const backup = await wallet.exportCredentials();
await saveToCloud(backup);

// Import from backup
const restored = await loadFromCloud();
await wallet.importCredentials(restored);

Proof Generation

// Generate age proof
const proofResponse = await wallet.generateAgeProof(
  null, // Auto-select most recent credential
  18, // Minimum age
  'challenge-nonce-from-verifier',
);

// Submit to verifier
await fetch('https://verifier.com/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(proofResponse),
});
import { Linking } from 'react-native';
import { parseAuthorizationRequest, generatePresentation, submitPresentation } from '@zk-id/mobile';

// Listen for deep links
Linking.addEventListener('url', async (event) => {
  const url = event.url; // e.g., openid4vp://?presentation_definition=...

  // Parse authorization request
  const authRequest = parseAuthorizationRequest(url);

  // Generate presentation
  const presentation = await generatePresentation(authRequest, wallet);

  // Submit to verifier
  const httpAdapter = {
    post: (url, body, headers) =>
      fetch(url, { method: 'POST', headers, body: JSON.stringify(body) }),
    get: (url, headers) => fetch(url, { headers }),
  };

  const result = await submitPresentation(authRequest.response_uri, presentation, httpAdapter);

  console.log('Verification result:', result);
});

QR Code Scanning

import { Camera } from 'expo-camera';
import { parseAuthorizationRequest } from '@zk-id/mobile';

const handleBarCodeScanned = async ({ data }) => {
  if (data.startsWith('openid4vp://')) {
    const authRequest = parseAuthorizationRequest(data);
    // Handle authorization request...
  }
};

<Camera onBarCodeScanned={handleBarCodeScanned} />

Expo SecureStore Example

import * as SecureStore from 'expo-secure-store';

const expoStorageAdapter: SecureStorageAdapter = {
  getItem: (key) => SecureStore.getItemAsync(key),
  setItem: (key, value) => SecureStore.setItemAsync(key, value),
  removeItem: (key) => SecureStore.deleteItemAsync(key),
  getAllKeys: async () => {
    // Expo SecureStore doesn't support getAllKeys natively
    // Maintain a key index
    const index = await SecureStore.getItemAsync('zkid:key-index');
    return index ? JSON.parse(index) : [];
  },
};

Configuration Options {#configuration}

Circuit Paths

CDN (Recommended for Production):

circuitPaths: {
  ageWasm: 'https://cdn.jsdelivr.net/npm/@zk-id/circuits/dist/age.wasm',
  ageZkey: 'https://cdn.jsdelivr.net/npm/@zk-id/circuits/dist/age.zkey',
  nationalityWasm: 'https://cdn.jsdelivr.net/npm/@zk-id/circuits/dist/nationality.wasm',
  nationalityZkey: 'https://cdn.jsdelivr.net/npm/@zk-id/circuits/dist/nationality.zkey',
}

Self-Hosted:

circuitPaths: {
  ageWasm: 'https://your-cdn.com/circuits/age.wasm',
  ageZkey: 'https://your-cdn.com/circuits/age.zkey',
}

Local Development:

circuitPaths: {
  ageWasm: '/circuits/age.wasm',
  ageZkey: '/circuits/age.zkey',
}

Issuer Registry

In-Memory (Development):

import { InMemoryIssuerRegistry } from '@zk-id/sdk';
import { createPublicKey } from 'crypto';

const issuerRegistry = new InMemoryIssuerRegistry([
  {
    issuer: 'Test Issuer',
    publicKey: createPublicKey(issuerPublicKeyPem),
    status: 'active',
  },
]);

Database-Backed (Production):

import { IssuerRegistry, IssuerRecord } from '@zk-id/sdk';
import { createPublicKey } from 'crypto';

class PostgresIssuerRegistry implements IssuerRegistry {
  async getIssuer(issuer: string): Promise<IssuerRecord | null> {
    const row = await db.query(
      'SELECT issuer, public_key_pem, status FROM issuers WHERE issuer = $1',
      [issuer],
    );

    if (!row) return null;

    return {
      issuer: row.issuer,
      publicKey: createPublicKey(row.public_key_pem),
      status: row.status,
    };
  }
}

Credential Storage

Browser (IndexedDB):

import { IndexedDBCredentialStore } from '@zk-id/sdk';

const store = new IndexedDBCredentialStore();

Mobile (Secure Storage):

import { MobileCredentialStore } from '@zk-id/mobile';
import AsyncStorage from '@react-native-async-storage/async-storage';

const store = new MobileCredentialStore({
  getItem: (key) => AsyncStorage.getItem(key),
  setItem: (key, value) => AsyncStorage.setItem(key, value),
  removeItem: (key) => AsyncStorage.removeItem(key),
  getAllKeys: () => AsyncStorage.getAllKeys(),
});

Server (In-Memory for Testing):

import { InMemoryCredentialStore } from '@zk-id/sdk';

const store = new InMemoryCredentialStore();

Troubleshooting {#troubleshooting}

Common Issues

Proof Generation Takes Too Long

Problem: First proof takes 45-60 seconds.

Solution:

  • This is expected for the initial circuit load (~5MB WASM + zkey)
  • Subsequent proofs are faster (~10-15 seconds)
  • Consider adding a loading indicator
  • Roadmap: Circuit caching and proof reuse (Q2 2026)
// Show loading indicator
setLoading(true);
setLoadingMessage('Loading verification circuit... (~45 seconds)');

const proof = await wallet.generateAgeProof(null, 18, nonce);

setLoading(false);

CORS Errors

Problem: Access-Control-Allow-Origin errors when calling verifier.

Solution: Add CORS headers to your verifier:

import cors from 'cors';

app.use(
  cors({
    origin: ['https://your-frontend.com', 'http://localhost:5000'],
    credentials: true,
  }),
);

Circuit Files Not Loading

Problem: 404 errors for .wasm or .zkey files.

Solution:

  1. Verify circuit paths are correct
  2. Check CDN availability
  3. Serve files with correct MIME types:
// Express
app.use(
  '/circuits',
  express.static('circuits', {
    setHeaders: (res, path) => {
      if (path.endsWith('.wasm')) {
        res.setHeader('Content-Type', 'application/wasm');
      }
    },
  }),
);

Verification Fails with β€œInvalid Signature”

Problem: Proof verification returns β€œInvalid signature” error.

Solution:

  1. Ensure issuer public key is in the registry
  2. Verify credential hasn’t expired
  3. Check that proof timestamp is recent
  4. Confirm circuit files match (same trusted setup)
// Debug issuer registry
const issuer = await issuerRegistry.getIssuer(credential.issuerPublicKey);
console.log('Issuer found:', issuer);

Problem: openid4vp:// URLs don’t trigger the app.

Solution: Configure deep link handling in your app:

iOS (Info.plist):

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>openid4vp</string>
    </array>
  </dict>
</array>

Android (AndroidManifest.xml):

<intent-filter>
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <data android:scheme="openid4vp" />
</intent-filter>

Storage Adapter Errors

Problem: SecureStorageAdapter methods throwing errors.

Solution:

  • Ensure all 4 methods are implemented: getItem, setItem, removeItem, getAllKeys
  • Handle null returns correctly
  • Test with mock adapter first:
const mockAdapter = {
  getItem: async (key) => mockStorage.get(key) ?? null,
  setItem: async (key, value) => {
    mockStorage.set(key, value);
  },
  removeItem: async (key) => {
    mockStorage.delete(key);
  },
  getAllKeys: async () => Array.from(mockStorage.keys()),
};

Performance Optimization

Cache Circuit Files

// Service Worker (browser)
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('zk-circuits').then((cache) => {
      return cache.addAll(['/circuits/age.wasm', '/circuits/age.zkey']);
    }),
  );
});

Preload Circuits

<!-- HTML head -->
<link rel="preload" href="/circuits/age.wasm" as="fetch" crossorigin />
<link rel="preload" href="/circuits/age.zkey" as="fetch" crossorigin />

Lazy Load Wallet

// Only load wallet when needed
const walletPromise = import('@zk-id/sdk').then((module) => new module.OpenID4VPWallet(config));

button.addEventListener('click', async () => {
  const wallet = await walletPromise;
  // Use wallet...
});

Security Best Practices

  1. Always use HTTPS in production
  2. Validate nonces on the server side
  3. Check proof timestamps to prevent replay attacks
  4. Rate limit verification endpoints
  5. Log verification attempts for audit trails
  6. Keep issuer registry updated with trusted issuers only
  7. Use secure storage on mobile (Keychain/EncryptedSharedPreferences)
  8. Never log user credentials or proofs

Next Steps


Need help? Open an issue or ask in Discussions.