Self-destructing secret sharing. Like OneTimeSecret, self-hosted on a Raspberry Pi.
- Paste a secret on the home page, choose an expiry (
first_view,1h,6h,12h,1d,1w), optionally set a password. - The server generates a random AES-256-GCM key, encrypts the plaintext, and stores only the ciphertext in SQLite. The key is never persisted.
- You get a private URL like
https://shh.example.com/s/<uuid>#<key>. The key lives in the URL fragment so it never reaches the server in normal request logs. - The recipient opens the URL, clicks to reveal, and (if password-protected) enters the password. The server uses the key from the request body to decrypt the ciphertext.
- If
first_view, the row is deleted in the same transaction. Otherwise, a sweeper runs every minute and deletes anything past itsexpires_at.
- DB-only compromise: nothing is recoverable. Encryption keys are not in the DB.
- DB + URL: the secret is recoverable. If a password was set, the password must also be cracked (bcrypt, 12 rounds). For high-stakes secrets, set a strong password.
- Server compromise during reveal: an attacker controlling the server can intercept the decrypted plaintext. This app cannot defend against that.
- XSS: secrets are rendered as text inside
<pre>, which React auto-escapes, and there is no rich-text rendering — that, not CSP, is the actual defense. The CSP allows'unsafe-inline'for scripts because Next 16's hydration scripts don't reliably carry a nonce; it is there to restrict what external resources can load (default-src 'self',object-src 'none',frame-ancestors 'none'), not to stop inline execution. Seeproxy.ts. - Link-preview burn: viewing requires a click, so chat clients that prefetch URLs (Slack, iMessage, etc.) won't burn first-view secrets.
- On-device cache: the service worker's cache is opt-in, not opt-out. Only
/_next/static/*(immutable build output) and/icons/*are ever written to CacheStorage./s/*,/created/*and/api/*are hard-denied before any caching strategy runs, and no navigation response is cached — so a revealed secret can't be replayed from disk after it self-destructs.
The app is installable on desktop and mobile: app/manifest.ts serves
/manifest.webmanifest, and public/sw.js provides the offline behaviour that
browsers require before offering an install prompt.
The service worker is registered in production only
(components/ServiceWorkerRegistrar.tsx). Under next dev it actively
unregisters instead, because it serves /_next/static/* cache-first — true of a
production build, but not of dev, where those URLs change on every recompile.
Offline, every navigation falls back to /offline. Nothing else is cached; see
the on-device cache note in the threat model above. After changing the caching
rules, bump CACHE_VERSION in public/sw.js to invalidate existing clients.
Icons are generated from public/icons/icon.svg and public/icons/maskable.svg:
cd public/icons
rsvg-convert -w 192 -h 192 icon.svg -o icon-192.png
rsvg-convert -w 512 -h 512 icon.svg -o icon-512.png
rsvg-convert -w 192 -h 192 maskable.svg -o maskable-192.png
rsvg-convert -w 512 -h 512 maskable.svg -o maskable-512.png
rsvg-convert -w 180 -h 180 maskable.svg -o apple-touch-icon.pngnpm install
npm run devOpen http://localhost:3000.
The easiest way to self-host. Multi-arch images (amd64, arm64) are
published to GHCR, so a NAS or Raspberry Pi can pull rather than build.
cp .env.docker.example .env
$EDITOR .env # set SHH_BASE_URL
mkdir -p data && chown 1000:1000 data
docker compose up -dThe app listens on 127.0.0.1:3011 and expects a reverse proxy in front;
docker-compose.caddy.yml is an optional overlay that adds Caddy with
automatic HTTPS. All configuration is read at runtime, so no rebuild is ever
needed to change a setting.
See DOCKER.md for proxy requirements, backups, volume
permissions, and NAS-specific notes. One setting deserves attention:
SHH_TRUSTED_PROXY_HOPS tells the app how many proxies are in front, which is
what keeps rate limits keyed to the real client rather than to a value the
client can forge.
One-time:
cd deployment
cp .env.deploy.example .env.deploy # set PI_USER / PI_HOST / DOMAIN_NAME
./setup-pi.shEach deploy:
./deployment/deploy.shNginx config and systemd unit live in deployment/. Service runs on port 3011.
There is a versioned HTTP API at /api/v1 for programmatic clients — an
Omarchy plugin, a CLI, a script. See PLUGIN_API.md for the
full contract.
curl -sS https://shh.example.org/api/v1/info # capability discovery
curl -sS -X POST https://shh.example.org/api/v1/secrets \
-H 'Content-Type: application/json' \
-d '{"plaintext":"hunter2","expiry":"1h"}' # -> { url, id, key, ... }Unlike the browser route, create returns a complete shareable url: a desktop
client has no window.location, and an instance behind a proxy can't infer its
own public origin. Set SHH_BASE_URL so it doesn't have to guess.
Because anyone can self-host shh, a client must treat the server address as
user configuration and validate it against /api/v1/info — never hardcode one.
Auth is optional and off by default. Set SHH_API_TOKENS and /api/v1
requires Authorization: Bearer <token>; the browser routes stay open either
way, since the web UI has nowhere to hide a token. All configuration is read at
runtime, so changing it in the systemd EnvironmentFile and restarting is
enough — no rebuild. See .env.example for the full list.
The routes under /api/secrets are internal to the web UI and unversioned;
both surfaces share one implementation (lib/secrets.ts) so they can't drift.
- Plaintext: 100 KB max.
- Reveal attempts: 10 per IP+id per 5 minutes, shared across the web UI and API.
- Secret creation: 60 per IP per hour (
SHH_CREATE_RATE_LIMIT,0disables). - Plain text only — no file upload, no markdown.
Bug reports and pull requests are welcome. CI runs lint, typecheck, build, and a Docker smoke test on every PR.
Found a security issue? Please don't open a public issue — see SECURITY.md for private reporting.
MIT © Jeff Mueller