DEV Community

Cover image for I built a zero-knowledge secret sharing tool because I was tired of passwords in Slack
Maxim Novak
Maxim Novak

Posted on • Originally published at vaulted.fyi

I built a zero-knowledge secret sharing tool because I was tired of passwords in Slack

Someone on my team pasted a database password into a Slack DM. Six months later, that password was still sitting there — searchable, exportable, backed up to who-knows-where.

That's when I decided to build Vaulted: encrypted, self-destructing secret sharing. No accounts, no plaintext on the server, no lingering credentials. You
paste a secret, get a link, and it burns after reading.

Here's how I built it and every technical decision along the way.

## The core idea

The threat model is simple: the server should never be able to read your secret, even if fully compromised.

That rules out server-side encryption (the server would hold the key). It also rules out storing the key in a database or a cookie. The entire encryption and decryption flow has to
happen in the browser, and the key has to travel through a channel the server can't see.

Enter URL fragments.

## The encryption trick that makes it work

When you create a secret on Vaulted, here's what happens:

  1. Your browser generates an AES-256-GCM key using the Web Crypto API
  2. It encrypts your secret locally — plaintext never leaves your device
  3. Only the ciphertext + IV get sent to the server
  4. The encryption key goes into the URL fragment (#)

That last part is the key insight (pun intended). Per RFC 3986, URL fragments are never sent to the server in HTTP requests. The
browser strips everything after # before making the request. So the link looks like:

https://www.vaulted.fyi/s/abc123#Ek9mN2x...
↑ never hits the server

The recipient opens the link, the browser reads the fragment, imports the key, and decrypts locally. The server only ever sees an encrypted blob.

## The crypto implementation

I used the Web Crypto API directly — no third-party crypto libraries. Here's the core of it:


typescript
  const ALGORITHM = 'AES-GCM'
  const KEY_LENGTH = 256
  const IV_LENGTH = 12

  async function generateKey(): Promise<CryptoKey> {
    return crypto.subtle.generateKey(
      { name: ALGORITHM, length: KEY_LENGTH },
      true,
      ['encrypt', 'decrypt']
    )
  }

  async function encrypt(
    plaintext: string,
    key: CryptoKey
  ): Promise<{ ciphertext: string; iv: string }> {
    const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))
    const encrypted = await crypto.subtle.encrypt(
      { name: ALGORITHM, iv },
      key,
      new TextEncoder().encode(plaintext)
    )
    return {
      ciphertext: bufferToBase64url(encrypted),
      iv: bufferToBase64url(iv.buffer),
    }
  }

  AES-256-GCM gives you both confidentiality and authentication (it'll reject tampered ciphertext). The IV is randomly generated per secret, and the key is exported as base64url for the
  URL fragment.

  Optional passphrase layer

  For higher-security scenarios, users can set a passphrase. This wraps the AES key using PBKDF2 (100,000 iterations, SHA-256) + AES-KW. Even if someone intercepts the link, they can't
  decrypt without the passphrase. You share the passphrase through a separate channel — a phone call, a different chat, whatever.

  async function wrapKeyWithPassphrase(
    key: CryptoKey,
    passphrase: string
  ): Promise<{ wrappedKey: string; salt: string }> {
    const salt = crypto.getRandomValues(new Uint8Array(16))
    const wrappingKey = await deriveWrappingKey(passphrase, salt)
    const wrapped = await crypto.subtle.wrapKey(
      'raw', key, wrappingKey, 'AES-KW'
    )
    return {
      wrappedKey: bufferToBase64url(wrapped),
      salt: bufferToBase64url(salt.buffer),
    }
  }

  When a passphrase is used, the URL fragment contains wrappedKey.salt instead of the raw key. The recipient enters the passphrase, the browser derives the wrapping key, unwraps the AES
  key, and decrypts.

  The stack

  - Next.js 16 (App Router) — server components by default, client components only where needed
  - Upstash Redis — serverless Redis for storage with TTL-based auto-expiry
  - Web Crypto API — all cryptographic operations, zero dependencies
  - Tailwind CSS 4 + shadcn/ui — for the UI
  - Vercel — deployment

  Why Redis?

  Secrets are ephemeral by nature. Redis gives me:

  - TTL-based auto-expiry — set it and forget it, Redis deletes the secret automatically
  - Atomic view counting — HINCRBY increments the view count and I check against the limit in one operation
  - Hash storage — each secret is a hash with fields for ciphertext, IV, view count, max views, etc.

  When the view limit is reached, the secret is deleted immediately. When the TTL expires, Redis handles it. No cron jobs, no cleanup scripts.

  Why no database?

  A traditional database would work, but it's overkill. Secrets have no relationships, no queries beyond key lookup, and they're designed to be deleted. Redis is a natural fit.

  The API is three endpoints

  That's it:
  ┌──────────────────────────┬────────┬───────────────────────────────────────────────────┐
  │         Endpoint         │ Method │                      Purpose                      │
  ├──────────────────────────┼────────┼───────────────────────────────────────────────────┤
  │ /api/secrets             │ POST   │ Store ciphertext + metadata                       │
  ├──────────────────────────┼────────┼───────────────────────────────────────────────────┤
  │ /api/secrets/[id]        │ GET    │ Return ciphertext, consume a view                 │
  ├──────────────────────────┼────────┼───────────────────────────────────────────────────┤
  │ /api/secrets/[id]/status │ GET    │ Check if secret exists (without consuming a view) │
  └──────────────────────────┴────────┴───────────────────────────────────────────────────┘
  The status endpoint exists so the UI can show "this secret requires a passphrase" before the user commits to viewing it.

  Self-destructing links

  Secrets self-destruct in two ways:

  1. View limit reached — after the configured number of views (1, 3, 5, or 10), the Redis key is deleted
  2. TTL expiry — after the configured time (1 hour to 30 days), Redis auto-deletes

  The view count is atomic. When someone views a secret, the server increments the count and checks the limit in one operation. If the count hits the max, the key gets deleted right there
  — no race conditions.

  For unlimited-view secrets (useful for sharing something with a team), the view count still increments but never triggers deletion. Only the TTL applies.

  Rate limiting

  I use Upstash Ratelimit with sliding window limits per IP:

  - Creating secrets: 20 per 10 minutes
  - Viewing secrets: 60 per 10 minutes

  This runs in Next.js 16's proxy handler (proxy.ts), which intercepts all /api/* routes before they hit the route handlers.

  What I'd do differently

  1. The 1,000 character limit is intentional but polarizing. Some people want to share entire .env files. I chose to keep it small because: the encryption overhead scales with payload
  size, it keeps the stored data minimal, and most secrets (passwords, API keys, connection strings) fit comfortably. But I might increase it to 5K eventually.

  2. No accounts was the right call. Zero-knowledge + anonymous usage means there's nothing to breach in a user database. No passwords to hash, no sessions to manage, no GDPR headaches.
  The tradeoff is you can't see your own history — but that's a feature, not a bug.

  Who actually uses this?

  Since building Vaulted, I've seen the same patterns come up repeatedly:

  Developers sharing API keys. You get a new third-party API key and need to send it to a colleague. Pasting it in Slack means it's searchable forever. Vaulted link → 1 view → gone.

  Teams onboarding new hires. Initial passwords, Wi-Fi credentials, VPN configs. Create a link, send it in the onboarding email, it self-destructs after they open it.

  Freelancers exchanging credentials with clients. Database passwords, hosting logins, SSH keys. Clients don't need to install anything — they just click a link.

  Anyone who's ever texted a password. SSNs, tax IDs, insurance numbers. If you'd normally send it in a text message, Vaulted is strictly better.

  CI/CD pipelines. There's a https://www.vaulted.fyi/github-action (vaulted-fyi/share-secret@v1) that creates encrypted links from your workflows. And a https://www.vaulted.fyi/cli (npx
  vaulted-cli) for terminal workflows.

  Try it

  https://www.vaulted.fyi — free, no signup, zero-knowledge encryption. The https://github.com/maximn/vaulted.

  If you have questions about the crypto implementation or want to poke holes in the security model, I'd genuinely love to hear it.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)