NoobProMax

How it works

How end-to-end encryption works in a browser app

Where the key lives is the entire design. Everything else is detail.

9 min read

A web app can be built so that its own servers cannot read what users put into it. Not “will not”, as a policy — cannot, as a consequence of where the key is. The mechanism is not complicated, and every browser has shipped the necessary primitives for years.

What is complicated is being honest about the limits, because browser-delivered end-to-end encryption protects against a narrower set of threats than the phrase suggests. This guide covers both halves: how it works, and what it genuinely does not do.

Start with the threat model

“Encrypted” is not a property, it is an answer to “against whom”. Before any design decision, write down what you are defending against.

For content in a collaborative editor, the realistic list is:

  • A database breach. Someone obtains a dump of stored content. This is the single most likely bad outcome for most services.
  • The operator. Whoever runs the service, including a careless employee, an over-broad debugging session, or a compromised admin account.
  • Legal compulsion. A demand for stored data, which can only be satisfied with what the operator can actually decrypt.
  • Infrastructure below you. The hosting provider, the managed database, their backups.

Notably absent: a network attacker. TLS already handles data in transit. The gap end-to-end encryption closes is data at rest and data passing through a system you are asking people to trust.

The one decision that matters: where the key lives

Everything else is implementation. If the server ever holds the key — even briefly, even without storing it — the design is not end-to-end, because a compromised or compelled server can retain what passed through it.

So the key must be generated on the client and never transmitted. Which leaves the question of how the second person gets it.

The answer used by most link-based systems is the URL fragment — everything after the #:

https://example.com/room/a7f3c9#k=Zm9vYmFyYmF6cXV4...
└──────── sent to server ────────┘└─ never sent ─┘

The fragment is defined as a client-side reference. Browsers do not include it in the HTTP request line, and this is not a convention or a best-effort behaviour — it is specified, and universally implemented. The server receives /room/a7f3c9 and has no way to ask for the rest.

JavaScript running on the page, meanwhile, reads it from location.hash immediately. So the link carries the key to exactly the people you send it to, and to nobody in between.

The cryptography, concretely

The Web Crypto API (crypto.subtle) provides audited, native implementations of the primitives. You do not implement any cryptography yourself; you make a series of choices about which primitives to call.

Generating the key

const raw = crypto.getRandomValues(new Uint8Array(32));  // 256 bits
const key = await crypto.subtle.importKey(
  "raw", raw, "AES-GCM", false, ["encrypt", "decrypt"],
);

Note getRandomValues, not Math.random. The second is a fast statistical generator with predictable internal state and has no business anywhere near a key. Note also the false — the key is marked non-extractable, so even code running on the page cannot read it back out of the CryptoKey object afterwards.

Then base64url-encode the raw bytes into the fragment. Base64url rather than standard base64 because + and / do unhelpful things in URLs.

A related question: what does the server use as the room identifier? It cannot be the key. A clean answer is a hash — SHA-256 of the key material gives a stable identifier that is derived from the key but reveals nothing about it, so two people with the same link independently compute the same room ID without either the ID or the key ever needing to be coordinated.

Encrypting

AES-GCM is the standard choice, and specifically an authenticated mode. Encryption without authentication protects confidentiality but not integrity: an attacker who cannot read the ciphertext may still be able to flip bits in it and have the result decrypt to something plausible. GCM attaches an authentication tag, so tampered ciphertext fails to decrypt rather than decrypting to something wrong.

const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
  { name: "AES-GCM", iv }, key, plaintextBytes,
);
// Ship iv ++ ciphertext; the IV is not secret.

The initialisation vector must be fresh for every single encryption under a given key. This is the one place where an otherwise reasonable implementation can fail catastrophically: reusing an IV with AES-GCM does not merely weaken it, it can leak the XOR of two plaintexts and, worse, allow an attacker to forge the authentication tag for arbitrary messages. Twelve random bytes per message, every time, no counters, no reuse.

The IV itself is not secret and is simply prepended to the ciphertext, so the decrypting side can slice it back off.

Encrypting a collaborative stream

A collaborative editor is not encrypting one document — it is encrypting a continuous stream of small updates. This is where the choice of merge algorithm becomes a security-architecture question.

Because a CRDT merges correctly on the client, the server never needs to understand an update. So each update is encrypted independently on the producing client, stored as an opaque blob, and decrypted on arrival by every other client. The database holds a list of ciphertexts and a room ID it cannot connect to any content.

This is not incidental. Under operational transformation the server performs the transformation, so it must read every operation — and encrypting the payload would break the algorithm. Choosing a CRDT is a precondition for end-to-end encrypted collaboration, not a coincidence.

What this actually protects you from

Against the threat model above, the design holds up well.

  • Database breach: defeated. The dump contains random bytes. No key material is stored anywhere on the server side.
  • The operator: defeated for stored content. There is no administrative view that decrypts a room, because the necessary input never arrived.
  • Legal compulsion for stored data: defeated in the practical sense — the operator can hand over ciphertext and metadata, and that is the complete extent of what exists.
  • Infrastructure and backups: defeated, same reasoning.

What it does not protect you from

This section matters more than the previous one, and most descriptions of browser E2EE skip it.

A malicious or compromised server. This is the fundamental limitation of end-to-end encryption delivered over the web, and it cannot be engineered away. The server that stores your ciphertext also serves the JavaScript that holds your key. An operator willing to ship modified code to one user could exfiltrate that key. Installed applications get to be audited once and pinned; a web page is re-downloaded on every visit. What browser E2EE gives you is protection against passive compromise — breaches, snooping, subpoenas of data at rest — and not against an actively hostile provider. Treat any product claiming otherwise with suspicion.

The link itself. The key is in the URL, so the URL is the secret, and URLs are handled carelessly by default. They land in browser history, in synced-across-devices bookmarks, in the clipboard, in the chat app you pasted it into and that app’s own server logs, and in link-preview crawlers that follow anything posted in a channel. The cryptography is irrelevant if the link is in a Slack channel with two hundred people in it.

Metadata. The server does not know what is in a room, but it can see that a room exists, roughly how large it is, when it was created, how often it changes, how many connections it has, and the IP addresses of those connections. For some threat models that is plenty.

The endpoints. Content is plaintext on every screen it is open on. Malware, a shoulder surfer, or a screen share defeats all of this instantly.

Loss. The flip side of the server holding no key is that it cannot help you when you lose one. Lose the link and the content is unrecoverable, permanently, by anybody. There is no reset flow and there cannot be one.

Summary

Browser-based end-to-end encryption is a strong answer to “what happens when the database leaks” and a weak answer to “what if the provider is actively hostile”. That is a genuinely useful guarantee — most real incidents are the first kind — as long as it is described accurately.

How NoobProMax implements it

Private rooms use exactly the construction above: a 256-bit key from getRandomValues, base64url-encoded into the URL fragment, imported non-extractably, AES-GCM with a fresh 12-byte IV prepended to every message, and a SHA-256 digest of the key material as the room identifier the server sees. Each Yjs update is encrypted individually before it reaches Firebase.

The limits above apply in full, and the last one is the one people actually hit: the link is the only copy of the key. Lose it and the room is gone. That is what it means for the guarantee to be real.

Regular workspaces are a different and weaker proposition, deliberately. They are unlisted rather than encrypted — anyone who knows or guesses the URL can open them, and they are excluded from search indexing. Use a private room when the distinction matters; the privacy policy sets out what is stored in each case.