Sending a password over email or Teams means it sits in someone’s mailbox (and a backup, and a search index) forever. I wanted something simple to hand credentials to customers and colleagues: generate a strong password, get a link, and have that link stop working after it’s been read.
So I built one. You can try it at password.ntcu.be.
What it does
- Generates passwords in the browser with
crypto.getRandomValues(). You pick the length and character sets; the generated password never touches the server unless you choose to share it. - Shares a secret as a link, with either:
- an expiry time,
- one-time access (deleted after the first read), or
- a view limit (1 to 50 views).
- Encrypts in the browser. The server only ever sees ciphertext; the key lives in the link itself.
- Custom branding through environment variables (logo, colours, title), so it can look like your company’s tool.
Architecture
Three containers in a Docker Compose stack:
internet ──► nginx ──► app (Node.js / Express) ──► Redis
(public) (internal network only) (internal network only)
The app and redis containers sit on a Docker network marked
internal: true, so they have no route to the outside world at all. Only nginx
is exposed.
Encryption: the server can’t read your secrets
The secret is encrypted in the browser, with the Web Crypto API, before anything is sent. Each secret gets its own random 256-bit AES-GCM key:
async function encryptForLink(plaintext) {
const keyBytes = crypto.getRandomValues(new Uint8Array(32));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt"]);
const data = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv, additionalData: AAD },
key,
new TextEncoder().encode(plaintext)
);
return {
ciphertext: { v: 1, iv: toBase64Url(iv), data: toBase64Url(new Uint8Array(data)) },
linkKey: toBase64Url(keyBytes),
};
}
Only the ciphertext goes to the server. The key goes into the share link, after
the #:
https://password.ntcu.be/3f2a…c91#k3Y9xQ…
└─ id ─┘ └─ key: never sent to any server
Browsers never send the part after # (the fragment) in a request. Not to my
server, not to Cloudflare in front of it, not to the reverse proxy, and it
doesn’t end up in any access log. The recipient’s browser fetches the
ciphertext by id, takes the key from its own address bar and decrypts locally.
After a successful reveal, the key is removed from the address bar.
The server adds its own AES-256-GCM layer on top before storing it in Redis, so a stolen Redis dump is double-encrypted noise.
The one thing this can’t protect against is a compromised server serving different JavaScript. That’s true for every web-based tool like this (PrivateBin, Yopass, Bitwarden Send). A strict Content-Security-Policy and zero third-party scripts keep that surface small.
Click to reveal
Opening a link doesn’t fetch anything. The recipient sees “Someone shared a secret with you” and a Reveal secret button. That matters more than it sounds: Outlook Safe Links, Teams and Slack previews “visit” links before a human does. If opening the page fetched the secret, a one-time link would be burned by a bot before the real recipient ever saw it.
Let Redis do the expiring
Every secret is stored with Redis’s EX option, so expiry isn’t something my
code has to remember to clean up: when the TTL runs out, the key is simply gone.
- One-time secrets are deleted as part of the read itself.
- View-limited secrets carry a counter; each read decrements it (keeping the remaining TTL), and the last read deletes the key.
Reading and consuming a view happen in one small Lua script, which Redis runs
atomically. Two requests hitting a one-time link at the same moment can’t both
get the secret: fire 10 in parallel and you get exactly one 200 and nine
404s.
Persistence is optional. With AOF enabled, secrets survive a container restart. With it disabled, a restart wipes everything, which for some people is a feature.
Hardening
- Two layers of rate limiting per client IP: nginx (
limit_req, 10 requests/s with a small burst, answering429) in front ofexpress-rate-limitin the app. The request path is Cloudflare → Nginx Proxy Manager → the app’s nginx, so nginx’srealipmodule recovers the visitor’s IP fromX-Forwarded-For, trusting only private addresses and Cloudflare’s ranges. Otherwise every visitor would share one rate-limit bucket. - Strict headers: Helmet plus nginx set a tight Content-Security-Policy (no
inline scripts, no framing),
nosniff,no-referrer. Cache-Control: no-storeon secret responses, so they don’t end up in a browser or proxy cache.- Boring 404s: secret IDs are UUIDv4 and validated; invalid, expired and
already-read secrets all return the same
404 Not found. - Input limits: 4096 characters per secret, 1 MB request bodies, expiry capped at 30 days.
Deployment
It runs on my homelab Docker host and deploys with the same push-to-deploy
pipeline as everything else (see
Self-hosting my code): a push to main
builds the image on my runner, pushes it to my Forgejo registry and the Docker
host pulls it.
What’s next
- An optional passphrase, sent through a second channel (SMS, a call), so a leaked link alone isn’t enough. The ciphertext already carries a version number, so this can be added without breaking existing links.
It’s a small project, but it replaced a lot of “I’ll send the password in a separate email” moments.