Documentation

opaque Your personal password manager

This is the deep project documentation of opaque

A zero-knowledge, end-to-end encrypted password vault built on Next.js, Clerk, and Neon.

opaque is built around one simple promise: the server never sees your secrets. Titles, usernames, passwords, URLs, notes even the name of the service get encrypted in your browser before they ever leave it. The backend only ever stores opaque ciphertext and IV blobs that it cannot read.

If the database got dumped tomorrow, an attacker would walk away with nothing but noise.


Why It's Built This Way

Zero-knowledge. Encryption and decryption both happen client-side. The API only ever touches ciphertext, so there's no code path where the server can read a plaintext secret.

Recoverable. Your Vault Key gets wrapped twice: once by a key derived from your master password, and once by a key derived from a recovery phrase. Lose the password and you can still recover with the phrase the server never holds either one.

Fast. The dashboard decrypts everything once when you unlock, then search, filtering, and favorites all run locally. The network only gets touched when you create, update, or delete an item.

Simple to run. Auth is handled by Clerk, data lives in Neon (Postgres), and every route is a plain Next.js handler. No object store, no extra services to wire up.


Vault Item Types

Each user has one vault holding typed items:

TypeWhat it stores
loginUsername / email, password, website
noteFree-form secure note
cardPayment card details
identityPersonal identity information

Items can be favorited, sorted into folders, and searched instantly all against the decrypted copy that only ever exists in your browser.


Tech Stack

LayerPackage / Version
Frameworknext 16.2.6 · react 19.2.4 · react-dom 19.2.4
Auth@clerk/nextjs ^7.4.0 · svix ^1.94.0 (webhook verification)
Database@neondatabase/serverless ^1.1.0 (Postgres)
CryptoWeb Crypto API · @scure/bip39 ^2.2.0 (BIP39 recovery phrases)
Searchfuse.js ^7.3.0 (client-side fuzzy search)
UItailwindcss ^4 · framer-motion ^12.39.0 · lucide-react ^1.16.0 · react-icons ^5.6.0
Contentreact-markdown ^10.1.0 · remark-gfm ^4.0.1 · rehype-highlight ^7.0.2
ToolingTypeScript ^5 · ESLint ^9 · @tailwindcss/typography ^0.5.19

The Crypto Model

The server stores wrapped keys and KDF parameters never the keys themselves. Unlocking the vault on the client looks like this:

// Derive a key from the master password using the stored salt + params,
// then unwrap the Vault Key, entirely in the browser.
const passwordKey = await deriveKey(masterPassword, kdf_salt, kdf_params);
const vaultKey = await unwrap(
  wrapped_vault_key,
  wrapped_vault_key_iv,
  passwordKey,
);

// Every item is decrypted locally with the Vault Key.
const secret = await decrypt(item.ciphertext, item.iv, vaultKey);

The API routes (/api/vault/init and /api/vault/items) only persist and return these blobs. They never accept or emit a plaintext secret, a master password, or a recovery phrase.

Tip: The Vault Key never changes when you rotate your master password. Only the password-derived wrapper gets re-encrypted, which is why a password change is instant and doesn't require re-encrypting every item.


Core Flows

1. Vault Initialization

The browser generates a 256-bit Vault Key, derives a KEK from the master password (Argon2id + salt), and generates a BIP39 recovery phrase. The Vault Key is wrapped twice once with the password KEK, once with a recovery KEK. Only the wrapped blobs and salt get sent to the server.

2. Unlock

Fetch the wrapped_vault_key and salt, re-derive the KEK from the typed password, and unwrap the key locally. The AES-GCM auth tag means a wrong password simply fails to unwrap no hashes are stored or compared server-side.

3. Add / Edit Item

The full entry is encrypted as JSON ({ title, username, pass, ... }) client-side. The server checks the item limit against the user's plan, then atomically inserts the row and bumps the counter in a single transaction.

4. List & Search

The server returns raw ciphertext rows. The browser decrypts them into memory, and search runs completely client-side using Fuse.js over the decrypted objects.

5. Forgot Master Password

Enter the 12-word recovery phrase, derive the recovery key, unwrap the Vault Key via recovery_wrapped_key, then immediately set a new master password which re-wraps the Vault Key. All existing entries stay untouched.

6. Account Deletion

The Clerk user.deleted webhook deletes the user row, and Postgres ON DELETE CASCADE wipes folders, items, and logs instantly. No orphaned data left behind.


Getting Started

Prerequisites

  • Node.js 18.18 or newer
  • A Clerk application
  • A Neon Postgres database

Install

git clone https://github.com/your-username/opaque.git
cd opaque
npm install

Environment Variables

Create a .env.local file in the project root:

# Clerk
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_...
CLERK_SECRET_KEY=sk_...
CLERK_WEBHOOK_SIGNING_SECRET=whsec_...

# Neon
DATABASE_URL=postgresql://user:password@host/db?sslmode=require

Run

npm run dev       # dev server at http://localhost:3000
npm run build     # production build
npm run start     # serve production build
npm run lint      # eslint

Webhook Setup

Point a Clerk webhook at /api/webhooks/clerk and subscribe to the user.deleted event. Add the signing secret to CLERK_WEBHOOK_SIGNING_SECRET — the handler verifies incoming payloads via svix.


Security Note

opaque leans entirely on client-side cryptography. The master password and recovery phrase never reach the server, so they cannot be reset for you. If you lose both, your data is genuinely unrecoverable. That's the tradeoff that makes zero-knowledge actually mean something.


License

MIT. See LICENSE for details.