seekrit
← all posts

What you would have to build to hold your users' credentials yourself

Vault

"Connect your Google account" is a button. Behind the button, on every agent platform that acts for its users, is a system that captures credentials in four different forms, encrypts them, keeps them alive, hands them to an agent without letting the agent read them, lets the user take them back, and can answer an auditor's question about any of it. Nobody budgets for that system when they add the button.

This post is the inventory. If you are deciding whether to build it, it is the list to estimate against. If you are deciding whether to use Seekrit Vault, it is the list of what the five setup steps replace.

The inventory

You needWhy it is harder than it looksWhat Vault does
A capture page your users open from a text messageIt handles third-party passwords, so it needs its own origin, a strict CSP, no third-party script, and a mobile-first design for a link opened from iMessageHosted Connect page, white-labelled with your name, logo, and consent sentence
Consent that holds upRecorded before capture, with the copy version the person saw, revocable by themRecorded per connection with its consent version; manage links let the person disconnect
Encryption with a key you can defendA key your app server holds is a key your app server's attackers hold. Per-value data keys, authenticated against where they live, so a relabelled or replayed row fails rather than decryptsFresh data key per revision, wrapped to a key that exists only in your executor's Durable Object; AAD binds project, connection, slot, provider, revision
OAuth, per providerYour own client per provider (Google requires it for Gmail), PKCE, state, the quirks each preset knows — access_type=offline, offline_access, GitHub's HTTP 200 on failurePresets for Google, Microsoft, GitHub, a generic provider; the exchange runs on your executor's origin
Refresh that survives crashesConcurrent refreshes, and single-use refresh tokens where a lost outcome means the grant is gone and a retry is a replaySingle writer per connection with a dispatch journal; refresh_outcome_unknown instead of a second use of a spent token
A way to use the credential without giving it to the agentThe agent is prompt-injectable; a token in its context is a token in a transcript. Allow rules per provider, redaction of echoes, a check that the agent is acting for the user it claimsvault.fetch: default-deny host/method/path rules, injection, streaming redaction, userRef ownership check, typed refusals
Browser sessions for sites with no APIA session blob is more dangerous than a password hash and expires without warning; restoring it needs a fenced browser, a lease, a write-back, and a plan for when the site wants a humanCapture through Live View on the person's phone, leases on fenced Browser Run sessions, fill under the password-manager rule, reauth links, CAS write-back — written up separately
Revocation that means itBy the platform, by the user, by an admin; must stop in-flight saves, delete the ciphertext, and leave a record of who did itImmediate refusal of every further release; ciphertext deleted; revokedBy recorded; late write-backs lose the CAS
An end-user control surfaceYour users should be able to see and cut what the agent can reach without filing a ticketManage links: every connection you hold for them, with Disconnect
EventsSigned, retried, replay-resistant, carrying no valuesconnection.ready / reauth_required / revoked, HMAC-SHA256, retried with backoff for about a day
AuditEvery mutation, who did it, never a value; releases high-volume enough to need metering rather than rowsEvery mutating route audits synchronously into your org's audit log; releases metered; denied releases are durable rows with a reason
Rate limits that fail closedA stolen key or a runaway agent should be loud, not expensivePer platform key, per executor, per Connect session and address
OperationsKey rotation, replacing a compromised executor, reading why a connection stopped workingstatus smoke test, rotate-key, disable-and-replace, a reason on every reauth_required

Twelve rows. A reasonable engineer looks at any one of them and says "a sprint." The problem is that they are coupled: the revocation design depends on the write-back design, which depends on the refresh design, which depends on where the key lives. And the system is on the critical path of every action the agent takes for every user, so it has to work before the agent does.

The row that decides the others: where does the key live?

Every design decision in the table follows from one choice. If the key that decrypts your users' credentials lives on your application servers, then your database is a credential store, your backend is in the plaintext path, and every other row is about hardening a system that holds the thing. If the key lives somewhere your backend cannot reach, most rows get simpler and the guarantee you can offer users gets stronger.

Vault puts it in an executor: a small Worker you deploy into your own Cloudflare account from a template. The private key is generated inside its Durable Object and never exported. Seekrit holds ciphertext it cannot open. Your backend holds a platform key that can open connect links and read metadata but cannot release a credential. The trust model walks through why a breach of either side — or both — comes up short.

The five-step version

Vault's design target was a platform's first credential working in under fifteen minutes, in at most five steps, with three environment variables on the backend. Here is what that looks like against the table above.

  1. Dashboard → Vault → New project, then copy the skv_… platform key.
  2. Deploy the executor and declare your providers:
npx @seekrit/vault-executor init my-vault-executor
cd my-vault-executor && npm install
npx wrangler secret put EXECUTOR_TOKEN        # any long random string
npx wrangler secret put GOOGLE_CLIENT_SECRET  # one per OAuth provider, if any
npx wrangler deploy
  1. Register it with a one-time code from the dashboard:
npx @seekrit/vault-executor register <code> --url https://my-vault-executor.<you>.workers.dev
  1. Three variables on your backend: SEEKRIT_VAULT_KEY, VAULT_EXECUTOR_URL, VAULT_EXECUTOR_TOKEN.
  2. Code:
import { Vault } from "@seekrit/sdk/vault";

const vault = new Vault();

const link = await vault.connect.create({ userRef: "u_123", providerId: "google-gmail" });
// text link.url to the user; vault.connect.get(link.id) or a webhook tells you when it completes

const res = await vault.fetch({
  userRef: "u_123",
  connectionId,
  request: { method: "GET", url: "https://gmail.googleapis.com/gmail/v1/users/me/labels" },
});

You need a Cloudflare account on the Workers Paid plan (the executor uses Durable Objects) and a seekrit organization with Vault on its plan; the unit is active connections. npx @seekrit/vault-executor status --url … tells you whether the executor is up, registered, and missing any OAuth secret.

What you still own

Buying custody does not buy the product. You still own:

  • The agent and its tools. Vault is custody, not usage. There is no send_email; your agent's Gmail tool makes the request and Vault makes it authenticated.
  • Your OAuth clients and their verification with each provider.
  • Your browsers, if you use browser sessions: Browser Run in your account, on your plan.
  • The decision of what to connect. Keep provider rules narrow; the executor enforces hosts, methods and paths, and cannot judge intent.
  • What you tell your users. The Connect page says neither you nor seekrit can read their credentials; the trust model is the page to point them at for why.

That is a shorter list than the table, and every item on it is something only you could own anyway.

The guide has every step above in detail; the API reference has the three surfaces and which credential opens each.