Ephemeral Chat is a small end-to-end encrypted web chat for short-lived conversations. It runs as a single Node.js process, keeps room state only in memory, and never stores plaintext messages or shared keys on the server.
Users join by entering the same shared key. The browser derives both the room ID and the encryption key locally, opens a same-origin WebSocket connection to /ws, then sends a join frame with the derived roomId and any requested room settings. In stateless multi-room mode, the server treats roomId as the transport-level room selector and does not verify knowledge of the original shared key. When the last participant leaves and the idle grace period expires, the room is removed from RAM.
- end-to-end encrypted text messaging in the browser
- no accounts, no database, no persistent history
- multi-room mode derived from the shared key
- optional single-room mode for one predefined shared key
- optional per-room join settings chosen by the first participant
- optional encrypted backlog replay for later joiners
- optional message self-destruct and room self-destruct timers
- room auto-lock after the second participant joins
- presence and system events for joins, leaves, locks, and expiry
- optimistic sending UI with retry, reconnect, and undeliverable states
- unread banner for live unread messages
- same-origin WebSocket admission policy, payload limits, heartbeat, and rate limiting
- A user opens the app and enters a shared key.
- The browser derives the
roomIdand encryption key locally. - The client opens a same-origin WebSocket connection to
/ws. - After the socket opens, the client sends a
joinframe with the derivedroomIdand any requested room settings. - The server validates origin during the HTTP upgrade, then validates the
joinpayload before joining the room. - The client receives a
room-stateevent first and abacklogevent second. - Live messages, presence updates, and system events then stream over the same WebSocket.
The browser uses the Web Crypto API:
roomId = SHA-256("ephemeralchat-room-v1:" + sharedKey)- the encryption key is derived with
PBKDF2+SHA-256 - messages are encrypted with
AES-GCM - each message gets a fresh 12-byte IV
The server sees only transport metadata and ciphertext:
roomIdivciphertextclientMessageId- server-generated metadata such as
id,seq,sentAt, andexpiresAt
The server never sees:
- the shared key
- the derived encryption key
- plaintext message contents
Ephemeral Chat provides end-to-end encryption for message contents, but in stateless multi-room mode it does not provide server-side proof that a joining client knows the original shared key.
Security properties:
- the shared key protects message confidentiality
- the browser derives the encryption key locally and plaintext stays client-side
- the server stores only in-memory room state and encrypted message payloads
- same-origin WebSocket policy, join deadlines, payload validation, heartbeat, and rate limiting reduce transport abuse
Non-properties in stateless multi-room mode:
- the server does not authenticate room membership based on the shared key
- the server does not distinguish "knows the shared key" from "knows the derived roomId"
- room membership is not cryptographically hidden from parties that know the
roomId - room-level control actions are not cryptographically authorized by the shared key alone
In multi-room mode, roomId should be treated as a bearer capability for transport-level access to a room. Anyone who learns the roomId can attempt to join that room over WebSocket even if they do not know the original shared key.
Knowing only the roomId does not reveal plaintext messages, but it can still expose or enable:
- room existence
- timing and traffic patterns
- encrypted backlog visibility when backlog is enabled
- lifecycle events such as joins, leaves, locks, and expiry
- room-level actions that are authorized only by successful room join
Because of this, roomId should be handled as sensitive data:
- do not treat it as a harmless public identifier
- avoid putting it in URLs, analytics, crash reports, or debug output
- avoid logging it in server logs, client logs, or proxy access logs when possible
This section describes the intended threat model for the default stateless multi-room deployment.
If an attacker learns a valid roomId, they should be treated as having transport-level access to that room lifecycle even if they do not know the original shared key.
What an attacker with only roomId can do:
- attempt to join the room over WebSocket as another participant
- receive future encrypted chat payloads and encrypted backlog replay if backlog is enabled
- observe room lifecycle events such as joins, leaves, locks, expiry, and room destruction
- trigger room-level actions that are currently authorized by successful join rather than proof of shared-key knowledge
- record ciphertext, timing, and traffic volume for later analysis
What an attacker with only roomId still should not learn directly:
- plaintext message contents
- plaintext usernames carried inside encrypted chat payloads
- the original shared key
- the derived browser encryption key
What can still be inferred from metadata:
- whether a room is active at all
- when participants arrive, leave, or reconnect
- whether a room appears to be one-to-one or more active over time
- whether backlog, room locking, expiry, or room destruction events occurred
- rough conversation intensity from message timing, burstiness, and ciphertext counts
What this means operationally:
- a leaked
roomIdis enough to lose membership secrecy for that room lifecycle - a leaked
roomIdis not by itself enough to decrypt historical or live message contents without the shared key - a leaked
roomIdis enough for an attacker to issue room-level actions such as room lock or room destroy because those actions are currently authorized by successful join - the ability to lock or destroy a room after learning
roomIdis a conscious design tradeoff of this stateless architecture, not an undocumented exception to the model - strong shared keys still matter because they protect message confidentiality even when transport-level room membership is lost
User and operator guidance:
- share the original shared key only over an out-of-band channel appropriate for the sensitivity of the conversation
- avoid exposing
roomIdin URLs, analytics, logs, support dumps, screenshots, or copied debug output - assume anyone who learns
roomIdcan observe room activity timing and disrupt the room even if they cannot read its plaintext - prefer short-lived rooms and rotate to a new shared key when disruption or leakage is suspected
- passive observers who can see ciphertext but do not know the shared key
- accidental server-side plaintext disclosure through normal application behavior
- server compromise limited to in-memory room metadata and encrypted payloads, but not client-side secrets already cleared from the browser
- basic transport abuse mitigated by same-origin policy, admission limits, deadlines, and rate limiting
- anyone who learns a valid
roomIdand uses it as a bearer token for room membership - metadata disclosure such as room existence, participant timing, presence, and message volume
- malicious room participants who know the shared key and can read or inject valid encrypted content
- destructive or administrative room actions triggered by a participant who joined using only the
roomId - endpoint compromise in the browser, including malicious extensions, local malware, or compromised client devices
- TLS is required in production so
roomId, ciphertext, and room metadata are not exposed in transit - operators should avoid logging sensitive WebSocket payloads and should review reverse proxy logging defaults
- users must share the shared key out of band using a channel appropriate for the sensitivity of the conversation
- users should treat both the shared key and the derived
roomIdas secrets for the lifetime of the room
The default architecture intentionally chooses stateless multi-room operation over strong server-side membership verification. That keeps the service simple and avoids server-side secret storage, but it means membership privacy is weaker than message confidentiality. In practical terms, if an attacker learns roomId, they can join at the transport layer and can also trigger room-level actions such as lock or destroy because the server is intentionally not in a position to verify knowledge of the original shared key before applying those actions.
This architecture is chosen for a reason:
- the server never needs the shared key or a server-side verifier derived from it
- the server can remain a simple relay for ciphertext plus minimal room metadata
- deployment stays lightweight because there are no accounts, no persistent credential store, and no key-verification database
- message confidentiality remains end-to-end even if server memory or logs are later inspected, as long as the shared key itself does not leak
The price of those properties is that roomId acts as a bearer capability for room membership and room-level disruption. If your use case requires the server to verify knowledge of the shared key before join or before destructive actions, this architecture needs an additional authorization layer or a different room admission model.
This is the default mode. Every shared key maps to a different room.
If you want to allow only one predefined room:
SINGLE_ROOM_MODE=true
SINGLE_ROOM_KEY=your-shared-keyAt startup, the server derives the one allowed roomId and rejects joins to any other room.
The first participant in a room can define room policy for that room lifecycle:
maxPeopleallowBacklogmaxBacklogMessageslockRoomAfterSecondJoinmessageSelfDestructMsroomSelfDestructMs
Later joiners cannot override those settings. They receive the effective policy back in the room-state payload.
If lockRoomAfterSecondJoin=true, the room is capped at two people and becomes closed to any future rejoin attempts once the second participant has entered.
- backlog stores encrypted messages only
- backlog replay is optional and disabled by default
- when backlog is enabled, retention is FIFO and capped
- message self-destruct removes the message from the server backlog and from connected clients
- room self-destruct expires the whole room on a fixed timer from room creation
- empty rooms without a room self-destruct timer are still removed after
ROOM_IDLE_GRACE_MS
After a successful join:
- the join form is replaced by the chat view
- the composer is enabled after the connection is established
- the current room settings summary is shown when relevant
- a live room-expiry countdown is shown when the room has an expiry time
- encrypted backlog messages are decrypted locally in the browser
While chatting:
- messages are shown optimistically before the server confirms them
- failed sends can be retried
- final server-side failures become
undeliverable - unread messages are counted only for live chat messages from other participants while the user is away from the bottom of the timeline
If the connection drops after a successful join, the client disables the composer, marks in-flight messages as failed, and attempts to reconnect automatically unless the server returned a final room error.
- the server accepts WebSocket upgrades only on the same origin
- idle WebSocket connections must send a valid
joinframe beforeJOIN_DEADLINE_MSexpires - pre-join sockets are capped globally and excess upgrades are rejected before the WebSocket handshake completes
- upgrade bursts are rate limited per client IP when the server can resolve the client IP safely
- invalid JSON, invalid transport envelopes, and binary frames are rejected
- per-socket sliding-window rate limiting is enforced
- WebSocket heartbeat pings terminate stale connections
- CSP,
X-Frame-Options,X-Content-Type-Options,Referrer-Policy, andPermissions-Policyheaders are set on HTTP responses - the app is intentionally single-process because room state lives in memory
The protocol is a JSON-over-WebSocket protocol on GET /ws.
The browser connects to:
ws(s)://<host>/ws
After the socket opens, the client sends a join frame:
{
"type": "join",
"roomId": "64-char-lowercase-hex",
"roomSettings": {
"maxPeople": 2,
"allowBacklog": true,
"maxBacklogMessages": 5,
"lockRoomAfterSecondJoin": true,
"messageSelfDestructMs": 1000,
"roomSelfDestructMs": 2000
}
}Fields:
type: literal"join"roomId: required 64-character lowercase hex room IDroomSettings: optional object, honored only when creating a new room lifecycle
Admission rules:
- request must be same-origin
- request path must resolve to pathname
/ws - the server may reject the HTTP upgrade with
429or503before opening a WebSocket when pre-join protection triggers join.roomIdmust match/^[a-f0-9]{64}$/join.roomSettingsmust pass bounds validation- in single-room mode,
join.roomIdmust equal the configured allowed room ID
If admission succeeds, the server responds with WebSocket 101 Switching Protocols.
- all application frames are JSON text frames
- binary frames are rejected with close code
1003 - invalid JSON is rejected with close code
1003 - invalid transport envelopes are rejected with close code
1008 - rate-limit violations are rejected with close code
1008
Used immediately after the socket opens to select a room and optionally request initial room settings.
{
"type": "join",
"roomId": "64-char-lowercase-hex",
"roomSettings": {
"maxPeople": 2,
"allowBacklog": true,
"maxBacklogMessages": 5,
"lockRoomAfterSecondJoin": true,
"messageSelfDestructMs": 1000,
"roomSelfDestructMs": 2000
}
}Behavior:
- the first valid client frame must be
join - repeated
joinframes on the same socket are rejected - if
roomSettingsare omitted, server defaults apply when creating a new room - later joiners cannot override settings of an already-existing room lifecycle
Used to send an encrypted chat message.
{
"type": "message",
"clientMessageId": "client-uuid-or-other-non-empty-string",
"iv": "base64",
"ciphertext": "base64"
}Fields:
type: literal"message"clientMessageId: non-empty string used for idempotent resend detection within one room lifecycleiv: non-empty stringciphertext: non-empty string
Behavior:
- if the same
clientMessageIdis seen again in the same room lifecycle, the server echoes the already-stored message instead of creating a new one - the server assigns
id,seq,sentAt, and optionallyexpiresAt
Used by joined participants to manually lock or unlock the current room when lockRoomAfterSecondJoin is not enabled.
{
"type": "set-room-lock",
"locked": true
}Behavior:
- ignored when the room policy uses
lockRoomAfterSecondJoin - ignored when the requested lock state already matches the current room state
- otherwise broadcasts updated
room-stateplus asystem-eventofRoom lockedorRoom unlocked
Used by any joined participant to destroy the current room lifecycle immediately.
{
"type": "kill-room"
}Behavior:
- the server broadcasts a final
errorwithreasonCode: "room_destroyed" - all connected room participants are closed
- the active room lifecycle is destroyed, and a later join creates a fresh lifecycle
The server can emit the following message types.
Always sent before backlog when a client joins successfully. Also broadcast later when room lock state changes.
{
"type": "room-state",
"roomInstanceId": "uuid",
"settings": {
"maxPeople": 2,
"allowBacklog": true,
"maxBacklogMessages": 5,
"lockRoomAfterSecondJoin": true,
"messageSelfDestructMs": 1000,
"roomSelfDestructMs": 2000
},
"runtime": {
"locked": false,
"roomExpiresAt": 1730000000000
}
}Fields:
roomInstanceId: unique ID for the current room lifecyclesettings.maxPeople: integersettings.allowBacklog: booleansettings.maxBacklogMessages: integer ornullsettings.lockRoomAfterSecondJoin: booleansettings.messageSelfDestructMs: integer ornullsettings.roomSelfDestructMs: integer ornullruntime.locked: booleanruntime.roomExpiresAt: unix epoch milliseconds ornull
Sent immediately after room-state.
{
"type": "backlog",
"messages": []
}Fields:
messages: array ofmessagepayloads in server backlog form
Broadcast for newly accepted chat messages and reused inside backlog.messages.
{
"type": "message",
"id": "server-message-id",
"roomInstanceId": "uuid",
"seq": 1,
"iv": "base64",
"ciphertext": "base64",
"clientMessageId": "client-message-id",
"expiresAt": 1730000000000,
"sentAt": 1730000000000
}Fields:
id: server-generated message IDroomInstanceId: room lifecycle IDseq: monotonically increasing room-local sequence numberiv: encrypted payload IVciphertext: encrypted payload bodyclientMessageId: original client message IDexpiresAt: unix epoch milliseconds ornullsentAt: unix epoch milliseconds
Notes:
- plaintext author/message body is never present here; the client decrypts
iv+ciphertextlocally
Broadcast on join and leave transitions, and when a room auto-locks because the second participant joins.
{
"type": "presence",
"roomInstanceId": "uuid"
}Fields:
roomInstanceId: room lifecycle ID
Broadcast for room lifecycle events.
{
"type": "system-event",
"roomInstanceId": "uuid",
"text": "Someone joined",
"sentAt": 1730000000000
}Observed text values in the current implementation:
Someone joinedSomeone leftRoom lockedRoom unlockedRoom expired
Broadcast when a self-destructing message expires.
{
"type": "message-removed",
"roomInstanceId": "uuid",
"messageId": "server-message-id",
"reason": "self_destruct"
}Broadcast before the server closes room connections for terminal room-level failures such as room expiry.
{
"type": "error",
"roomInstanceId": "uuid",
"reasonCode": "room_expired",
"retryability": "final",
"message": "Room expired."
}Fields:
roomInstanceId: room lifecycle IDreasonCode: machine-readable error coderetryability:"final"or"retryable"message: human-readable error
- after successful join,
room-stateis sent beforebacklog - if a room becomes locked because the second participant joined, existing participants receive updated
room-statebefore the correspondingpresencebroadcast - room events are scoped by
roomInstanceId, so clients can ignore stale frames from older room lifecycles
- Node.js 24+
- npm
npm install
npm run devBy default, the app runs at http://127.0.0.1:3000.
If you want to test the app locally behind HTTPS, or expose it on your LAN while keeping HTTPS enabled, you can run the Node app first and then start Caddy with the checked-in Caddyfile:
npm run dev
caddy run --config CaddyfileCurrent local example from Caddyfile:
https://192.168.44.5 {
tls internal
reverse_proxy 127.0.0.1:3099
}Notes:
- replace
192.168.44.5with your machine's current LAN IP or another hostname you use locally - the checked-in example proxies to
127.0.0.1:3099, so either start the app on that port withPORT=3099 npm run devor change the proxy target to127.0.0.1:3000 - if another device on your LAN opens the HTTPS URL, that device must trust Caddy's local root CA or the browser will show a certificate warning
Why use Caddy here:
- Caddy serves local and internal hosts over HTTPS automatically and can generate locally trusted certificates for them
- browsers treat
localhostand127.0.0.1as potentially trustworthy for local development, but non-local resources must be served overhttps://to be considered secure - many Web APIs and browser features are available only in a secure context, so HTTPS is useful when you are testing on a LAN address instead of only on
localhost
Relevant references:
- Caddy automatic HTTPS and local HTTPS: Automatic HTTPS
- MDN secure contexts: Secure contexts
npm install
npm run build
npm startHealth check:
curl http://127.0.0.1:3000/healthnpm testThe current test suite covers:
- app shell rendering,
/health, and baseline security headers - strict config parsing and config summary redaction
- room lifecycle, backlog retention, message removal, and expiry behavior
- transport admission policy, invalid payload handling, and rate limiting
- browser/server protocol compatibility for encrypted messages
- room settings propagation and first-join policy authority
- optimistic delivery reconciliation and unread state helpers
- WebSocket heartbeat behavior
Configuration is resolved in this order:
- CLI flag, for example
--port=4000 - environment variable
.env- default value
Basic setup:
cp .env.example .envAPP_TITLE: page title and UI branding, defaultEphemeral ChatHOST: server bind address, default0.0.0.0PORT: HTTP port for both the web app and WebSocket server, default3000TRUST_PROXY:trueorfalse, defaultfalse; enable only when the app sits behind a local trusted reverse proxy because degraded mode falls back torequest.socket.remoteAddressJOIN_DEADLINE_MS: max time an upgraded socket may stay unjoined, default5000ROOM_IDLE_GRACE_MS: how long to keep an empty room in memory, default60000DEFAULT_ALLOW_BACKLOG: default backlog policy for newly created rooms, defaultfalseMAX_BACKLOG_MESSAGES: max retained backlog messages per room, default100MAX_CLIENTS_PER_ROOM: hard ceiling for room capacity, default32MAX_ROOMS: max in-memory room count, default10000MAX_MESSAGE_SIZE_BYTES: max incoming WebSocket payload size, default16384MAX_OPEN_PREJOIN_SOCKETS: max upgraded sockets waiting forjoin, default250MAX_MESSAGE_SELF_DESTRUCT_MS: upper bound for per-room message self-destruct, default86400000MAX_ROOM_SELF_DESTRUCT_MS: upper bound for per-room room self-destruct, default604800000RATE_LIMIT_WINDOW_MS: sliding-window duration for rate limiting, default10000RATE_LIMIT_MAX_MESSAGES: max client frames per rate-limit window, default10UPGRADE_RATE_LIMIT_WINDOW_MS: sliding-window duration for upgrade rate limiting, default10000MAX_UPGRADES_PER_IP_PER_WINDOW: max upgrade attempts per resolved client IP in one upgrade rate-limit window, default20SINGLE_ROOM_MODE: enables single-room mode, defaultfalseSINGLE_ROOM_KEY: required whenSINGLE_ROOM_MODE=true
CLI flags mirror the environment variable names in kebab-case. Examples:
npm start -- --port=4000 --max-clients-per-room=8 --default-allow-backlog=trueWS_PORT is intentionally unsupported in the current single-origin server.
This project is designed for a single Node.js instance behind a reverse proxy.
- run the Node process on an internal port such as
3000 - place Nginx, Caddy, or a platform ingress in front of it
- forward normal HTTP traffic and WebSocket upgrades
- if
TRUST_PROXY=true, keep the Node app behind a local trusted proxy hop - use HTTPS if the app is reachable from the public internet
- add
MAX_OPEN_PREJOIN_PER_IPif real traffic shows one-IP socket hoarding after burst limiting - replace the simple
TRUST_PROXYboolean with a more explicit trusted-proxy policy if deployment topology grows - expose degraded proxy mode on a separate operator status endpoint if logs are not enough
Run only one app instance unless you add shared room state. Clustered or horizontally scaled instances will split rooms across processes and break the current in-memory behavior.
npm ci
npm run build
NODE_ENV=production npm startBuild and run only the app:
docker compose -f compose.yml up --buildThe app will be available at http://127.0.0.1:3000.
Run the app behind Caddy over HTTP:
docker compose -f compose.yml -f compose.proxy.yml --profile http-proxy up --buildThe proxy will listen at http://127.0.0.1:8080.
Run the app behind Caddy over HTTPS with Caddy's internal CA:
docker compose -f compose.yml -f compose.proxy.yml --profile https-proxy up --buildBy default, the HTTPS proxy serves https://localhost:8443. You can override the hostname with CADDY_HTTPS_HOST, for example:
CADDY_HTTPS_HOST=ephemeral-chat.local docker compose -f compose.yml -f compose.proxy.yml --profile https-proxy up --buildNotes:
compose.ymlruns the app directly withTRUST_PROXY=falsecompose.proxy.ymlflips the app toTRUST_PROXY=trueand adds Caddy profiles- the HTTPS profile persists Caddy state in named volumes so the internal CA and certificates survive restarts
- both proxy services wait for the app health endpoint before starting
This repository includes a PM2 config at ecosystem.config.cjs.
npm install -g pm2
npm ci
npm run build
pm2 start ecosystem.config.cjs --env productionUseful PM2 commands:
pm2 status
pm2 logs ephemeral-chat
pm2 restart ecosystem.config.cjs
pm2 stop ecosystem.config.cjs
pm2 delete ecosystem.config.cjs
pm2 save
pm2 startupThis project was created for educational purposes, testing, and learning about end-to-end encrypted communication. It is provided as-is, without warranties or guarantees of any kind, and you use it entirely at your own risk.
You are solely responsible for how you use, deploy, modify, or share this software. It must always be used in compliance with the laws, regulations, and policies applicable in your country or jurisdiction.
This project is not intended to support, encourage, or facilitate illegal activity, unauthorized access, privacy violations, or harmful behavior of any kind. The author disclaims liability for misuse, damages, losses, legal consequences, or other claims arising from the use or inability to use this project.
