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, recovers a forgotten password with recovery words, 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 |
|---|---|
| The HTTP server and route table. |
| Every flag, its environment variable and default. Configuration is read only here. |
| Request and response types, the encryption invariant and the authentication rules, as one contract for client and server. |
| The handlers. |
| The schema and its version check. |
| The app's logic: login, vault decryption, profile publishing, delegation, vault connect. |
| Browser crypto helpers (WebAuthn, PRF, recovery words) 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. |
Recovery words | 12 BIP-39 words chosen at password sign-up encode 128 bits, which HKDF turns into the secret of a secret credential tagged | The same as a secret. A person has at most one: saving new words deletes the old credential. |
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.
A person who signs up with a password also saves recovery words. The vault shows them once, and the person copies them, downloads them as a text file, or writes them down. If the password is forgotten, the words, typed in or uploaded as that file, sign in through /vault/api/login/recovery and unwrap the DEK through their own credential. Signing in this way does not reset the password; the person can change it in settings afterwards. A password user without recovery words, because they left sign-up early or set the password before recovery words existed, sees a reminder in the vault, and on the consent screen before returning to a site, until they save them. These words unlock the vault only. They are not the recovery phrase of an account key.
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. The desktop app reads the same signature through its daemon's GetVaultEmailPrevalidation and presents it to the Seed hosting service, which lets a new user skip the email login.
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 |
|---|---|
| The app: login, choose credential, verify email, recovery words, create profile, delegate, connect, settings. |
| Public config: backend URL, notify server, web base URL. |
| Authentication, with an HTTP-only session cookie. |
| Read and save the encrypted vault. |
| Manage passkeys, passwords, device secrets and the email address. |
| The connect mailbox. |
| Account lookup through the daemon. |
| The app reports the public account IDs its vault holds, each claim signed by that account's key. See Invite-only mode and the admin API. |
| Public lookup of an unused invitation: the email it is for, or 404. The invited-user screen uses it. |
| The admin API, for the Enterprise Edition. Bearer-authenticated with the admin secret; answers 404 when none is configured. |
Configuration
Every setting is a flag with an environment variable behind it.
Flag | Variable | Default |
|---|---|---|
|
| 3000 (3030 in development) |
|
|
|
|
| required; the WebAuthn relying party, |
|
|
|
|
| the relying-party origin |
|
| the backend HTTP URL |
|
| same origin |
|
| unset, which disables email |
|
| unset; when set, verification emails go as |
|
| off; |
|
| unset, which disables the admin API |
none |
|
|
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. Migrations live in src/sqlite.ts beside the schema and run at startup; the schema version is 6 as of October 2026 (the last three added the invitations and account-claim tables, then the invitation role and grants).
Invite-only mode and the admin API
The Enterprise Edition runs the vault on a private site where only invited people may create an account. Two settings turn this on: SEED_VAULT_INVITE_ONLY=true and SEED_VAULT_ADMIN_SECRET, a bearer secret for the admin API. Signing in to an existing account is unchanged.
Every invitation is for one person. An operator mints one with POST /vault/api/admin/invitations (Authorization: Bearer <admin secret>, body {email, role?, send?}; in invite-only mode email is required and count, if given, must be 1), which returns a random code and a ready-to-share link of the form <site>/hm/sign-in?invite=<code>&email=<url-encoded email> (the site is --web-base-url, or the vault's own origin): the site's sign-in page starts a delegation to the vault with the invitation attached, so creating the account and signing in to the site are one flow and the person lands on the site as a member. Outside invite-only mode the link is <origin>/vault?invite=<code>. role is reader (the default: vault membership, which the site's sign-in gate turns into read access) or writer. Unless send is false the vault emails the link to that address through its configured sender (the hosting relay on an enterprise node); the response says emailed: true, or emailed: false with an emailError when the send failed, and the link is returned either way so the operator can pass it on by hand. A writer invitation also grants a WRITER capability on the site's space: once the person has an account, the vault learns its public ID from the account-claim call described below, signs the capability with the enterprise-owner key in the daemon's keystore, and records the grant in invitation_grants so it happens once per account; a failed grant is retried on the next claim. Only that address can register with the code: registerStart and registerVerify both refuse any other email, and a code with no email attached is refused outright. GET on the same path lists invitations with their role, used state and, for writers, the accounts that have received the capability (grants). GET /vault/api/invitations/:code is public and answers {email, valid: true} for an unused code and 404 for an unknown, used or unbound one; it never reveals anything else.
Reaching the vault with an invitation (through the site's sign-in, or by an older link straight to /vault?invite=…, which the vault first bounces through the site so that the delegation is in place) shows the invited-user screen instead of the usual sign-in form: "You've been invited to <relying-party name>" (the --rp-name setting, which /vault/api/config now reports as relyingPartyName; the host name when it is empty), the email fixed and not editable, and a single Continue button. There is no code field, no terms line and no sign-in option, because the invitation already says who the person is and they have no account to sign in to. The app takes the email from the link and then from the lookup, so a link that lost its query string still works, and a used or invalid code shows a plain message with no form. Continue sends the email and the code to POST /vault/api/register/invited, which creates the account at once and starts the session: the link was itself emailed to that address, so there is no second verification code. The invitation is marked used in the same database transaction that creates the account, so a code works exactly once; from there the flow is the usual one: pick a passkey or password, set up the vault. Without ?invite= in the URL the regular sign-in screen is unchanged, and in invite-only mode it says that a code is required to create a new account.
The admin listing (GET /vault/api/admin/invitations) reports, for each invitation, the role, whether and when it was used, the writer grants made so far, and accounts: the account IDs the invited person's app has reported, so an operator can find the person's profile on the site.
GET /vault/api/admin/accounts/:accountUid tells a site which vault user, if any, holds a given public account ID: 200 {accountUid, email, createTime} or 404. The web app uses it at sign-in to decide membership. For this to work the vault keeps one piece of per-account state in plaintext that the hosted vault never had: a table of public account IDs per vault user. The app reports its IDs after it decrypts the vault and after every save (POST /vault/api/vault-accounts), and every entry carries a signature by that account's key over seed-vault-account:<origin>:<vault user id>:<account id>, so a user cannot register an ID they do not control; an ID already registered to another user is refused. The account keys themselves still never leave the encrypted blob, but on an invite-only vault the server now knows which public accounts belong to which email.
Running it
cd vault
bun run dev # :3030 with hot reload, against the web app on :3000 and notify through it (:3000/hm/api/notify → :3060)
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
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.