To create an account, I almost always hand over an email address. A code lands in my inbox, I copy it back, the service displays “email verified”. Later, I sign in with that email and a password; the day I forget it, a reset link goes back to the same inbox. My whole identity rests on a single question: can I read this mailbox?

That question proves little. It establishes that I control a mailbox, and not that I am a real person. It also has a cost: the service stores my address, can write to me, can link me from one service to another, and can expose that address the day it is breached.

This article presents an architecture that asks a different question. Instead of “do you control this mailbox?”, it asks “are you a real person, holding a valid identity document?”, and it answers without reading the name or the date of birth. The mechanism is the same throughout: a verifiable presentation (Verifiable Presentation), where a wallet shows a signed proof and demonstrates that it holds the matching key. Everything happens with no email address, no verification code, and nothing of the sort kept.

On the left, the email-based account chains an address, a verification code received by email and a password; on the right, the emailless account chains proof of personhood, an account in the wallet, then a passkey or a recovery code

The email-based account chains three rituals: an address, a code received by email, a password. The emailless account answers with a PID presentation at sign-up, an account lodged in the wallet, then a passkey or a recovery code to get back in.

What the code received by email proves

The ritual is familiar. The service generates a code, sends it to the address, and waits for me to copy it back. Succeeding proves one thing: at this instant, I can read this mailbox. The address then becomes the account anchor, the identifier that follows me, the reset channel, and the key that links me across services.

That anchor is fragile. A mailbox is shared, hacked, lost. And since almost everything hangs on it, losing access to it, a suspended account, a breach, or the platform simply closing it, means being shut out of almost your entire digital life at once. It also ties my accounts together: the same address, presented to ten services, draws a common trail. And it forces the service to keep a piece of personal data it does not really need in order to know it is dealing with a person.

This approach shifts the proof. It replaces “I control this address” with “I am a person, and here is what proves it”.

Proving a person, without revealing their identity

The foundation is the PID (Person Identification Data), the digital identity document of the European EUDI wallet, signed by the State. At sign-up, the wallet makes a verifiable presentation of it: it proves a real person, without revealing their identity. How these presentations work I covered in a talk at DevLille.

What remains is to tie that person to one account, and only one. That is the job of a value derived from the PID, the nullifier. The PID carries a per-person secret seed (seed); the service computes N = HMAC(seed, scope), where the scope is a stable identifier of the service. The result is a pseudonym: deterministic for a given person on this service, and different from one service to another.

The PID keeps its identity attributes hidden and reveals only its seed; the service derives N = HMAC(seed, scope), a pseudonym that anchors one account per person

The PID identity attributes stay hidden. The service reads only the seed, from which it derives the nullifier N = HMAC(seed, scope): a per-person pseudonym that anchors a single account.

This pseudonym does all the work, without ever identifying anyone. The same person always lands on the same N, hence on the same account: opening a thousand accounts for one person is impossible (so-called Sybil resistance), and re-presenting their PID is enough to find their account again (recovery, below). Since the scope changes from one service to another, the pseudonym seen here does not line up with the one elsewhere.

An account no one holds on your behalf

Once eligibility is proven, the service issues the account itself. It relies on no third-party identity provider: it builds a Verifiable Credential of its own, the AccountCredential, and hands it to the wallet.

This credential is, again, an SD-JWT VC. Its subject, account_subject, is exactly the nullifier N computed above, and it is bound to the wallet key through a cnf block (holder binding): only the holder of that key will be able to present it later.

{
  "iss": "https://account.example",
  "sub": "Zk9P2x7nQ1cE...",
  "vct": "https://account.example/credentials/account",
  "cnf": {
    "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
  },
  "iat": 1783584000,
  "exp": 1815120000
}

At issuance, the wallet proves that it does hold the key it claims, then receives the credential. The detail of the exchanges matters little here; the point that counts is what the service keeps of all this.

On the server side, the account anchor is the hash of account_subject, and not its cleartext value. That hash is what points to an internal user_id. In total, the service keeps three items.

{
  "subject_hash": "sha256:1f6e...",
  "user_id": "b1c0e7...",
  "holder_thumbprint": "sha256:Nzb..."
}

It keeps no email address, no password, no wallet DID, not a single PID attribute, and not the seed the nullifier is derived from. The account identity rests on two things only the holder can bring together: a credential kept in their wallet, and the private key that goes with it.

The wallet key enters the account through the cnf block; account_subject, which is the nullifier N, is hashed into an internal user_id; email, wallet DID and PID attributes are never stored

The account is bound to the wallet key through the cnf block. On the server side, account_subject (the nullifier N) is hashed into an internal user_id. The email, the wallet DID and the PID attributes are never stored.

Signing in by presenting your account card

Signing in then comes down to presenting that credential. The wallet shows the AccountCredential along with a fresh proof of possession of the key, and the service checks the same chain as at sign-up: its own signature on the credential, then the proof that the wallet key matches the cnf block. It extracts account_subject, hashes it, finds the user_id, and opens the session. The session opens this way with no password traveling, and no code to copy back.

One can also consider signing in with a passkey. Once signed in, the holder registers a passkey (WebAuthn) on their device: the private key stays in the device, the server keeps only the public key, bound to the user_id. Presenting it reopens the session directly, with no username to type. The wallet credential stays the portable identity; the passkey is a second, faster path, tied to one device.

At sign-up, the PID proof and the account issuance follow one another in a single run: the user scans once, and the rest unfolds in the wallet. The detail of the exchanges between the wallet and the service is out of scope for this article too (see the talk mentioned above).

Keeping access without email

The strength of the nullifier shows on the day you lose everything. N depends neither on the device nor on the wallet key: it follows the person. Re-obtain your PID from the State on a fresh phone, present it again, and the service recomputes the same N, finds the same account, and re-issues an AccountCredential to the new key. Recovery thus happens with no secret kept in advance.

A last-resort net remains, to reopen a session when neither the wallet nor the passkey is at hand. At sign-up, the service hands out once a list of single-use recovery codes, a pattern common in the industry: GitHub, Google and many others offer them as a two-factor fallback. Eight codes in the ABCDE-FGHJK format, drawn from an alphabet that excludes ambiguous characters (the letters I and O, the digits 0 and 1). The server keeps only a hash of them, salted with a pepper. Presenting a valid code opens a session, and the code is immediately consumed: it serves only once.

ABCDE-FGHJK
LMNPQ-RSTUV
WXYZ2-3456A
...

Limitations

Two substantive limitations come down to the maturity of the ecosystem, and a third is a deliberate trade-off.

  • The PID comes from a separate test issuing service playing a fake “State”. The service only verifies it here, pinning that issuer’s public key. In production, trust would rest on the real roots of the EUDI ecosystem, and on real revocation.
  • The account is anchored on a nullifier N = HMAC(seed, scope), derived from a per-person seed that the standard EUDI PID does not carry yet (we add it to the test PID). Above all, without a zero-knowledge proof, that seed is sent in clear to the service: services that see it could link the same person, despite distinct nullifiers. Full unlinkability calls for a nullifier proven in zero-knowledge (BBS#, for instance), an approach proposed in the EUDI wallet ARF, not yet validated.
  • Doing without email also removes the channel to reach the user: email notifications, security alerts and reminders are gone. Unlike the previous two, this limitation does not come from the ecosystem’s maturity; it is a fundamental trade-off, and contacting the user would require a separate, explicitly opted-in channel (which is not always a downside: the user keeps the choice of giving an address, or not).

I tested the flow end to end with a real wallet (Altme, DIIP profile); the mechanism works.

Conclusion

We turned email into an identity document, and that is an anomaly. To create an account today, you are asked for an email address, that is, an account at Google or Apple: an account the user does not truly own, which becomes the key to their whole digital life, and their first point of compromise. The dependency has become so ordinary that it goes unnoticed.

The approach explored here takes another path. The user proves once that they are a real person, through their identity wallet (the future EUDI wallet). From that proof a nullifier is derived, a deterministic fingerprint, specific to this person and this service. A single object then fills three roles:

  • sign-up;
  • resistance to fake-account creation (Sybil resistance), for services that want it;
  • recovery after total loss: re-presenting the proof is enough for the service to recognize its holder, with no email, no password and no stored secret, from a brand-new device.

On the server side, there is no Google address involved, and no password database to steal.

Is it production-ready? No. The EUDI PID does not carry the needed attribute yet, and the zero-knowledge piece that would seal it all is still at the discussion stage in Brussels.

Source code:

  • wallet-login: the POC described here (signup, login, recovery).
  • pid-issuer: the fake “State” issuer that delivers the test PID, provided so the whole flow can be replayed end to end.