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, answering 429) in front of express-rate-limit in the app. The request path is Cloudflare → Nginx Proxy Manager → the app’s nginx, so nginx’s realip module recovers the visitor’s IP from X-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-store on 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.