The ZK Identity Trilemma: Privacy, Verifiability, and Machine-Readability
The ZK Identity Trilemma: Privacy, Verifiability, and Machine-Readability
The standard way to explain zero-knowledge proofs to someone is to use the cave analogy. You prove you know the secret passage without revealing where it is. Clean, intuitive, memorable.
The problem with this analogy is that it makes ZK sound like a solved primitive — just apply it to identity and you're done. In practice, building a real identity system with ZK is less like demonstrating a cave proof and more like trying to build a cave map that a thousand different people can read, without revealing the cave to any of them, at web-scale latency.
I've spent the better part of a year on this problem with NebulaID. Here's what I actually learned.
The Three Things You Actually Need
Let me define the trilemma more precisely, because "privacy" and "identity" are words that mean different things to different people.
Privacy: A credential holder should be able to prove attributes about themselves without revealing the underlying data. Prove you're over 18 without giving anyone your birthdate. Prove you're a KYC-cleared investor without sharing your passport. Prove you have a credit score above 700 without disclosing the actual number. The specific fact should be provable; the raw data should not be reachable.
Verifiability: Any party receiving a credential presentation should be able to verify it without having to contact the issuer, without a centralized registry, and without trusting the presenter's word. The verification should be cryptographically binding — impossible to forge without compromising the issuer's private key.
Machine-readability: The entire issuance, presentation, and verification flow should be executable by software systems without human interpretation. An AI agent should be able to receive a credential presentation, verify it, extract the relevant attributes, and act on them — in milliseconds, not minutes. No human reads the credential. No human clicks "approve."
That third one is what most identity systems drop. And it's the one that matters most if you believe the next five years of the internet involves AI agents making decisions autonomously.
Why Existing Systems Get Two of Three
Traditional KYC (Verify.com, Jumio, etc.): Verifiable, machine-readable, not private. Your KYC provider holds all your data. They verify it and issue a token. The token is machine-readable. But if you want to prove you're KYC'd to five different DeFi protocols, you either give all five access to your KYC provider's API (they all see your data) or you re-do KYC five times. There's no credential you hold and present privately.
Self-Sovereign Identity (Veramo, SpruceID, etc.): Private, verifiable, not machine-readable at the speed we need. DIDs and Verifiable Credentials are well-designed standards. The verification story is solid. The problem: VC verification involves DID resolution (network call), signature verification (CPU), and often a check against a revocation list (another network call). Under 500ms is achievable. Under 50ms for a high-traffic service is hard. For an AI agent making 100 decisions per second, this is prohibitive.
On-chain identity (ENS, Lens, etc.): Machine-readable, often verifiable, not private. Everything is public. Your ENS name, your Lens profile, your transaction history — all queryable by anyone. These systems are great for social identity where you want public reputation. They're terrible for identity where you want selective disclosure.
The Architecture Behind NebulaID
NebulaID's core bet is that you can get all three if you separate the layers correctly:
-
Issuance layer (off-chain): Trusted issuers (KYC providers, credential authorities) issue ZK-friendly credentials. These are not standard VCs — they're structured specifically to be provable in ZK circuits efficiently.
-
Commitment layer (on-chain): Only a commitment hash is written to the chain. The hash commits to the credential's attributes and an expiry without revealing them. The chain stores:
keccak256(attributes || nullifier || expiry). -
Presentation layer (client-side): The credential holder generates a ZK proof locally. The proof says: "I have a credential from issuer X that commits to the hash at position N in the Merkle tree, and that credential includes the attribute A > V." The proof reveals: which Merkle root (public), that the attribute condition holds (public), and a nullifier that prevents double-use. Nothing else.
-
Verification layer (on-chain or off-chain): Anyone can verify the ZK proof. It's a 200-400ms operation. The verifier learns: the credential was issued by issuer X, the attribute condition holds, the credential hasn't been used in this context before. Nothing about the underlying data.
Let me make this concrete with an example.
A DeFi protocol needs to verify you're an accredited investor before letting you into a restricted pool. Traditional approach: you upload documents to the protocol, they do KYC, 2-3 days, protocol now holds your data.
With NebulaID: you generate a ZK proof that says "I have a credential from accredited-issuer.eth that committed to accredited: true, jurisdiction: US and that commitment is in the current credential tree." You submit this proof with your transaction. The smart contract verifies it in ~150k gas. Done. The protocol learns you're accredited. Nothing else.
The Machine-Readability Problem, Specifically
This is where NebulaID differs from most ZK identity work, which is still aimed at human users.
AI agents have different identity consumption patterns than humans:
- Volume: An agent might verify 50 credentials per second for a high-throughput service. Human-optimized flows (wallets, popups, confirmations) are useless here.
- No wallet UX: Agents don't have browser extension wallets. They have keys, period. Any flow that assumes a wallet UI is unavailable to an agent.
- Composability: An agent might need to verify three credentials simultaneously — "accredited investor" + "not on sanctions list" + "KYC completed in last 12 months" — and only proceed if all three hold. This needs to be expressible in a single verification call.
- Response format: The verification response needs to be structured data that software can act on, not a human-readable message like "Verification successful."
NebulaID's agent-facing API is a simple HTTP endpoint:
POST /verify
{
"proofs": [<proof_1>, <proof_2>, <proof_3>],
"requirements": [
{ "issuer": "kyc.nebulaid.eth", "attribute": "kyc_cleared", "value": true },
{ "issuer": "sanctions.nebulaid.eth", "attribute": "sanctioned", "value": false },
{ "issuer": "kyc.nebulaid.eth", "attribute": "kyc_date", "min": "2025-01-01" }
],
"context": "defi-pool-access-01"
}
→ 200 OK
{
"verified": true,
"attributes": { "kyc_cleared": true, "sanctioned": false, "kyc_fresh": true },
"nullifiers": ["0xabc...", "0xdef...", "0x123..."],
"expires": 1748000000
}
Verification latency in testing: ~180ms for three concurrent proofs. That's borderline acceptable. The target is sub-100ms, which requires batching circuit verification — we're working on it.
The Circuit Design Tradeoffs
I want to talk about the ZK circuit choices because this is where the identity-specific tradeoffs live.
We use a Groth16 circuit for credential proofs. Groth16 has constant-size proofs (~128 bytes) and fast verification, which is why we chose it over PLONK or STARKs. The downside: Groth16 requires a trusted setup per circuit. If the circuit needs to change (new attributes, new credential types), you need a new trusted setup ceremony.
For a credential system that evolves, this is friction. We mitigate it by designing the credential schema to be forward-compatible: attributes are stored as a key-value map with a versioned schema hash, so adding new attributes doesn't require a new circuit in most cases.
The nullifier design is worth explaining separately. The nullifier is a value derived from the credential that's unique per credential and per context. Using the same credential for two different contexts produces different nullifiers. This means:
- You can use the same underlying credential for multiple applications (good — you don't need separate KYC for each DeFi protocol)
- Each use is unlinkable across contexts (good — no one can see that "credential at position N" was also used in context B)
- Double-use within a single context is detectable (good — prevents replaying the same proof)
Implementation:
nullifier = hash(credential_secret || context_id)
Simple, but the context_id needs to be carefully scoped. Too broad (e.g., "defi" as the context for all DeFi protocols) and different protocols can link your usage. Too narrow (e.g., specific transaction hash) and the nullifier is only usable once globally. The right scope depends on the application, which means context_id is a parameter the verifier specifies, not the prover.
Where It Still Falls Short
I said this is a trilemma because something has to give. Here's what we're still struggling with:
Credential freshness. A ZK proof attests to a credential that was issued at some point in the past. KYC credentials are typically valid for one year. But within that year, circumstances can change: someone can become sanctioned, a fraud signal can emerge. The credential is still cryptographically valid. How do you revoke it?
We use a revocation tree: a Merkle tree of revoked credential commitments, updated periodically. Provers must include a non-membership proof ("my commitment is not in the revocation tree"). This adds ~50ms to proof generation and ~30k gas to on-chain verification. It works, but it means proofs become stale if the revocation tree updates after the proof was generated. We use 10-minute proof windows. Fine for human-paced transactions. Tight for high-frequency agent interactions.
The issuer trust problem. NebulaID's privacy properties depend entirely on trusting the credential issuers. If a KYC issuer issues fraudulent credentials, the system can't detect it. We're working on issuer reputation scoring — an on-chain record of an issuer's credential issuance volume vs. dispute rate — but it's imperfect. The honest answer is: ZK identity shifts the trust question, it doesn't eliminate it. You're trusting issuers instead of platforms.
Client-side proof generation is slow on mobile. Generating a Groth16 proof for a moderate circuit (30k constraints) takes 2-4 seconds on a modern laptop, 8-15 seconds on a mid-range mobile device. For a user tapping "approve" on a DeFi transaction, 15 seconds feels broken. We're exploring proof aggregation (generate proofs in the background and cache them) and WASM-optimized proof generation. The zkEmail team has made good progress on this; we're watching their approach closely.
What Good Identity Infrastructure Actually Needs
After a year in this space, here's my honest view:
The ZK primitives are ready. Groth16, PLONK, recursive proving, folding schemes — the cryptographic toolbox is good enough. What's not ready is the layer above: standardized credential schemas, trusted issuer registries, revocation infrastructure, client libraries that abstract proof generation into something a non-cryptographer can use.
NebulaID is trying to build some of that layer. But the honest answer is that ZK identity needs the same thing that HTTPS needed: a handful of well-funded organizations to establish the root trust infrastructure, and then an ecosystem of applications to build on top. We're at the SSL certificate stage, not the HTTPS-everywhere stage.
The machine-readable identity problem is a subfield even more nascent than that. Most of the work on AI agent identity is happening in closed R&D at labs, not in open standards bodies. That's going to create fragmentation. I hope UACP's use of DIDs for agent identity and NebulaID's approach to credential issuance end up being part of the open standard that wins, but the honest answer is: this is still unsettled.
NebulaID: nebulaid.io | ZK identity discussions happen publicly on X @NebulaIDxyz