Zero-log, anonymous, peer-to-peer video chat and file transfer in the browser. No accounts, no database, no message or media storage — the server only exists to help two browsers find each other and exchange WebRTC handshake data.
![]() Landing |
![]() Waiting room (QR + code) |
![]() Call + SAS verification |
![]() Chat |
![]() Camera/mic switcher |
Captured headlessly for documentation, so the video feed shown is Chromium's synthetic test camera, not a real webcam.
- One person clicks Create Room and gets a 6-character room code (and a QR code / shareable link).
- The other person enters the code, scans the QR code, or opens the link.
- The server (Socket.io) relays only the WebRTC signaling messages (SDP offer/answer, ICE candidates) needed to establish a direct connection — it never sees audio, video, or file contents.
- Once connected, video/audio flows peer-to-peer over WebRTC, and files transfer peer-to-peer over a WebRTC data channel, chunked and backpressure-aware. A text chat channel rides the same data channel, so you can always type a message even if the peer connection is still negotiating or media fails.
- Both sides see a short SAS (short authentication string), derived from each peer's DTLS certificate fingerprint, so they can verbally verify the connection isn't being intercepted.
- Rooms are in-memory only, capped at 2 occupants, and auto-expire after a period of being idle/single-occupant (
ROOM_TTL_MS). Nothing is persisted to disk or a database.
- P2P video/audio call via WebRTC (
getUserMedia+RTCPeerConnection) - P2P file transfer over a negotiated WebRTC data channel, chunked (16KB) with backpressure handling for large files
- Text chat over the same data channel — a fallback way to communicate that doesn't depend on camera/mic access or media negotiation succeeding
- Camera/microphone switcher — pick a different input device mid-call without restarting the call (uses
RTCRtpSender.replaceTrack, no renegotiation) - Room codes + QR codes for joining, entered via individual OTP-style character boxes (ambiguous characters like
0/O/1/Iexcluded from codes) - SAS fingerprint verification to detect signaling-layer MITM, with explicit "It matches" / "Doesn't match" actions — a mismatch ends the call immediately
- Mic/camera mute toggle (track-level, no renegotiation)
- Mobile-friendly — the call screen fits the visible viewport on phones (no scrolling to see the video), a large touch-sized control bar (56px+ targets), and a responsive layout throughout
- Auto-expiring rooms — idle or single-occupant rooms are purged after a TTL
- Rate limiting on room create/join (per-socket, plus a global non-identifying throttle) and on signal relay, so a client can't brute-force room codes or flood a peer just by reconnecting
- Enforced file-transfer size cap — checked against both the declared and actual received size, not just a UI warning
- Zero request logging — no morgan/access logs, no IP-keyed state
- Strict CSP and security headers, all static assets (Tailwind, QR library) vendored locally — no third-party script origins
- Optional self-hosted TURN (via coturn), with time-limited HMAC credentials, so calls still connect when both peers are on different, restrictive networks (carrier-grade NAT, corporate firewalls)
npm install
npm start # or: npm run dev (nodemon)Visit http://localhost:3000. Requires Node.js >= 20.
The app container publishes no port to the host at all — it's meant to sit behind Nginx Proxy Manager on a shared Docker network, not be reached directly. Create that network once (skip this if NPM's own compose file already creates it) and make sure NPM's container is attached to it too:
docker network create npm_sharedThen:
cp .env.example .env # edit as needed
docker compose up -dIn NPM, point the proxy host at app:3000 (the Docker service/DNS name, not
an IP or localhost) — this only resolves for containers on npm_shared.
To also run a self-hosted TURN relay (coturn) for NAT traversal, set TURN_URL and TURN_SECRET in .env (see .env.example), then:
docker compose --profile turn up -dcoturn's config is rendered from coturn/turnserver.conf.template at container start using TURN_SECRET/TURN_REALM from .env — there's no separate file to copy or hand-edit. TURN_SECRET must be at least 32 characters (openssl rand -hex 32); both the app and coturn refuse to start with a shorter one. coturn runs on its own Docker network, isolated from the app — they don't need to reach each other.
Set via .env (see .env.example):
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Port the server listens on |
NODE_ENV |
production |
Node environment |
ALLOWED_ORIGIN |
(none) | Exact origin allowed for Socket.io CORS in production |
ROOM_TTL_MS |
600000 |
How long an idle/single-occupant room lives before auto-expiring |
TURN_URL |
(none) | TURN server URL(s), comma-separated; if unset, the app runs STUN-only |
TURN_SECRET |
(none) | Shared secret for time-limited TURN credentials (coturn use-auth-secret mode) — preferred over static credentials. Required to run the turn profile; also used to render coturn's config |
TURN_REALM |
turn.local |
Realm coturn reports in its auth challenge. Only relevant when TURN_SECRET is set |
TURN_USERNAME |
(none) | Static TURN username — used only if TURN_SECRET is unset |
TURN_CREDENTIAL |
(none) | Static TURN credential — used only if TURN_SECRET is unset |
STUN (Google's public STUN servers) is always included as a fallback ICE server, so the app works without any TURN configuration on networks that allow direct/STUN connectivity. TURN becomes necessary when both peers are behind NAT/firewalls restrictive enough that STUN can't find a direct path — this is what makes calls reliably work even when the two devices aren't on the same network.
server.js Express + Socket.io signaling server, room lifecycle, rate limiting
public/
index.html App shell
app.js Client: signaling, WebRTC, file transfer, chat, device switching, UI state
vendor/ Vendored, version-pinned third-party assets (Tailwind, QR code)
docs/
screenshots/ README screenshots
coturn/
turnserver.conf.template TURN config template for NAT-traversal fallback, rendered at container start
render-config.sh Substitutes TURN_SECRET/TURN_REALM into the template before launching turnserver
Dockerfile Multi-stage build (deps -> runtime), runs as non-root `node` user
docker-compose.yml App service + optional `turn` profile for coturn
- The signaling server never inspects or stores SDP/ICE payloads beyond confirming the sender belongs to the room; it only relays them.
- Signaling payloads are capped (100KB) to limit abuse — media, file, and chat bytes never touch the server, only peer-to-peer.
- Transport is WebSocket-only (no long-polling fallback) to reduce attack surface and intermediary logging exposure.
- TURN credentials are time-limited (HMAC, derived per session) when
TURN_SECRETis configured, rather than a long-lived shared password. StaticTURN_USERNAME/TURN_CREDENTIALare visible to any connecting client via the ICE server payload — fine for trusted/self-hosted use, but preferTURN_SECRETfor an internet-facing deployment. Both the app and coturn refuse to start ifTURN_SECRETis set but under 32 characters. - Received file transfers are capped at 2GB, enforced against both the declared and actual size — not just a UI warning — since a peer could otherwise understate the size and keep streaming past it.
- Every socket event handler is wrapped so a malformed payload can't crash the whole signaling process; there's also a process-level backstop (
uncaughtException/unhandledRejection) that exits cleanly rather than continuing in an unknown state —restart: unless-stoppedbrings it back up. - Containers run with
cap_drop: [ALL],no-new-privileges, a read-only root filesystem, and memory/CPU limits. - Known accepted risks, not fixed by design: env-var-based secrets (
TURN_SECRET, etc.) are visible viadocker inspect/docker compose configto anyone with Docker socket access on the host — a secrets manager would close this but is disproportionate for a self-hosted single-host deployment. coturn's own connection logs may include peer IPs (bounded to 1MB via the logging driver) — this is inherent to operating a TURN relay and distinct from the app's own zero-log guarantee. Room codes are 6 characters (~30 bits) from a 32-character alphabet; rate limiting (per-socket, per-relay, and a global non-identifying throttle) bounds brute-force feasibility without adding IP tracking, but widening the code itself would require UI changes not yet made.




