0xShikhar
Writing

The ZK Identity Trilemma: Privacy, Verifiability, and Machine-Readability

· 13 min read· IdentityShare on X ↗

The standard way to explain zero-knowledge proofs is the cave analogy. You prove you know the secret passage without revealing where it is. Clean, intuitive, memorable.

The problem with that analogy is it makes ZK sound like a solved primitive. Apply to identity, done. In practice, building a real identity system with ZK is less like demonstrating a cave proof and more like drawing a cave map a thousand different people can read, without revealing the cave to any of them, at web latency.

I spent most of a year on this problem with NebulaID. This is what I actually learned.

The three things you need#

Let me pin down the trilemma, because "privacy" and "identity" mean different things to different people.

Privacy: a credential holder should prove attributes without revealing the underlying data. Prove you're over 18 without showing your birthdate. Prove you're KYC-cleared without sharing your passport. Prove a credit score above 700 without giving the number. The fact is provable, the raw data is not reachable.

Verifiability: anyone receiving a credential presentation can verify it without calling the issuer, without a centralized registry, without trusting the presenter. Cryptographically binding, unforgeable without the issuer's private key.

Machine-readability: the whole issuance, presentation, verification flow should run without human interpretation. An AI agent should be able to receive a credential, verify it, extract attributes, and act on them in milliseconds. No human reads it. No human clicks approve.

That third one is what most systems drop. It's also the one that matters most if the next five years of the internet involves agents making autonomous decisions.

Why existing systems get two of three#

Traditional KYC (Jumio and friends): verifiable, machine-readable, not private. Your KYC provider holds everything. Prove you're KYC'd to five DeFi protocols and either all five get API access to your provider, or you redo KYC five times. There's no credential you hold and present privately.

Self-sovereign identity (Veramo, SpruceID): private, verifiable, not machine-readable at the speed we need. DIDs and Verifiable Credentials are well-designed standards. The problem is the verification path: DID resolution is a network call, signature verification is CPU, and often there's a revocation check on top. Under 500ms is doable. Under 50ms for a high-traffic service is hard. For an agent making 100 decisions a second, it's prohibitive.

On-chain identity (ENS, Lens): machine-readable, verifiable, zero privacy. Everything is public and queryable. Great for social identity where you want public reputation. Terrible for selective disclosure.

How NebulaID is built#

The bet: you get all three if you split the layers correctly.

  1. Issuance layer (off-chain): trusted issuers (KYC providers, credential authorities) issue ZK-friendly credentials. Not standard VCs. They're structured to be provable inside ZK circuits efficiently.

  2. Commitment layer (on-chain): only a commitment hash goes on-chain, committing to the credential's attributes and expiry without revealing them. The chain stores keccak256(attributes || nullifier || expiry).

  3. Presentation layer (client-side): the holder generates a ZK proof locally. The proof says "I hold a credential from issuer X that commits to the hash at position N in the Merkle tree, and that credential satisfies attribute A > V". What it reveals: which Merkle root, that the condition holds, and a nullifier preventing double-use. Nothing else.

  4. Verification layer (on-chain or off): anyone verifies the proof in a few hundred ms. The verifier learns the credential came from issuer X, the attribute holds, it hasn't been used in this context before. Nothing about the underlying data.

Concrete example. A DeFi protocol needs to check you're an accredited investor before letting you into a restricted pool. Traditional approach: upload documents, wait 2-3 days, protocol now holds your passport.

With NebulaID: you generate a proof that says "I hold a credential from accredited-issuer.eth committing to accredited: true, jurisdiction: US, and that commitment sits in the current credential tree". Submit it with your transaction, the contract verifies it in ~150k gas. The protocol learns you're accredited. Nothing else.

The machine-readability part specifically#

This is where NebulaID differs from most ZK identity work, which is still aimed at humans.

Agents consume identity differently:

  • Volume: an agent might verify 50 credentials a second for a high-throughput service. Wallets, popups, confirmations are useless here.
  • No wallet UX: agents have keys, period. Any flow assuming a browser wallet UI is unavailable to them.
  • Composability: an agent might need "accredited investor" AND "not on sanctions list" AND "KYC done within 12 months" in one check.
  • Response format: verification has to return structured data software can act on, not "Verification successful".

The agent-facing API is just an 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 my testing: ~180ms for three concurrent proofs. Borderline acceptable. The target is sub-100ms, which needs batched circuit verification. Still working on it.

The circuit tradeoffs#

We use Groth16 for credential proofs. Constant-size proofs (~128 bytes), fast verification, which is why it won over PLONK or STARKs. The downside: Groth16 needs a trusted setup per circuit. Change the circuit (new attributes, new credential types) and you need a new ceremony.

For a credential system that evolves, that's friction. The mitigation: forward-compatible schema design. Attributes are a key-value map with a versioned schema hash, so adding attributes usually doesn't force a new circuit.

The nullifier design deserves its own note. The nullifier is derived from the credential, unique per credential and per context. Same credential, two different contexts, different nullifiers. Which means:

  • You can reuse one credential across applications (no separate KYC per DeFi protocol)
  • Uses are unlinkable across contexts (nobody sees that the credential in context A was also used in context B)
  • Double-use within one context is detectable (no replaying the same proof)
nullifier = hash(credential_secret || context_id)

Simple, but context_id scoping is delicate. Too broad (say "defi" for all protocols) and protocols can link your usage. Too narrow (a specific tx hash) and the nullifier is one-time globally. The right scope is application-dependent, so context_id is specified by the verifier, not the prover.

Where it still falls short#

It's a trilemma because something has to give. Open problems:

Credential freshness. A ZK proof attests to a credential issued at some point in the past. KYC credentials are valid ~a year, but circumstances change within that year. Someone gets sanctioned, a fraud signal appears. The credential is still cryptographically valid. How do you revoke?

We use a revocation tree: a Merkle tree of revoked commitments, updated periodically. Provers include a non-membership proof ("my commitment is not in the revocation tree"). That adds ~50ms to proof generation and ~30k gas to verification. Works, but proofs go stale if the tree updates after generation, so we run 10-minute proof windows. Fine for human-paced transactions. Tight for agents.

Issuer trust. NebulaID's privacy properties rest entirely on trusting issuers. A fraudulent KYC issuer mints fraudulent credentials and the system can't detect it. We're building issuer reputation scoring (issuance volume vs dispute rate on-chain) but it's imperfect. Honest answer: ZK identity shifts the trust question, it doesn't eliminate it. You trust issuers instead of platforms.

Mobile proving is slow. A Groth16 proof on a moderate circuit (~30k constraints) takes 2-4 seconds on a laptop, 8-15 on a mid-range phone. Fifteen seconds after tapping "approve" feels broken. We're looking at background proof generation with caching and WASM optimizations. The zkEmail team has made good progress here and I'm watching their approach.

What this space actually needs#

After a year in it, my honest view:

The ZK primitives are ready. Groth16, PLONK, recursion, 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 hide proof generation from non-cryptographers.

ZK identity needs what HTTPS needed: a handful of well-funded orgs to establish root trust infrastructure, then an ecosystem on top. We're at the SSL certificate stage, not the HTTPS-everywhere stage.

Agent identity is an even earlier subfield. Most of the work is happening in closed R&D labs, not open standards bodies, which means fragmentation is coming. I'd like UACP's DID approach and NebulaID's credential work to end up inside whatever open standard wins. But honestly, it's still unsettled.


NebulaID: nebulaid.io | ZK identity discussion happens publicly on X @NebulaIDxyz

↑ back to top
The ZK Identity Trilemma: Privacy, Verifiability, and Machine-Readability — Shikhar Singh — Shikhar Singh (@0xshikhar)