DEV Community

Rubem Vasconcelos
Rubem Vasconcelos

Posted on

Building Authentication Without Collecting Any Personal Data

What If Your Auth System Collected Zero Personal Data?

Traditional authentication has a common pattern: it requires you to collect personal information before you can verify identity. Email. Phone number. Password. All of it ends up in a database, and databases get breached.

But there's a different model — one borrowed from cryptocurrency wallets — where the server stores nothing that can be traced back to a real person. Let's build it.

The Core Idea

Instead of email + password, the user gets a 12-word seed phrase. From that phrase, we derive a cryptographic key pair. The public key becomes their anonymous identity on the server. The private key and the raw mnemonic never leave the browser.

Login doesn't ask for the full phrase either. It challenges the user to provide 3 randomly chosen words — which are hashed client-side before comparison. The server only ever sees hashes.

Here's what the server ends up storing:

{
  "username": "a1b2c3d4",
  "publicKey": "8f7a9b2c1d3e4f5a...",
  "mnemonicHashes": [
    "e3b0c44298fc1c14...",
    "d7a8fbb307d78094...",
    "..."
  ]
}
Enter fullscreen mode Exit fullscreen mode

No email. No password. No name. Nothing linkable to a person.


Step 1: Generating the Seed Phrase (BIP-39)

BIP-39 is the standard used by hardware wallets like Ledger and Trezor. It maps 128 bits of random entropy to 12 words from a fixed 2048-word English dictionary. Each word encodes roughly 11 bits of information — enough that the full phrase has ~128 bits of security.

import { generateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english.js';

// 128 bits of entropy → 12 words
const mnemonic = generateMnemonic(wordlist, 128);
// "witch collapse practice feed shame open despair creek road again ice least"
Enter fullscreen mode Exit fullscreen mode

The @scure/bip39 library is audited and has zero dependencies — important for anything touching key material.


Step 2: Deriving the Ed25519 Key Pair

Ed25519 is a fast, secure elliptic-curve signature scheme. A 32-byte private key deterministically produces a 32-byte public key. We derive the private key from the mnemonic using HMAC-SHA-256 with a domain-separation tag — this ensures the same mnemonic produces different keys for different applications.

import { mnemonicToSeedSync } from '@scure/bip39';
import * as ed from '@noble/ed25519';
import { hmac } from '@noble/hashes/hmac';
import { sha256 } from '@noble/hashes/sha2';

async function deriveKeyPair(mnemonic: string) {
  // BIP-39: mnemonic → 64-byte seed
  const seed = mnemonicToSeedSync(mnemonic);

  // HMAC with a domain tag — same seed, different keys per app
  const derived = hmac(
    sha256,
    new TextEncoder().encode('your-app-name-ed25519'),
    seed
  );

  const privateKey = derived.slice(0, 32);
  const publicKey = await ed.getPublicKeyAsync(privateKey);

  return {
    publicKey: bytesToHex(publicKey),   // goes to the server
    privateKey: bytesToHex(privateKey), // stays in the browser, forever
  };
}
Enter fullscreen mode Exit fullscreen mode

The domain tag ('your-app-name-ed25519') is what prevents key reuse across different systems using the same BIP-39 seed.


Step 3: Hashing the Mnemonic Words

Before any data leaves the browser, each of the 12 words is hashed individually using SHA-256 via the native Web Crypto API. The server stores these hashes — never the words themselves.

async function hashWord(word: string): Promise<string> {
  const data = new TextEncoder().encode(word.trim().toLowerCase());
  const buffer = await crypto.subtle.digest('SHA-256', data);
  return Array.from(new Uint8Array(buffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
}

// Hash all 12 words in parallel
const mnemonicHashes = await Promise.all(
  mnemonic.split(' ').map(hashWord)
);
Enter fullscreen mode Exit fullscreen mode

A note on salting: BIP-39 words are common English words, making them vulnerable to precomputed rainbow tables in theory. For production, you'd want to salt each hash with something unique per user (e.g., their public key). This demo keeps it simple for clarity.


Step 4: Registration

The client sends three things to the server — and none of them are personally identifiable:

await register({
  username: someAnonymousId,   // could be anything — a hash, a UUID
  publicKey: keyPair.publicKey,
  mnemonicHashes,
});
Enter fullscreen mode Exit fullscreen mode

That's the entire registration payload. The server has no way to contact this user, no way to identify them in the real world, and nothing useful to hand over in a breach.


Step 5: The Login Challenge

This is the most interesting part. Login doesn't ask for the full mnemonic — it picks 3 random positions and asks the user to type only those words.

// Pick 3 unique random positions from 0–11
function pickRandomIndices(count: number, total: number): number[] {
  const indices = new Set<number>();
  while (indices.size < count) {
    indices.add(Math.floor(Math.random() * total));
  }
  return [...indices].sort((a, b) => a - b);
}

const challengeIndices = pickRandomIndices(3, 12);
// e.g. [1, 6, 10] → "Enter words #2, #7, and #11"
Enter fullscreen mode Exit fullscreen mode

The user types those 3 words. Each one is hashed client-side, then compared against the stored hash at that position:

const isAuthenticated = await Promise.all(
  challengeIndices.map(async (idx, i) => {
    const hash = await hashWord(userInput[i]);
    return hash === storedHashes[idx];
  })
).then(results => results.every(Boolean));

if (isAuthenticated) {
  saveSession(publicKey);
}
Enter fullscreen mode Exit fullscreen mode

The full mnemonic is never transmitted. The server never sees plaintext words. Only hashes travel over the wire — and only 3 of the 12, at random positions that change every login.


What About Sessions?

Sessions store the minimum possible state: the public key and an expiry timestamp.

interface SessionData {
  publicKey: string;
  expiresAt: number;
}

function saveSession(publicKey: string): void {
  const session: SessionData = {
    publicKey,
    expiresAt: Date.now() + 2 * 24 * 60 * 60 * 1000, // 2 days
  };
  storage.set('session', session);
}
Enter fullscreen mode Exit fullscreen mode

No JWT with an email claim. No cookie with a user ID tied to a real person. Just a public key reference.


The Trade-offs (Honestly)

This architecture makes some things genuinely better and some things genuinely harder.

Better:

  • A database breach exposes nothing usable — public keys and hashes only
  • No "forgot password" attack surface exists, because there's no password
  • No email = no phishing vector tied to this account
  • The user owns their identity completely

Harder:

  • Losing the seed phrase means losing the account. Permanently. No recovery.
  • Users unfamiliar with seed phrases need onboarding. This is real UX work.

The Libraries

Everything cryptographic uses audited, zero-dependency implementations from Paul Miller:

Library Role
@scure/bip39 Mnemonic generation from entropy
@noble/ed25519 Key pair derivation and signing
@noble/hashes SHA-256, HMAC for key derivation
Web Crypto API SHA-256 word hashing (built-in)

Audited libraries matter here. Key derivation code is security-critical and not the place for unreviewed dependencies.


Conclusion

The shift this pattern forces is conceptual more than technical: instead of asking "how do we protect our users' data?", you ask "what if we simply didn't have it?".

The cryptographic primitives — BIP-39, Ed25519, SHA-256 — are battle-tested and well-understood. The challenge is UX: helping users understand that their 12 words are their account, and that means treating them with the same care as a physical key.

For the right use case — privacy tools, pseudonymous platforms, self-sovereign applications — this model removes an entire category of risk from the threat surface.

Full open source demo: GitHub link

Flow gif

Top comments (0)