Share a piece of text that can only be read a set number of times, then destroys itself.
Text is encrypted in the browser. The server stores ciphertext it has no way to read, counts down the remaining reads, and deletes the record when the budget runs out. Google sign-in is required to create and to read.
Runs at share.tnkrhaus.dev, deployed by Ansible from GetThrive/infra onto the Thrive primary DNS box.
Go · HTML + Tailwind · Redis — the Thrive standard stack. No Postgres: paste data is deliberately short-lived and self-deleting.
| Layer | What | Why |
|---|---|---|
| Crypto | Web Crypto crypto.subtle, AES-256-GCM |
Native, audited primitives; encryption happens client-side so the server never holds a key |
| Frontend | Server-rendered HTML + Tailwind | No framework and no bundler — the only JavaScript is the ~130-line crypto module and the form wiring that drives it |
| Backend | Go | One self-contained binary; templates, stylesheet and scripts are embedded, so a deploy copies one file |
| Storage | Redis (dedicated instance, loopback-only) | Ciphertext with a native TTL and an atomic read counter — no separate GC |
| Auth | Google OAuth 2.0 | Every create and read is attributable; no passwords stored |
| Edge | Caddy + Let's Encrypt | Automatic TLS; a secure context is mandatory for crypto.subtle |
Everything security-relevant lives in a few files: web/static/js/crypto.mjs (browser encryption), internal/store (the paste store and its atomic reveal), internal/web (the HTTP surface). See How it works for the data flow and Security notes for the threat model.
CREATE READ
------ ----
browser: key = 32 random bytes browser: split key, verify checksum
browser: ct = AES-256-GCM(key, text) | (a mistyped key stops here — no request)
| v
| POST { ct, iv, max_views, ttl } POST /api/paste/{uuid}/reveal
v ^^^ no key, ever |
server: store, return UUIDv4 server: atomic decrement, return ct
| | deletes the record at zero
v v
share key = <uuid>.<secret>.<checksum> browser: decrypt locally with secret
^^^^ server knows plaintext never leaves the browser
^^^^^^ server never sees
The share key is one string:
| Part | Goes to the server? | |
|---|---|---|
uuid |
UUIDv4 | Yes — it is the Redis lookup key |
secret |
32 random bytes | Never. This is the AES key |
checksum |
2 bytes | No — a client-side typo guard |
Because the secret half never reaches the server, a full dump of Redis — or of a backup, or of the host — yields nothing but opaque bytes. Losing the key means the text is unrecoverable by anyone, including the operator. That is the point.
web/static/js/crypto.mjs is JavaScript and has to stay that way. The product's whole value is that the key is generated and used on the user's device; moving encryption to the server would hand it exactly what it is designed not to have.
That is not a client-side app. There is no framework and no bundler — Go renders the HTML, and the browser loads ~130 lines of hand-written WebCrypto plus the form wiring that drives it.
The standard prefers v7 for its Postgres index locality. Here it would be actively wrong: v7 embeds a creation timestamp, and when a given secret was created is exactly the metadata a secret-sharing tool must not publish. Pastes are Redis keys, not B-tree rows, so v7 buys nothing to offset it.
A read is spent when the ciphertext is released, not when the browser reports a successful decrypt. Decryption happens client-side, so a confirmation from the client is unenforceable — a reader could simply never send one and read forever.
That would make a typo expensive, so the share key carries a 2-byte checksum the browser validates before any network call. A mistyped key is rejected locally and costs nothing.
Reveal is a single Lua script. Read-then-decrement over two round trips races: two concurrent reveals of a one-read paste would both see views = 1 and both succeed. Redis runs scripts to completion on one thread, so bundling read + decrement + delete makes over-reading impossible. internal/store fires 50 simultaneous reveals at a one-read paste and asserts exactly one wins — and that test is verified to fail against a deliberately non-atomic implementation.
docker compose up -d # Redis on :6380
make dev # http://localhost:3000make build produces a single self-contained binary — templates, stylesheet and scripts are embedded, so deploying copies one file.
ALLOW_DEV_LOGIN=true make devadds a local sign-in box that mints a session for any address you type. Double-gated — it needs the explicit opt-in and a non-production build — so it cannot be switched on in production by environment alone.
docker-compose.yml publishes on 6380 because 6379 is so often already taken by another project's Redis. The app defaults to match, so docker compose up -d && make test works with no .env. In production it gets its own instance on 6381.
| Variable | Default | Notes |
|---|---|---|
PORT |
3000 |
|
BASE_PATH |
empty | Empty means the app owns the domain root, the normal case. Set it (e.g. /sharetext) to mount under a path on a shared domain. |
PUBLIC_ORIGIN |
http://localhost:$PORT |
Also decides whether cookies are Secure |
SESSION_SECRET |
— | Required in production, min 32 chars. openssl rand -hex 32 |
GOOGLE_CLIENT_ID / _SECRET |
— | |
REDIS_HOST / REDIS_PORT |
127.0.0.1 / 6380 |
Or REDIS_ADDR as host:port |
APP_ENV |
— | production enforces SESSION_SECRET and disables dev login |
ALLOW_DEV_LOGIN |
unset | true enables the local bypass; ignored in production |
- Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application.
- Authorized redirect URI, character for character:
<PUBLIC_ORIGIN><BASE_PATH>/auth/google/callback- production —
https://share.tnkrhaus.dev/auth/google/callback - local —
http://localhost:3000/auth/google/callback
- production —
/up reporting google:true only means the credentials are present. It cannot tell you whether the redirect URI is registered — only a sign-in attempt can.
Runs on thrive-dns01 (dm.tnkrhaus.dev) beside coredns_ui. They share the box and Caddy and nothing else.
Caddy :443
┌─────────────┴──────────────┐
dm.tnkrhaus.dev share.tnkrhaus.dev
coredns_ui :3000 sharetext :3001
│ │
Redis :6379 Redis :6381
(authoritative DNS, (paste ciphertext,
replicates to ns02) loopback only, never replicated)
make sharetext # from GetThrive/infraThe DNS Redis on 6379 is the authoritative zone store and replicates to ns02. Paste ciphertext has no business on that link, and a stray FLUSHDB from an app sharing the instance would take tnkrhaus.dev down with it.
HTTPS is not optional. crypto.subtle exists only in a secure context; over plain HTTP on a real hostname the browser does not expose it and no encryption is possible. The app detects this and refuses rather than pretending to work.
All routes sit under BASE_PATH and require a session.
| Method | Path | |
|---|---|---|
POST |
/api/paste |
{ct, iv, max_views, ttl_seconds} → {id, expires_at, max_views} |
POST |
/api/paste/{uuid}/reveal |
→ {ct, iv, views_remaining}, or 404 |
GET |
/api/paste/mine |
Your shares: status and who read them |
GET |
/api/me |
Current session |
GET |
/up |
Health check (unauthenticated) |
Reveal answers identically for "never existed", "expired", "already used up" and "not even a valid UUID". Distinguishing them would confirm to a prober that an id was once real.
| Key | Type | Contents | TTL |
|---|---|---|---|
paste:<uuid> |
hash | ct, iv, views, created_by, created_at |
chosen expiry |
paste:<uuid>:log |
list | who read it, capped at 20 — outlives the paste | chosen expiry |
user:<email>:pastes |
list | ids created, capped at 50 | 7 days |
rl:<email>:<minute> |
string | reveal rate limiting | 120s |
Everything expires on its own; there is no cleanup job and no relational database. The session cookie is the user record.
- The server is blind. No code path can decrypt a paste.
crypto.mjslives underweb/static/for a reason: if Go ever imports that logic, the end-to-end property is being broken. - Ids are UUIDv4 — 122 bits of CSPRNG randomness. Reveals are additionally rate limited to 30/minute/user.
- Sessions are HMAC-signed and compared in constant time,
HttpOnly,SameSite=Lax, scoped toBASE_PATH,Secureon HTTPS origins. - OAuth CSRF: a
statenonce matched against a short-lived cookie, compared in constant time. Unverified Google addresses are rejected. - Headers: strict CSP,
Referrer-Policy: no-referrer(decrypted text must never ride along in aReferer),X-Frame-Options: DENY,nosniff. - Size cap of 64 KB, enforced on the client as plaintext and on the server as encoded length.
- A compromised server could serve modified JavaScript that exfiltrates keys. End-to-end encryption in a web app rests on trusting code delivery; the CSP narrows this but does not remove it.
- Anyone holding the share key and a Google account can read the text. Send it through a channel you trust.
- A reader can screenshot or retype what they were shown. Self-destruction limits retrieval, not what a reader does afterwards.
docker compose up -d
make test # Go + browser cryptointernal/store— UUIDv4 generation, view counting, deletion at zero, TTLs, the audit log, and the concurrency tests.internal/web— auth, session tampering, payload validation, indistinguishable 404s, rate limiting, per-user scoping, both mount modes, OAuth state, and that the pages actually render.internal/session— round-trip, cookie hardening, forged and truncated cookies, wrong secret.internal/config— secure-cookie and redirect-URI derivation, production secret enforcement.test/crypto.test.mjs— round-trips, unicode, 64 KB, wrong key, tampered ciphertext, the checksum guard. Run bynode --testwith zero dependencies —node:testplus the WebCrypto globals, nopackage.json, nonode_modules.
ShareText is open source precisely because "the server can't read your text" is a claim you should be able to verify rather than take on faith. Read the encryption for yourself in web/static/js/crypto.mjs, run your own instance, or send a change.
- Run your own — Running it locally gets you going with
docker compose up -dandmake dev; Deployment covers a real host. Self-hosting means you also control the JavaScript that gets served, which closes the one gap end-to-end encryption in the browser cannot close on its own (see What this does not defend against). - Contribute — issues and pull requests are welcome. Keep the tests green (
make test), and treat anything touchingcrypto.mjsor the paste store as security-sensitive: explain the reasoning in the PR and add a test that would fail without your change.
MIT © 2026 Ajey Gore. Use it, fork it, run your own — just keep the copyright and licence notice.