Documentation

Vault Items

What a vault item is made of, the item types, and how items are created, read, updated, and deleted.

What a vault item is

A vault item is a single stored secret — one login, one note, one card, one identity. Every item has two distinct halves, and understanding the split is the key to understanding everything else on this page:

  • The secret — the sensitive content (title, username, password, URL, notes). This is encrypted in your browser and stored only as ciphertext.
  • The metadata — the non-sensitive bookkeeping (a database id, the item type, a favorite flag, which folder it's in, timestamps). This is stored in plain form because the app needs it to list, sort, filter, and count items without ever decrypting anything.

The server only ever sees the second half plus an opaque blob for the first. It can tell you have a login created last Tuesday; it cannot tell which login or what's in it.

Anatomy of an item

FieldHalfStored asServer can read?
idmetadataplainYes
typemetadataplainYes
favoritemetadataplainYes
folder_idmetadataplainYes
created_atmetadataplainYes
updated_atmetadataplainYes
ciphertextsecretencryptedNo
ivsecretplain*Yes (but useless alone)

* The iv (initialization vector) is a small random value paired with each ciphertext. It isn't secret on its own — it only has meaning together with the key you hold, which the server never has.

Everything inside ciphertext — the secret payload — decrypts in the browser to an object like this:

Secret fieldMeaningRequired
serviceWhich known service (or "other")Yes
titleDisplay name, e.g. "Personal Gmail"Yes
customNameA custom label, used when service is "other"No
usernameUsername or email (logins)No
passwordThe password (logins)No
urlThe website (logins)No
notesFree-form textNo

Only service and title are guaranteed to be present; the rest are included only when filled in.

Item types

The type field puts each item into one of four buckets. It's plain metadata, so it powers the filter tabs and lets the list be organized without decryption.

TypeWhat it represents
loginA username/password account
noteA secure free-form note
cardA payment card
identityPersonal identity details

A practical detail about the current implementation: the login type collects the full set of fields (username, password with a generator, website), while the other types capture a title and notes. In other words, type today is mostly an organizing label, with logins carrying the extra structured fields. The encrypted payload simply stores whatever fields are present, so types can be enriched later without changing how storage works.

The service field and display names

Beyond its type, a login usually belongs to a recognizable service (Gmail, Instagram, etc.), chosen from a list. The service controls the little colored chip shown next to the item.

There's one special value, "other", for anything not in the list. When an item uses "other", you can give it a customName, and the app shows that custom name in place of the service. The display name resolves like this:

  • If service is "other" → show customName (falling back to title).
  • Otherwise → show title.

The item lifecycle

Create

When you save a new item:

  1. The browser assembles the secret payload from the form (dropping empty fields) and encrypts it with your Vault Key, producing ciphertext and a fresh iv.
  2. It sends only { type, ciphertext, iv, folderId } to the create endpoint.
  3. The server stores the row — but only if you're under your plan's item limit (see below) — and returns the new item's metadata.
  4. The browser keeps the decrypted secret it already had and prepends the new item to the list, so it appears instantly.

The plaintext title, password, and so on never travel to the server. It receives ciphertext and a few non-secret labels.

Read (list)

The list endpoint returns every one of your items as metadata + ciphertext, newest-updated first. The browser then decrypts each item's ciphertext with the Vault Key to reveal the secret for display. Decryption happens once, on load, entirely client-side.

Update

Editing works like creating: the browser re-encrypts the full secret into a new ciphertext/iv and sends it to the per-item endpoint. Toggling favorite is also an update — the favorite flag is metadata, but the item is saved through the same path so its encrypted payload stays consistent.

Delete

Deleting removes the row by id. The UI asks for a quick confirmation first (a "Delete / No" prompt) to prevent accidental loss, then calls the per-item delete endpoint and drops the item from the list.

Plan limits and the atomic insert

Each plan defines an item_limit, and each user has a running item_count. A naive implementation would (1) read the count, (2) compare it to the limit, then (3) insert — but between steps 1 and 3, a second request could slip in and you'd exceed the limit. That's a classic race condition.

Opaque avoids it by doing the check, the insert, and the counter bump in one atomic SQL statement:

  • Look up the user's item_count and the plan's item_limit (only if the vault is initialized).
  • Insert the new item only if item_count < item_limit.
  • Increment item_count only if the insert actually happened.

If the limit is already reached (or the vault isn't initialized), the insert matches no rows, the statement returns nothing, and the API responds with a clear 403. Because it's a single statement, there's no window for two concurrent requests to both "win," and no need for a multi-step transaction held open across the network.

Gotcha: this query joins the plans table to read item_limit. If plans has no matching row, the lookup finds nothing and item creation fails. Seed the plans table before creating items.

Why search runs in the browser

The list page has a search box and filter tabs. Filtering by type or favorite uses metadata, so it could in principle happen anywhere — but search over titles, usernames, and URLs cannot happen on the server, because those live inside the encrypted payload the server can't read.

So search runs client-side (via Fuse.js) over the already-decrypted items, after the vault is unlocked. It's fuzzy and instant because everything it searches is already in memory. There is no server-side search endpoint, and by design there can't be one.

Developer reference

The two halves correspond to two TypeScript shapes: a server row and the decrypted item the UI works with.

// What the server stores and returns (metadata + opaque blob).
interface VaultItemRow {
  id: string;
  type: "login" | "note" | "card" | "identity";
  ciphertext: string;
  iv: string;
  favorite: boolean;
  folder_id: string | null;
  created_at: string;
  updated_at: string;
}

// The decrypted secret, revealed only in the browser.
interface VaultSecret {
  service: string;
  title: string;
  customName?: string;
  username?: string;
  password?: string;
  url?: string;
  notes?: string;
}

// What the UI actually holds after unlock.
type DecryptedItem = VaultItemRow & { secret: VaultSecret };

Creating an item, end to end:

// In the browser: encrypt, then send only ciphertext + non-secret labels.
const { ciphertext, iv } = await encrypt(JSON.stringify(secret), vaultKey);

const res = await fetch("/api/vault/items", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ type, ciphertext, iv }),
});

// The response is metadata only; keep the secret you already encrypted.
const { item } = await res.json();
setItems((prev) => [{ ...item, secret }, ...prev]);

A few rules that follow from the model:

  • Never send a plaintext field. Title, password, URL, notes — all of it goes inside ciphertext. The request body should only ever carry the blob, the IV, the type, and folder placement.
  • Fresh IV every time. Each create or update produces a new random IV. Reusing an IV with the same key is unsafe.
  • Validate type server-side. The API only accepts the four known types.
  • The server never decrypts. It validates ownership and shape, enforces the limit, and stores blobs — nothing more.

Where to go next

  • Key Management and KDF — where the Vault Key that encrypts items comes from.
  • Zero Knowledge Model — why the server can store items it can't read.
  • Architecture — how the item routes and database fit together.