E2E Encryption and SSE Real-Time — Trust No Server (Part 3/6)
This is the part of the project I’m most proud of. When I set out to build a planning poker app, I had one non-negotiable: no server — including mine — should ever see unencrypted room data. No player names, no vote values, no story titles. Nothing.
Let’s talk about how end-to-end encryption works in a browser, and how real-time updates stream through a server that can’t read what it’s carrying.
The security model, one diagram
Browser URL hash (#k=…) Cloudflare Worker → Upstash
────────────────────── ───────────────────── ──────────────────────────────
generateKeyMaterial() → shared with teammates → stores only encrypted blobs
importKeyMaterial() (ciphertext only, no key)
extractable: false never sent in HTTP
encryptJSON(data, key) ──────────────────────────────────────────────────────────→
Three rules:
- The encryption key is generated in your browser and lives only in the URL fragment (
#k=...) - HTTP spec guarantees fragments are never sent to servers
- The
CryptoKeyis imported withextractable: false— even JavaScript can’t export it
Step 1: Generating the key
When you create a room, this happens:
export function generateKeyMaterial(): string {
return bufToB64(
crypto.getRandomValues(new Uint8Array(32)).buffer
);
}
32 random bytes. That’s 256 bits of entropy from the browser’s cryptographic random number generator. The output is base64url-encoded — URL-safe, no padding characters, perfect for embedding in a hash fragment.
The crypto.getRandomValues() call uses the OS-level CSPRNG (cryptographically secure pseudo-random number generator). On macOS that’s /dev/urandom. On Windows it’s BCryptGenRandom. Hardware-backed randomness in a single line of JavaScript.
Step 2: Importing as a non-extractable CryptoKey
Raw bytes are useful for sharing. But for actual encryption, we need a CryptoKey object:
export async function importKeyMaterial(b64: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
"raw",
b64ToBuf(b64),
{ name: "AES-GCM", length: 256 },
false, // ← extractable: false
["encrypt", "decrypt"],
);
}
That false parameter is the security linchpin. Once the key is imported, it’s locked inside the browser’s crypto engine. You can use it to encrypt and decrypt, but you cannot call crypto.subtle.exportKey() to get the raw bytes back. Not from JavaScript, not from DevTools, not from a browser extension.
The key material (the base64url string) is what gets shared via the URL. The CryptoKey is what does the actual work. They’re related but separate — and the CryptoKey is a one-way street.
Step 3: Encrypting everything
Every piece of data that leaves the browser goes through this:
export async function encryptJSON(data: unknown, key: CryptoKey): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV
const plain = new TextEncoder().encode(JSON.stringify(data));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
plain
);
return `${bufToB64(iv.buffer)}.${bufToB64(ct)}`;
}
AES-GCM needs two things: the key and a 12-byte initialization vector (IV). The IV can be public — it just needs to be unique per encryption. We generate a fresh one every time and prepend it to the output, separated by a dot.
The output format is base64url(iv).base64url(ciphertext) — a single string that’s safe to store as a Redis value. The server sees dGhpcyBp.c0FuZCB0aGlz... and has absolutely no idea what’s inside.
AES-GCM also provides authentication. If someone tampers with the ciphertext (even a single bit flip), decryption will throw. You get confidentiality and integrity in one operation.
Step 4: The URL fragment trick
Here’s the clever bit about key sharing. When you create a room, the encryption key gets appended to the URL:
https://planning-poker.oltionzefi.workers.dev
/planning-poker/room/abc123#k=dGhpcyBpcyBhIHRlc3Qga2V5
That #k=... is a hash fragment. Per the HTTP specification (RFC 3986, Section 3.5), hash fragments are processed entirely by the client. Browsers never include them in HTTP requests. The Cloudflare Worker, Upstash, CDN proxies, corporate firewalls — none of them ever see #k=....
When a teammate opens that link:
- The browser extracts
#k=dGhpcyBpcyBhIHRlc3Qga2V5from the URL - It imports the key material as a non-extractable CryptoKey
- It decrypts the room data fetched from Redis
- Everything displays in plaintext — but only in their browser
Step 5: Write-once session storage
The key material is stored in sessionStorage with a strict write-once policy:
const SS_PREFIX = "pp:key:";
export function storeKeyInSession(roomId: string, keyB64: string): boolean {
const existing = sessionStorage.getItem(SS_PREFIX + roomId);
if (existing !== null) return false; // never overwrite
sessionStorage.setItem(SS_PREFIX + roomId, keyB64);
return true;
}
Why write-once? Security. If an attacker somehow injects a different key via the URL, the app checks whether the stored key matches:
export function keyMatchesSession(roomId: string, candidateB64: string): boolean {
const stored = sessionStorage.getItem(SS_PREFIX + roomId);
if (stored === null) return true; // first time, ok
return stored === candidateB64; // must match
}
If the keys don’t match, the user is redirected home. No silent key substitution. No gradual data leak.
Now, real-time: SSE streaming
Encryption makes data private. SSE makes it live. When something changes in a room — a new vote, a reveal, a player joining — every connected browser receives the update instantly.
The subscribeToRoom method in the adapter opens a long-lived HTTP connection:
const connectSSE = async () => {
const sseUrl = `${this.mode.baseUrl}/subscribe/${encodeURIComponent(ROOM_CHANNEL(roomId))}`;
const res = await fetch(sseUrl, {
signal: controller.signal,
headers: this.authHeader
});
if (!res.body) return;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!isUnsubscribed) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const blob = parseSSELine(line.slice(6).trim());
if (blob) deliverBlob(blob).catch(() => {});
}
}
};
You might wonder: why not use the browser’s built-in EventSource API? Because EventSource doesn’t support custom headers. In proxy mode, the Cloudflare Worker adds the Authorization header server-side. But in direct mode (local development), we need to send the Upstash token ourselves. So we read the SSE stream manually via fetch + ReadableStream.
SSE message parsing
The Upstash pub/sub protocol wraps messages in an envelope:
{
"type": "message",
"channel": "room:abc123",
"data": "{\"data\":\"dGhpcyBp.c0FuZCB0aGlz...\"}"
}
That inner data field is our encrypted blob. The parseSSELine function peels off both layers:
const parseSSELine = (raw: string): string | null => {
try {
const event = JSON.parse(raw);
if (event.type !== "message" || !event.data) return null;
const inner = JSON.parse(event.data);
return inner.data ?? null;
} catch {
return null;
}
};
The blob gets decrypted in the browser, validated against the current room ID, and piped into the Zustand store via _setRoom(). From the user’s perspective: someone votes, and their card-back appears on your screen instantly.
Version filtering — no stale updates
SSE can deliver messages out of order, especially during reconnections. The adapter tracks a version number to prevent stale updates from overwriting newer state:
let lastDeliveredVersion = -1;
const deliverRoom = (room: Room) => {
const v = room.cmdCount ?? 0;
if (v <= lastDeliveredVersion) {
logger.warn(`Discarding stale snapshot (v${v} ≤ current v${lastDeliveredVersion})`);
return;
}
lastDeliveredVersion = v;
onChange(room);
};
The cmdCount field doubles as a version number — it increments with every write. If an old message arrives after a newer one, it gets silently discarded.
The 5-second poll fallback
SSE connections can drop. Browsers in the background throttle them. Corporate proxies kill long-lived connections. So there’s a backup:
const POLL_INTERVAL_MS = 5_000;
const pollInterval = setInterval(async () => {
if (isUnsubscribed) return;
const blob = await this.getBlob(roomId);
if (!blob) return;
await deliverBlob(blob);
}, POLL_INTERVAL_MS);
Every 5 seconds, the adapter fetches the latest room state from Redis. It goes through the same deliverBlob pipeline, so version filtering prevents it from overwriting a fresher SSE update.
It’s belt and suspenders. SSE for speed, polling for reliability.
Where data lives (and doesn’t)
| Data | Where | Lifetime |
|---|---|---|
| Player name, emoji | localStorage | Permanent (reused on next visit) |
| Room state, votes | Upstash Redis (AES-GCM encrypted) | 1h TTL, deleted when last player leaves |
| Session history | sessionStorage | Browser session only |
| Encryption key material | sessionStorage | Browser session only, write-once |
| CryptoKey object | Browser crypto engine | Memory-only, non-extractable |
Nothing sensitive persists beyond the browser session. Even if someone gained access to the Upstash database, they’d find only encrypted blobs that expire in an hour.
What’s next
We’ve built a system where data is encrypted before it leaves your browser, streams through a server that can’t read it, and arrives at your teammates’ screens in real time. Zero trust, zero plaintext at rest.
But that server in the middle — the Cloudflare Worker — what exactly does it do? In Part 4, we’ll build the thinnest possible auth proxy: a Hono Worker that handles SSE streaming, rate limiting, and static asset serving without ever touching your data.
Keep pushing forward and savor every step of your coding journey.
