The identity vault
The Bun service at hyper.media/vault that stores a person's account keys end-to-end encrypted, signs them in with a passkey or password, lets websites act for them through delegated session keys, and syncs keys to the desktop and mobile apps.

The vault is where a person's Hypermedia account keys live when they do not want to manage a recovery phrase. It works like a password manager: the keys are encrypted in the browser with a key only the person can derive, and the server stores ciphertext it cannot read. Keys covers the other ways to hold a key. From the vault a person creates an account, signs in with a passkey or a password, approves websites that want to act for them, and connects the desktop and mobile apps so the same accounts appear there. The hosted vault is https://hyper.media/vault, and in the interface it is called "Identity".

Where the code is

vault/, package @seed-hypermedia/vault: a separate Bun workspace outside the pnpm workspace. The server is Bun.serve with bun:sqlite; the frontend is a React single-page app with react-router and Valtio for state, built by bun build.ts. It consumes @seed-hypermedia/client, @shm/shared and @shm/ui through file: dependencies, which copy the packages at install time.

Path

What it holds

src/main.ts

The HTTP server and route table.

src/config.ts

Every flag, its environment variable and default. Configuration is read only here.

src/api.ts

Request and response types, the encryption invariant and the authentication rules, as one contract for client and server.

src/api-service.ts

The handlers.

src/sqlite-schema.sql, src/sqlite.ts

The schema and its version check.

src/frontend/store.ts

The app's logic: login, vault decryption, profile publishing, delegation, vault connect.

src/frontend/crypto.ts, src/frontend/views/

Browser crypto helpers (WebAuthn, PRF) and the screens.

The cryptography itself, key derivation and encryption, is in the SDK (@seed-hypermedia/client/vault and /encryption), so the browser and the mobile app share one implementation. The daemon, which holds the desktop app's side of a connected vault, has its own Go implementation.

The zero-knowledge design

Each person has one random data encryption key (DEK). It encrypts the vault: the account private keys, the list of delegations the person approved, and settings such as the notify server. The server stores that ciphertext in users.encrypted_data, with a version number for optimistic concurrency, so two devices saving at once cannot silently overwrite each other.

Each way of signing in is a credential that wraps the DEK with its own key, and the server stores only the wrapped copy.

Credential

How the client derives its keys

What the server keeps

Passkey

The WebAuthn PRF extension yields a secret the authenticator reproduces on every login.

Attestation data and the wrapped DEK.

Password

Argon2id in the browser, with the email as salt.

An Argon2id hash of the authentication half and the wrapped DEK.

Secret

A full-entropy random secret, used by the desktop app after vault connect; no Argon2id needed.

A SHA-256 hash of the authentication half and the wrapped DEK.

The derivation splits into two halves. The authentication key goes to the server and proves who you are; the encryption key never leaves the client and is the only thing that unwraps the DEK. Encryption is XChaCha20-Poly1305.

If you lose every credential and have no exported key or recovery phrase, nobody can recover your accounts. Email proves you own an address, with a 4-digit code and three attempts, but it cannot decrypt anything. The design also trusts the vault's origin to serve honest JavaScript, which is why the hosted vault lives on hyper.media and other sites redirect people there to sign in.

How it talks to the daemon

The vault uses the daemon behind the same site for three things.

    Publishing. Account creation and delegation build profile and capability blobs in the browser, signed with the decrypted account key, and upload them to <backend>/ipfs/<cid> on the site's daemon.

    Reading accounts. GET /vault/api/accounts/:id calls the daemon's GetAccount over gRPC-web.

    Email prevalidation. When the vault returns a person's data, it signs {email, signer, host} with the first key in its daemon's keystore through SignData. A notify service that trusts this vault accepts that signature instead of sending its own verification email.

Delegation: Sign in with Seed

A website that wants a visitor to comment or edit as their account sends them to /vault/delegate with a signed request naming a browser session key. The vault authenticates the person, shows a consent screen naming the site's origin, signs an AGENT capability from the chosen account to the session key, records the delegation in the encrypted vault, and redirects back. The web app uses this for its own sign-in, and any third-party site can use it with the SDK. The whole ceremony, with the parameters and checks, is in Sign in with Seed.

Vault connect: desktop and mobile

The desktop app keeps a local vault in its daemon, and can connect it to a remote vault so the same accounts appear everywhere. The connection is a short handshake through the vault server.

    The app mints a 32-byte token, opens /vault/connect in the browser with the token in the URL fragment, and polls /vault/api/vault-connect/<id>, where the id is a hash of the token.

    After consent, the browser creates a secret credential for the device, encrypts the credential and vault location with the token, and posts it to that mailbox.

    The app picks it up, the server deletes it, and the app can now open and sync the vault on its own. Pending mailboxes expire after 15 minutes.

The server only ever sees the encrypted payload. The token travels in the URL fragment, which browsers do not send to the server. The daemon implements the device side in backend/storage/vault, and the mobile app ports it in frontend/apps/mobile/src/vault/connect.ts.

Routes

Route

Purpose

/vault, /vault/*

The app: login, choose credential, verify email, create profile, delegate, connect, settings.

/vault/api/config

Public config: backend URL, notify server, web base URL.

/vault/api/pre-login, /login, /login/passkey/*, /register/*, /logout, /session

Authentication, with an HTTP-only session cookie.

/vault/api/vault

Read and save the encrypted vault.

/vault/api/credentials/*, /vault/api/email-change/*, /vault/api/vault-email

Manage passkeys, passwords, device secrets and the email address.

/vault/api/vault-connect, /vault/api/vault-connect/:id

The connect mailbox.

/vault/api/accounts/:id

Account lookup through the daemon.

Configuration

Every setting is a flag with an environment variable behind it.

Flag

Variable

Default

--server-port

SEED_VAULT_HTTP_PORT

3000 (3030 in development)

--server-hostname

SEED_VAULT_HTTP_HOSTNAME

0.0.0.0

--rp-id, --rp-origin

SEED_VAULT_RP_ID, SEED_VAULT_RP_ORIGIN

required; the WebAuthn relying party, hyper.media in production

--db-path

SEED_VAULT_DB_PATH

./data/vault.sqlite, /data/vault.sqlite in the image

--backend-http-base-url

SEED_VAULT_BACKEND_HTTP_BASE_URL

the relying-party origin

--backend-grpc-base-url

SEED_VAULT_BACKEND_GRPC_BASE_URL

the backend HTTP URL

--web-base-url

SEED_VAULT_WEB_BASE_URL

same origin

--smtp-*

SEED_VAULT_SMTP_*

unset, which disables email

none

SEED_VAULT_DEFAULT_NOTIFY_SERVER

https://notify.seed.hyper.media

The database schema carries a version. If it does not match the code, the server starts in a mode that only shows a schema-mismatch page and tells you to delete the database. There are no migrations yet.

Running it

cd vault bun run dev # :3030 with hot reload, against notify on :3060 and the web app on :3000 bun check # typecheck and format bun test

./dev up runs it as the vault pane. In development it proxies /hm/api/config to the web app on port 3000. The web app finds it through WEB_IDENTITY_ORIGIN or SEED_IDENTITY_DEFAULT_ORIGIN, http://localhost:3030 in development and https://hyper.media by default. Images are seedhypermedia/vault:dev and seedhypermedia/vault:latest. In the hosted deployment a reverse proxy sends hyper.media/vault and dev.hyper.media/vault to it.

Working with it

In the Seed app

Account settings in the desktop app switch between the local vault and a remote one, start vault connect, and manage the email, password and notify server of a connected vault. Connected accounts sign through the local daemon as before.

CLI

The CLI does not talk to the vault server. It reads the desktop app's local vault.json, and it can import an .hmkey.json exported from the vault. See Keys.

SDK

@seed-hypermedia/client/hmauth, part of the SDK, implements the client side of delegation: startAuth, handleCallback and createSessionSigner. The vault crypto helpers are exported for other clients.

Web API

Everything under /vault/api is JSON over HTTPS and belongs to the vault app. It is separate from the Seed API. These routes are for the vault's own frontend. Third parties use the delegation redirect described in Sign in with Seed.

Agents

Agents do not sign in through the vault. An agent acts with its own key and a capability delegated to it. See Building agents on Seed.

Where this is going

As of September 2026, three questions are open: paper keys as another credential, social recovery, and a clearer answer to why a person might need both a recovery phrase and a password. The team has also discussed moving notification subscriptions and read status into the vault.

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime