Identity verification presents a fundamental tension in modern web3 applications.
Services need to verify user attributes like age, residency, or accreditation status, yet users reasonably expect privacy regarding their personal information.
Traditional Know Your Customer (KYC) systems resolve this by creating centralized honeypots of sensitive data that become attractive targets for attackers and raise legitimate privacy concerns. This tutorial demonstrates a different approach; a privacy preserving KYC system built on Stellar’s Soroban smart contract platform that leverages BLS ring signatures to enable users to prove verified attributes without revealing their identity.
Please note that this code is for experimental purposes only. KYC/AML regulations vary across the world, please check with your local regulator as this demo may not meet all requirements.
Understanding The Three Party Architecture
The system orchestrates interactions between three distinct roles, each with specific responsibilities and trust assumptions.
- Administrators bootstrap the system and authorize credential issuers, wielding significant power that should be protected through multi-signature wallets in production deployments.
- Credential issuers verify user identities off-chain through traditional KYC processes, then add verified users to attribute-specific rings stored on the blockchain.
- Users submit verification requests to issuers, receive cryptographic credentials they store locally, and later prove attributes by generating ring signatures that the smart contract verifies without learning the user’s identity.
This architecture deliberately minimizes on-chain storage of sensitive information. The blockchain stores only BLS public keys organized into attribute rings, the list of authorized issuers, the administrator address, and a counter tracking total verification events.
The data flow proceeds through five distinct phases.
- During system setup, the administrator deploys the contract, initializes it with their address, generates BLS keypairs for issuers, registers issuer public keys on-chain, and securely delivers the corresponding private keys to issuers.
- When users request KYC verification, they select an issuer and desired attributes to verify, submit personal information and document photos to the backend, then poll for credential availability.
- Issuers monitor pending requests, review submitted documents, generate unique keypairs for each approved attribute, create rings containing the user’s key plus four decoy keys, register these rings on-chain through smart contract calls, and assemble credentials containing the user’s private keys and their corresponding rings.
- Users retrieve their credentials through a polling mechanism, save them to browser localStorage, and can then prove attributes by loading credentials, generating challenges, signing them with ring signatures, and submitting verification transactions to the blockchain.
- The smart contract validates that signatures satisfy the ring equation without learning which ring member signed, increments a global verification counter, and returns success.
Ring Signatures And The Mathematics Of Anonymity
Ring signatures provide a cryptographic primitive that enables any member of a group to produce signatures that appear identical regardless of which member actually signed. The verifier learns only that someone within the ring signed the message, gaining no information about the specific signer’s identity. This differs fundamentally from group signatures where a group manager can trace signatures back to individual members, or from simple multi-signatures where all members must participate.

The implementation uses the BLS12-381 elliptic curve, which offers 128-bit security with compact key sizes of 96 bytes for public keys and 32 bytes for private keys. These parameters strike a favorable balance between security and storage efficiency for blockchain deployment where every byte impacts transaction costs. The curve supports efficient pairing operations that enable elegant signature schemes, though this implementation uses the discrete logarithm properties rather than pairings.
The signing algorithm constructs a challenge-response chain that forms a cryptographic loop. The signer first generates a random nonce and random response values for each ring member. Starting from the position after their own key in the ring, they compute a series of group elements by combining generator exponentiation with public key exponentiation using the challenge values. Each computed element gets hashed along with the ring’s public keys and message to produce the next challenge value. This chain continues around the entire ring until returning to the signer’s position, where they complete the loop by computing their own response value as a function of their private key, the current challenge, and their initial random nonce. The signature consists of the initial challenge value and all response values, totaling 32 bytes plus 32 bytes per ring member.
Verification reconstructs this exact same chain without knowing which position corresponds to the actual signer. The verifier starts with the provided initial challenge and iterates through the ring, computing the same group elements using the provided response values. If the signature is valid, the chain closes perfectly with the final computed challenge matching the initial challenge. Any tampering breaks the chain and causes verification to fail. Critically, every valid ring member can produce signatures that cause this loop to close, and these signatures are computationally indistinguishable from each other. The verifier gains mathematical certainty that someone in the ring signed the message but zero information about which member it was.
The current implementation uses rings of five members: one real user plus four decoy keys generated during the approval process. This provides an anonymity set where any attempt to identify the signer has only a 20 percent chance of guessing correctly, and no strategy performs better than random guessing. Production deployments should consider larger rings depending on privacy requirements, though larger rings increase signature size and verification costs linearly.
Smart Contract Implementation On Soroban
The Soroban smart contract provides the trust anchor for the entire system, storing authorized issuer keys and attribute rings while exposing functions for ring management and signature verification. Written in 263 lines of Rust, the contract leverages Soroban’s storage tiers and cryptographic primitives to deliver efficient on-chain verification.
Storage architecture utilizes two of Soroban’s three storage types. Instance storage holds the administrator address as a singleton value that persists permanently and costs minimal fees. Persistent storage maintains the issuer list, attribute-to-ring mappings, and the verification counter, with each key managed independently and charged rent based on access patterns. The contract deliberately avoids temporary storage since all state requires long-term persistence. This tiered approach optimizes storage costs while ensuring critical data remains available.
The initialization function establishes the contract’s administrative foundation and can only be called once. It stores the provided administrator address in instance storage and creates an empty issuer list. Any attempt to initialize an already-initialized contract triggers a panic, making the initialization permanent and preventing administrative takeover. This immutability means that changing the administrator requires deploying an entirely new contract instance.
Issuer registration adds BLS public keys to the authorized issuer list after verifying that the transaction is signed by the administrator. The function loads the current issuer list, checks for duplicates to maintain a deduplicated set, and appends the new key if it doesn’t already exist. This deduplication logic prevents accidental double-registration while allowing idempotent calls during setup scripts. The 96-byte public keys get stored as BytesN<96> types that leverage Soroban’s efficient fixed-size byte array handling.
The create_ring_for_attribute function enables issuers to establish or update attribute rings. It takes an attribute symbol like “over_18” along with a vector of public keys, verifies the transaction is signed by the issuer address, and stores the ring under a composite key combining the attribute name with a ring prefix. This storage pattern supports unlimited attributes without contract modifications, as new attributes simply create new storage entries. Calling the function multiple times for the same attribute overwrites the previous ring, enabling ring rotation and membership updates.
Key generation happens through the create_keys function that performs pure computation without state changes. This design choice makes it usable as a simulation-only function that frontend code can call without submitting transactions.
The signing function implements the ring signature algorithm described earlier, taking a message, the complete ring of public keys, the signer’s index within the ring, and the signer’s private key. It returns a RingSignature structure containing the initial challenge and all response values. Like key generation, this function performs pure computation and gets called only in simulation mode to generate signatures off-chain. Users sign challenges locally in their browsers without broadcasting transactions, since signature generation doesn’t require blockchain consensus.
Verification provides the core security guarantee through the verify_attribute function that accepts a message, signature, and attribute name. It loads the ring associated with the specified attribute from persistent storage, returning false immediately if no ring exists for that attribute. With the ring loaded, it calls the internal verify_ring function that implements the challenge-response chain reconstruction described in the mathematics section. The verification iterates through each ring member, computing the same group elements that would have been computed during signing, hashing them to produce successive challenge values, and checking whether the final challenge equals the initial challenge. This equality test determines signature validity with cryptographic certainty. Upon successful verification, the function increments a global LoginCount counter that provides aggregate usage metrics without compromising individual privacy.
The contract exposes several query functions that enable frontends to inspect system state. The get_issuers function returns the complete list of authorized issuer public keys. The get_ring_for_attribute function retrieves the ring associated with a specific attribute, returning an empty vector if the attribute doesn’t exist. The get_login_count function returns the global verification counter. These read-only functions help administrators monitor system health and enable users to verify that rings contain expected numbers of members before using them.
Backend API Design And Ephemeral Storage
The Node.js backend serves as a temporary relay that coordinates communication between users and issuers without becoming a permanent data repository.
The API uses in-memory Map objects rather than databases to enforce data ephemerality. This architectural choice reflects a core privacy principle: the system should minimize data retention and push credential storage to user-controlled environments.
Three Map structures maintain runtime state.
- The kycRequests map stores pending and processed verification requests keyed by request ID, containing the user ID, selected issuer public key, requested attributes, submitted personal information, document photos, current status, and submission timestamp.
- The issuedCredentials map holds approved credentials waiting for user retrieval, keyed by user ID and containing the credential object, associated request ID, and issuance timestamp.
- The registeredIssuers map tracks issuer metadata keyed by issuer ID, storing issuer names, public keys, and registration timestamps. All three maps exist only in process memory and reset completely whenever the server restarts, making data retention strictly tied to server uptime.
The KYC request flow begins when users POST to /api/request-kyc with their user ID, chosen issuer public key, desired attributes, personal information, and a base64-encoded document photo. The endpoint validates that the issuer public key is 192 hexadecimal characters matching BLS public key encoding requirements, stores the request with pending status, and returns a unique request ID. Issuers discover pending requests by polling GET /api/kyc-requests with query parameters filtering by their public key and pending status. This polling happens every five seconds from the issuer dashboard, providing near-real-time request visibility without requiring WebSocket connections.
When issuers approve requests, they POST to /api/approve-kyc with the request ID, their public key, and a complete credential object. This credential contains the issuer’s public key, a mapping of attributes to user private keys, a mapping of attributes to their full rings, and an issuance timestamp. The endpoint validates the request exists and belongs to the specified issuer, updates the request status to approved, and stores the credential in issuedCredentials keyed by the user’s ID. Critically, users retrieve credentials through GET /api/credential/:userId which returns the credential data and then immediately deletes it from the issuedCredentials map. This one-time retrieval pattern enforces that credentials migrate to browser localStorage and cannot be fetched repeatedly, limiting exposure windows for potential interception.
The cleanup endpoint provides operational hygiene by allowing administrators to POST to /api/cleanup with a maximum age parameter. The function iterates through kycRequests and issuedCredentials, removing entries older than the specified cutoff. This prevents unbounded memory growth in long-running deployments while maintaining recent data accessibility. The default maximum age of 24 hours balances operational needs against the principle of minimal data retention.
Statistics and health check endpoints support monitoring. GET /api/health returns a simple status indicator and timestamp for uptime monitoring. GET /api/stats provides aggregate counts including total issuers, total requests, pending requests, approved requests, rejected requests, and pending credentials. These metrics enable dashboard visualizations and capacity planning without exposing individual user data.
Frontend Architecture And User Flows
The React frontend orchestrates five distinct user journeys across purpose built pages that collectively implement the complete KYC lifecycle. Built with Vite for fast development and optimized production builds, the application integrates with Freighter wallet for transaction signing and uses Stellar SDK for contract interactions.

The admin page at /admin handles system initialization and issuer authorization. Administrators connect their Freighter wallet through the StellarWalletsKit integration that supports multiple Stellar wallet types. The initialize contract button calls the contract’s initialize function exactly once to set the admin address, after which that button becomes permanently disabled. The authorize issuer workflow generates BLS keypairs through contract simulation, registers the public key on-chain through a signed transaction, saves issuer metadata to the backend API, and displays the complete credential JSON that the administrator must securely transmit to the issuer. The interface shows a table of authorized issuers retrieved from the backend, providing visibility into system configuration.

Users begin their journey on the verify page at /verify where they select an issuer from a dropdown populated via GET /api/issuers, check boxes for desired attributes like “over 18” or “UK resident”, fill a form with personal information, and capture a document photo using webcam integration. The DocumentCapture component handles browser camera access and converts captured frames to base64 encoding for transmission. Upon submission, the page generates a unique user ID combining timestamp and random characters, posts the complete request to the backend, and begins polling GET /api/credential/:userId every three seconds. A loading animation with periodic status updates keeps users engaged during the typically brief wait for issuer approval. When the polling detects credential availability, the page saves it to localStorage and redirects to the confirmation page after a two-second success message.

Issuers use the dashboard at /issuer to review and approve pending requests. After connecting their Freighter wallet, issuers paste the credentials JSON that administrators provided them, which contains their public and private keys. This unlocks the pending requests panel that polls GET /api/kyc-requests filtered by the issuer’s public key every five seconds. Each pending request displays in a card showing user information and a thumbnail of the submitted document photo. Clicking the thumbnail opens a modal with an enlarged view for detailed inspection. The approve button initiates a multi-step process that generates unique keypairs for each requested attribute, creates rings by generating four decoy keys and combining them with the user’s real key, registers each ring on-chain through separate createRingForAttribute transactions, waits for transaction confirmation using polling with one-second intervals and 20-second timeouts, assembles the complete credential structure mapping attributes to keys and rings, and posts it to the backend. Throughout this process, a loading indicator shows progress through the potentially lengthy transaction confirmation waits.
The transaction confirmation logic deserves special attention since Soroban transaction finality requires polling. After submitting each createRingForAttribute transaction, the code enters a loop that calls server.getTransaction with the transaction hash every second for up to 20 attempts. The loop breaks early if the transaction reaches SUCCESS status, throws an error if it reaches FAILED status, and continues waiting while status remains PENDING or NOT_FOUND. This confirmation wait prevents race conditions where the code might try to include rings in credentials before they finalize on-chain. Without these waits, users could receive credentials referencing rings that don’t yet exist in contract storage, causing verification failures.

Users prove attributes on the confirm page at /confirm after retrieving their credentials. The page loads the credential from localStorage and displays available attributes along with their ring sizes. Users connect their Freighter wallet since proof submission requires signing transactions. Clicking generate challenge creates a unique message combining the attribute name, current timestamp, and random characters. The sign button calls the contract’s signRing function in simulation mode, passing the challenge, the ring for the selected attribute, an index of zero assuming the user’s key appears first, and the user’s private key for that attribute. This returns a RingSignature object containing the challenge hash and response values.
The verify button submits an on-chain transaction calling verify_attribute with the challenge message, the signature, and the attribute name. This requires the user to sign via their wallet, costing a small amount of XLM for transaction fees. The code then polls for transaction completion using similar logic to the issuer’s ring creation waits, checking every second for up to 30 seconds. On successful verification, the contract increments its login counter and returns true, which the UI celebrates with a large success emoji and explanation. The page displays the current login count retrieved from the contract, providing social proof of system usage while maintaining individual privacy through the counter’s aggregate nature.
Nuts & Bolts
The contract utility layer at frontend/src/utils/contract.js encapsulates all Stellar SDK interactions behind simple async functions. It handles the repetitive complexity of converting JavaScript values to Soroban ScVal types, building transactions with proper fees and timeouts, simulating transactions to get resource requirements, assembling transactions with simulation results, requesting wallet signatures, and submitting to the Soroban RPC endpoint. Different parameter types require different conversion approaches: addresses use nativeToScVal with type ‘address’, byte arrays convert from hex strings to Buffer then wrap in ‘bytes’ type, symbols use ‘symbol’ type, and arrays map each element to ScVal then wrap in scvVec. The createKeys and signRing functions demonstrate XDR parsing complexity, navigating nested ScVal structures to extract byte arrays from maps and vectors returned by contract simulations.
The credentials utility at frontend/src/utils/credentials.js provides localStorage persistence with optional AES-GCM encryption. The saveCredential function accepts an optional password and either encrypts the credential JSON using Web Crypto API with PBKDF2 key derivation, 100,000 iterations, and random salts and initialization vectors, or stores it as plain JSON. A flag in localStorage tracks whether encryption was used. The loadCredential function checks this flag and either decrypts or directly parses depending on the saved format. Encryption provides defense against physical device access scenarios though it cannot protect against malware with localStorage access. Production deployments should evaluate whether the added encryption complexity matches their threat model.
Privacy Properties And Trust Assumptions
The system delivers genuine privacy through a combination of cryptographic techniques and architectural choices that minimize data exposure at every layer. The blockchain stores only public keys and aggregate counters, revealing nothing about which individuals possess verified attributes or when specific users prove them. When someone successfully verifies an attribute, observers see only that someone from the ring completed a verification, with the ring signature designed to provide mathematical assurances that even computationally unbounded adversaries cannot identify the specific signer beyond random guessing.
Ring unlinkability prevents correlation attacks where adversaries attempt to connect multiple verifications to the same user. Since each attribute uses a different ring containing different public keys, proving “over 18” and then proving “UK resident” generates completely distinct signatures with no mathematical relationship. An observer monitoring all blockchain activity sees two verification events but cannot determine whether they came from the same user or different users even if timing patterns seem suspicious. This unlinkability extends across time periods, so proving the same attribute repeatedly still generates fresh signatures that cannot be linked together.
The anonymity set size directly determines privacy strength. Current deployments use rings of five members, meaning each verification could belong to any of five people with equal probability from the verifier’s perspective. Larger rings provide stronger privacy but increase signature sizes, verification gas costs, and the logistical complexity of assembling rings with sufficient members. The optimal ring size balances these tradeoffs based on application requirements and expected user populations.
Trust assumptions shape the security model’s boundaries. Administrators have considerable power since they control which issuers can operate in the system. Malicious administrators could authorize compromised issuers or revoke legitimate ones, though they cannot forge ring signatures or extract private keys. Issuers occupy the most trusted position since they verify identities and decide which attributes to grant users. A malicious issuer could add users to incorrect attribute rings, for example marking someone as over 21 when they’re actually 19, or refuse to serve legitimate requests. These social layer attacks fall outside what cryptography can prevent, suggesting that production deployments should implement issuer reputation systems, potentially bond requirements, and mechanisms for users to dispute incorrect attribute assignments.
The smart contract provides trustless verification once rings are properly established. The verification logic uses deterministic mathematics with no discretionary decisions, so no party can cause invalid signatures to verify or valid signatures to fail. This property means users can prove attributes to skeptical verifiers without requiring those verifiers to trust any centralized authority beyond trusting that the rings were constructed honestly during the issuance phase.
Deploying And Extending The System
Developers can deploy this system to Stellar testnet or mainnet following a straightforward workflow. The contract requires building with Rust targeting wasm32-unknown-unknown, then deploying via Soroban CLI with commands that return a contract ID for use in subsequent operations. The initialization step sets the administrator address permanently and must happen before any other operations. Frontend and backend both need environment variables pointing to the deployed contract ID along with network configuration specifying testnet or mainnet endpoints.
Local development works without contract deployment since most functions operate in simulation mode. Developers can start the backend on port 3001 and frontend on port 3000, use mock key generation throughout the UI, and test the complete request-approve-retrieve-prove workflow with backend relay and localStorage integration fully functional. Contract calls will fail gracefully with error messages, but this allows rapid iteration on UI and business logic before dealing with blockchain deployment complexity.
Adding new attributes requires only frontend changes since the contract uses dynamic symbol keys for attribute-to-ring mappings. Developers add entries to the AVAILABLE_ATTRIBUTES array in the verify page, and users can immediately select the new attribute during KYC requests. When issuers approve requests containing new attributes, they create rings using the new attribute names, and the contract stores them like any other attribute. This extensibility supports domain-specific use cases like professional certifications, income brackets, or geographic regions without contract modifications.
Production hardening involves several security enhancements beyond the reference implementation. Credential encryption should become mandatory rather than optional, protecting keys stored in browser localStorage against physical device access. The backend should migrate from in-memory Maps to proper databases with encryption at rest and strong access controls. Ring size should increase to expand anonymity sets, potentially making ring sizes configurable per attribute based on privacy sensitivity. Multi-signature schemes could protect critical admin operations like issuer authorization. Rate limiting prevents spam attacks against the request submission endpoints. Audit logging provides forensic capability for investigating suspicious patterns while maintaining individual privacy.
Takeaways For Stellar Developers
Building privacy preserving identity systems on Stellar demonstrates the platform’s capability to support complex cryptographic protocols beyond simple asset transfers.
The combination of Soroban’s storage flexibility, native cryptographic primitives, and efficient transaction model enables sophisticated applications that would require significant infrastructure on other blockchains.
- Ring signatures provide mathematical privacy guarantees that social layer controls cannot match, making them essential for applications where anonymity requirements are genuine rather than cosmetic
- Soroban’s simulation-only functions enable complex off-chain computations that later get verified on-chain, minimizing transaction costs while maintaining security properties
- Transaction confirmation polling is essential when subsequent operations depend on finalized state, requiring careful timeout and retry logic to handle network delays gracefully
- Ephemeral backend storage combined with one-time credential retrieval enforces client-side data custody, distributing privacy risks across many user controlled environments rather than centralizing them
Stellar’s smart contract capabilities lower the barrier to deploying privacy preserving systems into production environments where they can deliver real improvements over traditional centralized identity approaches.


