seekrit
← all posts

Two breaches and still no credential: how Vault's custody works

Vault

A platform using Seekrit Vault to hold its users' credentials can be breached and leak none of them. So can seekrit. Both at once still leaves the attacker short one key, and that key exists only inside a Durable Object in the platform's own Cloudflare account, where it was generated and from which it is never exported.

That is a strong claim, and security claims deserve a mechanism rather than a sentence. This post is the mechanism: who holds what, and why each boundary holds.

Who holds what

PartyAt restIn useWhy
seekrit (API, database, staff)Cannot readCannot readStores ciphertext and metadata only — the same posture as every secret in seekrit.
Your backend, database, logsHold nothingHold nothingYour backend holds a platform key that opens connect links and reads metadata. It cannot release a credential.
Your agent (the LLM runtime)—Never receives a valueThe executor applies the credential to the outbound request and redacts any echo from the response.
Your executorHolds the only private keyDecryptsA Worker in your own Cloudflare account. Trusted by construction — it is your code, in your account.

The design rule behind the table: every component gets exactly the authority its job needs, and the component with the authority to decrypt is the one you run. Everything below is a consequence of that rule.

Why can't seekrit read it?

Because nothing seekrit holds can open anything seekrit stores. A credential is encrypted with a fresh data key, and that data key is wrapped to the executor's public key — in the user's browser for a typed key or password, or in the executor itself for an OAuth grant it just exchanged. Seekrit receives the two ciphertexts and the key's thumbprint. The private half of that key was generated inside the executor's ExecutorIdentity Durable Object and has never left it: not in an environment variable, not in a response, not in a backup seekrit takes.

This is the same zero-knowledge model every secret in seekrit already lives under. The executor is a client in exactly the sense seekrit run, the proxy, and the SDKs are — customer-side code decrypting in the customer's runtime. Vault adds no exception to it. No code path in seekrit's API decrypts, exchanges an OAuth code, or sees a password, a cookie, or a token.

Why can't your backend read it?

Because the platform key your backend holds, skv_…, authenticates a different surface than the one that releases credentials. It can open a connect link, poll its status, list a user's connections, revoke one, register a webhook. It cannot ask seekrit for a ciphertext — that route accepts only the executor's own credential, skx_…, which the executor received once at registration and keeps in Durable Object storage.

So the thing your backend does hold, if it leaks, lets an attacker open connect links in your name and read metadata. Loud, unpleasant, and rate-limited per key. Not a credential.

Why can't the agent read it?

Because it is never given one. vault.fetch carries a request — method, URL, headers, body — and no credential. The executor evaluates the request against the provider's allow rules first: which host, which methods, which paths, default-deny, the same engine seekrit's credential broker uses. Only a permitted request reaches the decrypt step. The credential is injected into the outbound call, and the response is scanned on its way back for any echo of the injected value — raw, percent-encoded, JSON-escaped, base64 — and redacted before the agent sees it.

Every call also names the userRef the agent is acting for, and the executor refuses unless the connection belongs to that user. An agent serving one user cannot reach another's connection by guessing its id.

This is custody, not usage: the agent still decides what to do and uses its own tools. Vault makes the tool's request authenticated without the tool's process holding the authentication.

Two defences against a compromise of us

Zero-knowledge storage covers the database being read. Two further pieces cover the database or API being changed, which is the more useful thing an attacker inside seekrit could try.

The link pins your executor. A connect link is https://connect.seekrit.dev/#t=…&x=…&k=…: a connect token, your executor's origin, and the thumbprint of its wrapping key. Your SDK builds that fragment from your own executor's identity, not from anything seekrit returns, and browsers never send fragments to a server. The Connect page talks to the executor at x and refuses to continue unless the key it presents has thumbprint k. An attacker who controls seekrit's API cannot redirect your users to a different executor or key, because the pin was built from a source they do not control.

Ciphertext is bound to its place. Every ciphertext is authenticated against the project, connection, slot, provider, and revision it belongs to. Relabel a Gmail connection's row as a different provider so the executor would send its token to a different host, and the ciphertext fails to decrypt. Swap in a stale revision as the current one, and it fails the same way. The database can lie about metadata; the executor will not act on the lie.

A third rule removes the authority entirely: the hosted API cannot change behaviour. Which hosts a credential may be sent to, how it is injected, and where the OAuth endpoints are all live in your executor's vault.config.ts, compiled into the Worker. Seekrit stores a display copy — names and the consent sentence — and nothing executable. Serving policy from an API that could be compromised would hand that API control over where plaintext goes, which is the same class of harm as reading it. The agent access policy follows the same rule for the same reason.

The person connecting an account has no seekrit account and never will. They are identified by your opaque userRef, which the docs tell you must not be an email address, because it is the one field about them Vault holds in the clear. Before any capture, the Connect page records consent along with the version of the copy they saw. Later, a manage link shows them every connection you hold for them and lets them disconnect any of it; that revocation is recorded as theirs, the ciphertext is deleted, and every further release is refused.

Why this shape

We could have shipped a simpler product: seekrit holds the credentials, encrypts them well, and promises not to look. Most of the market works that way, and it would have let us skip the executor and the fragment pin and the AAD design. We did not, because the reason a platform wants this product is "a system that holds this credential will eventually leak it," and that reasoning does not stop at the vendor boundary. If it applies to your database, it applies to ours.

The result is a product where the answer to "what does a breach of seekrit expose?" is the same for Vault as for every other secret we store: ciphertext, and the metadata around it. The guide has the setup; the API reference has the three surfaces and which key opens each.