Minimal E2E-encrypted chat on Cloudflare Workers and Durable Objects. One invite link = one room. No accounts, no server-side decryption.
Why it exists: spin up a private chat with a single link; the server only relays encrypted data and never sees the key or message content. Suited for one-off conversations, support threads, consultations, or your own zero-knowledge chat on your domain. You can run it on Cloudflare, or self-host with Docker Compose and optionally expose it as a Tor hidden service (.onion) for access without clearnet.
- How it works
- Technical implementation details
- Security
- Pros and cons
- Comparison with alternatives
- Run locally
- Docker and Tor (onion service)
- Deploying on Cloudflare
- Limitations
- Room ID is in the URL path (
/room/<roomId>). - Secret key is in the URL hash (
#...) and is never sent to the server. - On first open with
#secret, the key is imported as non-extractable and stored in IndexedDB per room; the page then reloads without the hash. - Encryption and decryption happen only in the browser (AES-GCM).
- The Durable Object only relays encrypted messages and stores the last 10 per room.
- Users are anonymous and identified by a short
clientIdstored in the browser per room.
Flow:
- Open
/new_chat— a room is created and an invite link is shown (room id + secret in the hash). - Share the link. When opened, the browser saves the key in IndexedDB and reloads without
#secret. - Clients connect via WebSocket to
/ws/<roomId>?client=<shortId>and exchange encrypted messages. - New joiners receive the last 10 encrypted messages on connect and decrypt them locally.
- Runtime: Cloudflare Workers (V8 isolates).
- Room state: Durable Object
ChatRoomDO(one instance perroomIdviaidFromName(roomId)). - Client: a single HTML document; CSS and JS are served from the same Worker (
/app.css,/app.js); no separate build.
| Path | Purpose |
|---|---|
/ |
Returns API OK (stub). |
/new_chat |
Chat page: create a room and generate invite link. |
/room/<roomId> |
Chat page for an existing room. |
/ws/<roomId>?client=<shortId> |
WebSocket into the room; client is the stable participant id (4–16 chars, a-z0-9_-). |
/app.js, /app.css |
Client script and styles. |
/healthz |
Liveness check (ok). |
| All other paths | API OK. |
All frames are JSON. Envelope types:
message— chat message:from,iv,ciphertext,sentAt. Server addsfromandsentAt, relays to others, and appends to history.system— system events (join/leave, history cleared).history— on connect: last up to 10 encrypted room messages.clear_history— client command: delete history in the DO and broadcasthistory_cleared.history_cleared— notification that history was cleared (and by whom).
Incoming data is validated on the server in parseEnvelope() (types and format; e.g. clientId via regex ^[a-z0-9_-]{4,16}$/i, roomId up to 64 chars).
- Algorithm: AES-GCM, 256-bit key (32 bytes).
- Room key: 32 random bytes in Base64URL in the link hash; imported with
crypto.subtle.importKey(..., { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'])— not extractable. - Per message: 12-byte random IV, encrypt plaintext;
ivandciphertextsent over WebSocket in Base64URL. - Key storage: IndexedDB, database
secure_chat_keys, storeroom_keys, record keyroomId; record holds CryptoKey,clientId,updatedAt.
- Class:
ChatRoomDO; Worker bindingCHAT_ROOMS. - Storage: built-in key-value (
this.ctx.storage); key"history"— array of up to 10ChatMessageEnvelopeitems (latest messages). - Connections:
Map<WebSocket, clientId>; on new WebSocket — send history, system “connected”, broadcast “joined”; on close/error — “left”. - Clear history: on
clear_historycommand —storage.delete("history"), broadcasthistory_clearedand system “cleared room history”.
- Content-Security-Policy:
default-src 'none';script-src/style-src/connect-srclimited to'self'andwss:as needed;base-uri 'none',frame-ancestors 'none',object-src 'none',form-action 'none';require-trusted-types-for 'script'. - Cache-Control:
no-store. - Referrer-Policy:
no-referrer. - X-Content-Type-Options:
nosniff. - X-Frame-Options:
DENY. - Cross-Origin-Opener-Policy:
same-origin. - Cross-Origin-Resource-Policy:
same-origin. - Permissions-Policy: camera, microphone, geolocation, etc. disabled.
- Confidentiality of text: the server only sees encrypted
iv+ciphertext; the key stays in the browser and in the URL hash (the hash is not sent to the server). - Zero-knowledge: the operator and Worker code cannot read messages without the key from the link.
- Strict CSP and isolation: scripts and styles only from same origin, no embedding in iframes, reduced XSS and injection surface.
- Single key per room: anyone with the link can read everything and trigger “Clear history” — history is cleared for everyone. No per-user keys or cryptographic “delete for everyone”.
- No key rotation or forward secrecy: a leaked key gives access to all room history.
- No contact verification: no way to compare key fingerprints (as in Signal/Keybase); trust is “whoever has the link”.
- Metadata visible to the server: who connected when to which room, traffic volume. No metadata hiding.
- Client code: encryption and key logic live in JS served from the same origin; integrity relies on HTTPS and CSP.
Pros:
- No accounts, sign-up, or email/phone binding.
- Minimal trust in the server: it only relays ciphertext and stores the last 10 messages in encrypted form.
- Simple crypto model (AES-GCM, one key per room) — easy to audit and learn from.
- Single repo, single deploy (Worker + DO), no separate DB or backend.
- Fork and deploy on your own domain (Cloudflare).
Cons:
- One shared key per room; anyone with the link is a full participant and can clear history.
- No key rotation, no forward secrecy.
- Key is tied to one browser/profile; a new device means sharing the full link with
#secretagain. - No verification of “who exactly I added”.
- Text only, last 10 messages; no media, editing, or history search.
| Solution | Advantages over Secure Chat | Disadvantages vs Secure Chat |
|---|---|---|
| Signal | Key rotation, contact verification, multi-device, rich features | Requires phone number, central server, no self-hosted deploy |
| Matrix (Element) | Federation, flexible rooms, many clients and devices | More complex, needs account/server, more dependencies |
| Session / Briar | Better metadata hiding (Tor/networks) | Harder to use and deploy |
| Telegram (secret chat) | Convenience, multi-device | Trust in server, account binding, not zero-knowledge in the same sense |
| Keybase (historical) | Identity, teams, key verification | Product discontinued; idea was “teams + keys” |
Secure Chat does not replace full-featured messengers; it targets a narrow use case: a private room from one link, no sign-up, with your own deployment.
npm install
npm run devOpen in browser: http://localhost:8787.
Run the app with Docker Compose and optionally expose it as a Tor hidden service so it is reachable via a .onion URL. No Cloudflare account needed; clients can use Tor Browser and never touch clearnet.
Prerequisites: Docker and Docker Compose.
-
Start the stack (app + Tor):
docker compose up -d
-
Clearnet: open
http://localhost:8787— usually available within a few seconds. -
Onion: get your
.onionaddress (after Tor has started):docker compose exec tor cat /var/lib/tor/secure_chat/hostnameOpen
http://<that>.onionin Tor Browser (or another Tor-capable client). Invite links will use the.onionhost when opened in that context.
The clearnet URL is ready quickly. The .onion address can take about 1–3 minutes to become reachable: Tor must bootstrap (connect to the network and build circuits), then publish and propagate the hidden service descriptor. If the onion URL does not load at first, wait a minute or two and try again.
Details: The tor service forwards hidden service port 80 to secure-chat:8787. The onion key is stored in the tor-hidden-service volume so the same .onion address is kept across restarts.
This project is a Worker with a Durable Object; HTML/CSS/JS are served by the same Worker. The recommended way is to deploy as a Cloudflare Worker. Cloudflare Pages is aimed mainly at static sites and Functions; you cannot define Durable Objects inside a Pages project’s code — they are used via a separate Worker. Below: quick Worker deploy and Dashboard (including Workers & Pages) options.
You need a Cloudflare account and Wrangler set up.
npm install
npm run deployOn first run, Wrangler will prompt to log in and attach the project. After deploy, the app is available at something like https://secure-chat.<your-subdomain>.workers.dev (or your custom domain if configured for the Worker in Cloudflare).
- Go to Cloudflare Dashboard → Workers & Pages.
- Create → Create Worker (or Create application → Worker).
- Choose Connect to Git and attach the repository for this project.
- Configure the build:
- Build command:
npm ci && npm run deploy - Or use Build command:
npm ciand, depending on the UI, set the build output to the result ofwrangler deploy(e.g. “Use Wrangler” / “Worker build”). If the UI has an explicit “Deploy with Wrangler” or “Build withwrangler deploy” mode, use it and set the command tonpm run deploy(afternpm ci).
- Build command:
- Save and deploy. The Worker with Durable Object will be deployed; URL will be like
https://secure-chat.<account>.workers.devor your custom domain.
This repo is a full Worker with a Durable Object, not a static site. Pages and logic are served by the Worker, so a “classic” deploy as Pages-only (static site) does not fit.
If you want to use Cloudflare Pages (e.g. custom domain via Pages or a single project under Pages):
- Deploy the Worker with Durable Object using one of the methods above (Wrangler or Workers & Pages with Git). That creates a Worker with the name from
wrangler.json(e.g.secure-chat). - In Pages, create a project:
- Create project → Connect to Git and select the same repo.
- In build settings, indicate this is not a static site but a Worker (if the UI has something like “Compatibility mode” / “Worker” for the build).
- Or keep Pages as a minimal static stub and set a Custom domain to the same host that points to your Worker (via CNAME or Workers route).
- Binding Durable Object to Pages (if needed):
The Durable Object is defined in this Worker’s code and deploys with it. You don’t need to “attach” the DO to Pages separately if you deploy this project as the Worker and use the Worker’s URL.
If you have a separate Pages project (different frontend) and want to call this chat from it, that frontend would call the deployed Worker URL (e.g.https://secure-chat.<account>.workers.dev). The DO already runs inside that Worker.
Summary: for this repo, deploying as a Worker (option 1 or 2) is enough. Use Pages if you want a custom domain through Pages or a separate static frontend that talks to the deployed Worker by URL.
- Only the last 10 messages per room are stored.
- One shared key per room — anyone with the link can read and use Clear history, removing history for everyone.
- Key is tied to one browser/profile; a new device or browser needs the full link with
#secret. - No key rotation, contact verification, or media — text-only MVP.
- The chat UI is served only for
/new_chatand/room/<roomId>. Other paths returnAPI OK. - Without the secret (e.g. only
/room/<id>with no hash), messages cannot be decrypted. - All app responses send the security headers listed above (CSP, COOP, CORP, etc.).