End-to-end encrypted, fully ephemeral chat. No accounts. No logs. No traces.
BenchTalks is a privacy-first chat application where the server is a pure message relay. It stores nothing, knows nothing, and forgets everything the moment a room empties. Encryption keys never leave your browser.
Self-hosted instances are called benches. Benches can connect into a park β a federated network where public rooms flow across instances while private rooms stay local.
Live bench: https://benchtalks.chat
When you create a room, your browser generates an encryption key. That key lives
in the URL fragment β the part after #. Browsers never send fragments to
servers, so the server never sees your key. Every message and image is encrypted
before it leaves your device and decrypted after it arrives. The server sees
only blobs it cannot read.
Rooms exist only while people are in them. When the last person leaves, the room vanishes.
docker run -d \
--name benchtalks \
-p 3000:3000 \
-e PORT=3000 \
ghcr.io/isidman/benchtalks:latestVisit http://your-server:3000. Put Traefik, Caddy, or nginx in front to handle
HTTPS.
Download the latest binary from the releases page.
curl -L https://github.com/isidman/benchtalks/releases/latest/download/benchtalks-linux-amd64 -o benchtalks
chmod +x benchtalks
./benchtalksNo runtime dependencies.
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Port to listen on |
MAX_FILE_SIZE |
10485760 |
Max image size in bytes (10MB) |
BENCH_ID |
random | Stable identity for this bench in the park |
NATS_PEERS |
(empty) | Comma-separated NATS addresses for federation |
By default a bench runs standalone. To connect benches into a park, each bench needs a NATS server running alongside it.
- A running BenchTalks instance
- A NATS server on the same host
- At least one other bench operator to peer with
# Docker
docker run -d --name nats -p 6222:6222 nats:latest --cluster nats://0.0.0.0:6222
# Binary
nats-server --cluster nats://0.0.0.0:6222Open port 6222 in your firewall.
docker run -d \
--name benchtalks \
-p 3000:3000 \
-e BENCH_ID=my-bench \
-e NATS_PEERS=nats://their-bench.example.com:6222 \
ghcr.io/isidman/benchtalks:latestThe other operator does the same, pointing back at your NATS address. Multiple peers are comma-separated.
The room admin opens the Admin dropdown and clicks Make room public. Messages in that room will flow across all connected benches. Private rooms β the default β never leave the local bench.
NATS clusters automatically once two servers can reach each other. No central authority, no registration, no discovery service. You connect only to benches you trust explicitly. BenchTalks publishes encrypted blobs to NATS subjects β no bench in the park can read another bench's traffic.
BenchTalks supports federating benches across multiple server instances via NATS. When federation is enabled, benches communicate through a shared NATS cluster called the "park". Messages remain end-to-end encrypted throughout β the NATS layer only sees encrypted blobs, never plaintext content.
This is the most important thing to understand before running a federated setup. NATS does not encrypt traffic between servers out of the box. This means:
- Message metadata is visible on the wire (room IDs, timing, peer IPs)
- Message content is still protected β blobs are E2EE encrypted before they ever reach NATS, so the NATS layer cannot read them
- Anyone who can observe traffic between your NATS servers can see which rooms are active and when messages are sent β but not what they say
For a private or sensitive deployment, you should encrypt NATS cluster traffic using TLS. Securing your NATS server is outside the scope of this README β refer to the official documentation:
π https://docs.nats.io/nats-concepts/security
By default, --cluster 0.0.0.0 binds the NATS cluster port to all network
interfaces, including your public IP. This exposes the cluster port to the
internet. For most deployments you should bind to a specific interface:
# bind cluster port to localhost only (for local peering):
nats-server --cluster 127.0.0.1:6224
# bind both client and cluster ports to a specific interface:
nats-server -a 127.0.0.1 --cluster 127.0.0.1:6224The -a flag controls which interface the client port binds to. Without it,
the client port also defaults to all interfaces, which leaks your NATS
server's presence on all available network addresses.
Federation between benches requires an explicit pairing handshake before messages are relayed. A bench will not forward messages to or from another bench until a pairing token has been generated and claimed.
How to pair two benches:
- The room admin on bench-A clicks Admin βΎ β Pair with another bench
- They enter bench-B's Bench ID (found in the
BENCH_IDenv var on bench-B) - A pairing URL is generated β valid for 5 minutes, single use only
- The admin shares that URL with the bench-B operator out of band (email, Signal, etc. β not through BenchTalks itself)
- The bench-B operator opens the URL on bench-B's instance
- The handshake completes automatically β trust is now bidirectional
Properties of the pairing token:
- Bound to a specific bench ID β useless to any other bench
- Single use β burned immediately on first valid claim
- 5 minute TTL β expired tokens are rejected regardless of validity
- Never stored in plaintext β only the SHA-256 hash is kept in memory
- Lost on server restart β in-memory only, not persisted to disk
What pairing protects against:
Without pairing, any bench connected to the same NATS cluster could silently receive and relay messages for any public room β provided they knew the room ID and encryption key. Pairing ensures that only explicitly trusted benches participate in federation for a given room.
What pairing does not protect against:
Pairing operates at the bench level, not the NATS level. A malicious operator who controls a NATS server in the cluster can still observe message metadata. For full protection, combine bench pairing with NATS TLS as described above.
| Threat | Protected? | How |
|---|---|---|
| Server reads message content | β Yes | E2EE β server never decrypts |
| Untrusted bench relays messages | β Yes | Pairing token handshake |
| Leaked pairing URL used by wrong bench | β Yes | ClaimerID binding |
| Pairing URL reused after claiming | β Yes | Single-use burn on claim |
| NATS metadata observation | Use NATS TLS for full protection | |
| NATS traffic interception | Use NATS TLS for full protection | |
| Leaked room key + known room ID | Pairing required but key still sensitive |
- Encryption: XSalsa20-Poly1305 via TweetNaCl
- Key size: 256 bits
- Server role: Forward encrypted blobs and count connections. It cannot decrypt messages, identify users, or reconstruct history.
- Federation: NATS peers receive the same encrypted blobs. No bench can read another bench's traffic.
See SECURITY.md for the responsible disclosure policy.
git clone https://github.com/isidman/benchtalks.git
cd benchtalks
go build -o benchtalks cmd/benchtalks/main.go
./benchtalksor with make:
make
./bin/benchtalksRequires Go 1.24 or later.
Before opening a pull request:
- Open an issue first for anything beyond small fixes. Describe what you want to change and why.
- Keep the core values intact. Contributions that add server-side logging of message content, user identification, or persistent storage of room data will not be merged. The privacy model is non-negotiable.
- Match the code style. Plain Go, explicit error handling, descriptive comments. No frameworks beyond gorilla/websocket and nats.go.
- Test end-to-end. Create a room, send messages, send an image, test admin features.
- One thing per PR.
Good contributions: bug fixes, performance improvements, better mobile UX, documentation, additional deployment guides.
Out of scope: user accounts, message persistence, server-side analytics, any feature requiring the server to read message content.
GNU Affero General Public License v3.0 β see LICENSE.
The AGPL means: if you modify BenchTalks and run it as a network service, you must make your modified source code available to the users of that service. You cannot take this code, strip the privacy features, and run a closed-source fork as a commercial service.
The entire application state is a map of rooms in memory. When the process stops, everything is gone. This is a feature.