Vault
Your agent acts for your users. Sooner or later it needs to authenticate as them — an API key for a tool they use, a login for a site with no API. Vault is that problem solved once: a hosted Connect page where your user authorizes access, encrypted custody seekrit cannot read, and an executor in your own Cloudflare account that decrypts just in time and applies the credential to the request — so the value is never returned to your backend or your agent.
your backend ──(skv_ key)──▶ seekrit API: open a connect link, poll, list, revoke
│ ciphertext + metadata only
end user ──▶ connect.seekrit.dev ──┘ encrypts what they type, in the browser,
(phone) │ to YOUR executor's key
▼
your Cloudflare account: the executor Worker ◀── your backend: "make this request as u_123"
decrypts · checks the provider's rules · injects · forwards · redacts
Read the trust model for what this guarantees and what it does not. The short version: seekrit never sees your users' credentials, and your systems never store them.
Four kinds of credential, one data model: API keys and username/password logins (with an optional authenticator secret) typed on the Connect page, OAuth grants (Google, Microsoft, GitHub presets, or any authorization-code provider), and browser sessions for sites with no API — captured through a Live View the person signs in on, restored into a fenced browser your agent drives.
Setup: five steps, three environment variables
You need a Cloudflare account on the Workers Paid plan (Durable Objects).
1. Create a project and a platform key
Dashboard → Vault → New project. The display name is what your users see
on the Connect page ("Instinct wants to send email as you"). Then New
key and copy the skv_… platform key — it is shown once. It needs a fresh
second factor if your account has one, because it is a long-lived credential.
2. Deploy the executor
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
Edit vault.config.ts — the single source of truth for which providers your
agent may use and what it may do with each:
import { apiKeyProvider, defineVaultConfig, oauth2Preset } from "@seekrit/vault-executor";
export default defineVaultConfig({
providers: [
oauth2Preset.google({
id: "google-gmail",
consentSummary: "read and send email as you",
clientId: "123.apps.googleusercontent.com",
clientSecretBinding: "GOOGLE_CLIENT_SECRET", // the Wrangler secret's *name*
scopes: ["https://www.googleapis.com/auth/gmail.modify"],
}),
apiKeyProvider({
id: "resend",
displayName: "Resend",
consentSummary: "send email through your Resend account",
keyLabel: "API key",
helpUrl: "https://resend.com/api-keys",
rules: [{ host: "api.resend.com", methods: ["GET", "POST"], paths: ["/emails/**"] }],
}),
],
});
The rules are the same default-deny allowlist the credential
broker uses: a request outside them is refused
before anything is decrypted. By default the credential is injected as
Authorization: Bearer …; pass inject to use a different header, or carry
{{seekrit:API_KEY}} / {{seekrit:ACCESS_TOKEN}} in your request yourself.
OAuth providers are covered below.
3. Register the executor
Dashboard → your project → Issue registration code, then where the Worker is deployed:
npx @seekrit/vault-executor register <code> --url https://my-vault-executor.<you>.workers.dev
The executor generates its wrapping key, registers with seekrit, and keeps the credential it receives — in its Durable Object, never in an env var. The dashboard now shows the executor and its catalog.
4. Three variables on your backend
SEEKRIT_VAULT_KEY=skv_… # step 1
VAULT_EXECUTOR_URL=https://my-vault-executor.<you>.workers.dev
VAULT_EXECUTOR_TOKEN=… # the EXECUTOR_TOKEN secret
5. Code
import { Vault } from "@seekrit/sdk/vault";
const vault = new Vault(); // reads the three variables
// When the agent needs Resend for user u_123: open a link and text it to them.
const link = await vault.connect.create({ userRef: "u_123", providerId: "resend" });
await sms.send(user.phone, `Connect Resend to Instinct: ${link.url}`);
// Later — poll, or list the user's connections.
const session = await vault.connect.get(link.id); // status, connectionId once completed
// Make the request as the user. No credential in this process, ever.
const res = await vault.fetch({
userRef: "u_123",
connectionId: session.connectionId!,
request: {
method: "POST",
url: "https://api.resend.com/emails",
headers: { "content-type": "application/json" },
body: JSON.stringify({ from: "…", to: "…", subject: "…", text: "…" }),
},
});
vault.fetch resolves to the provider's response with any echo of the
credential redacted. When the executor refuses — a host outside the rules, a
userRef that does not own the connection, a revoked connection — it throws a
VaultExecutorError with a code, so a refusal is never mistaken for a provider
error.
What the user sees
Your user opens the link on their phone: your name and logo, which provider, one plain sentence about what the agent will be able to do, and "Secured by seekrit — neither Instinct nor seekrit can see your password." They tap Allow, then either paste the key (or enter a username, password, and optionally the setup key from their authenticator app) — the page encrypts what they typed to your executor's key before it leaves the device — or, for an OAuth provider, tap Continue to Google, sign in with Google, and land back on the page's done screen.
The link lives an hour by default (people open texted links late), up to 24. Once opened, it is bound to that browser: a second device sees "already open on another device."
OAuth providers
An OAuth connection is captured by your executor, not by the Connect page: the person taps Continue to Google on the Connect page, which hands the executor the session; the executor sends them to the provider and receives the redirect back; it exchanges the code, asks the provider who signed in (for the connection's label), encrypts the tokens to its own key, and files the ciphertext with seekrit. seekrit's API never sees the code, the tokens, or the PKCE verifier — the whole round trip happens on your executor's origin.
Setting one up
- Create an OAuth client with the provider (a Google Cloud OAuth client ID,
a GitHub App, an Entra app registration). Its redirect URI is
https://<your executor>/oauth/callback. - Store the client secret as a Wrangler secret — any name you like — and
name it in
clientSecretBinding. The secret is read from the Worker's environment at run time; it is never invault.config.ts, so it is never in your repository or in the display catalog seekrit stores. - Pick a preset:
oauth2Preset.google({ id, clientId, clientSecretBinding, scopes })
oauth2Preset.microsoft({ id, clientId, clientSecretBinding, scopes, tenant? })
oauth2Preset.github({ id, clientId, clientSecretBinding })
Each preset carries the provider's endpoints and quirks — Google's
access_type=offline&prompt=consent (without which no refresh token is issued),
Microsoft's offline_access, GitHub's lower-case token_type and its habit of
answering a failed token request with HTTP 200 — so you do not have to know
them. The Google preset derives its allow rules from the scopes you ask for
(gmail.* → gmail.googleapis.com, calendar → www.googleapis.com/calendar/**,
…) and refuses at deploy time if it cannot, in which case pass rules
yourself. For a provider without a preset, oauth2Provider({ … }) takes the
endpoints and a refresh strategy explicitly.
Refresh
The executor refreshes an access token that is about to expire before it uses it, and writes the new revision back to seekrit; your code never sees a stale token or a refresh token. Two strategies, chosen by the preset:
stable(Google, Microsoft): the refresh token survives use. A failure mid-refresh is harmless; the next request tries again.rotating(GitHub App user tokens): a refresh spends the refresh token and issues a new one. The executor journals every dispatch in the connection's Durable Object before calling the token endpoint; if the outcome is never recorded — the Worker died, the provider timed out — it does not retry with the old token, which the provider may already have invalidated and would treat as a replay. The connection is markedreauth_requiredwith reasonrefresh_outcome_unknown, and a reconnect link repairs it.
Two requests arriving with the same expiring token share one refresh: the
Durable Object is the single writer. A 401 from the provider on a token that
was not just minted gets one forced refresh and retry; a 401 on a fresh
token marks the connection reauth_required.
Revoking
vault.connections.revoke(id) through the SDK revokes in seekrit. Revoking
through the executor (POST /v1/connections/:id/revoke with the user's
userRef) first tells the provider, where the preset knows how — Google's
revoke endpoint, GitHub's delete an app token — then revokes in seekrit. The
provider half is best effort; seekrit refusing to release is the control that
matters.
Browser sessions
For a site with no API, the credential is the signed-in browser session
itself. Declare the site in vault.config.ts:
import { siteProfile } from "@seekrit/vault-executor";
siteProfile({
id: "linkedin",
displayName: "LinkedIn",
consentSummary: "browse and message on LinkedIn as you",
startUrl: "https://www.linkedin.com/feed/",
loginUrl: "https://www.linkedin.com/login",
domains: ["linkedin.com", "*.linkedin.com", "*.licdn.com"], // Browser Run guardrails, ≤ 50
domainSets: ["common-cdns"],
signedOutUrlPrefixes: ["https://www.linkedin.com/login"],
});
and add "browser": { "binding": "BROWSER" } to the executor's wrangler.jsonc
(the template has it). The profile is data: the domains fence every
browser the executor opens for this site, and nothing in a request can widen
them.
Capture. The person opens the connect link, taps Allow, then Open
LinkedIn sign-in: a Browser Run session in your Cloudflare account, fenced to
the site, opens on their phone through a Live View link. They sign in with
their own hands — MFA, passkey, whatever the site asks — come back, and tap
I'm signed in. The executor checks the browser is off the sign-in page,
captures its cookies and localStorage, encrypts them to its key, and files
the session slot. They may also save a username and password (encrypted on
the page, relayed as ciphertext) so the agent can sign in again later.
Use.
const lease = await vault.browser.open({ userRef: "u_123", connectionId });
// lease.sessionId is a Browser Run session, already signed in — connect your
// own browser tools to it (connectSession() in a Worker, or the CDP endpoint).
await vault.browser.fill({ userRef: "u_123", leaseId: lease.leaseId, field: "password" });
await vault.browser.release({ userRef: "u_123", leaseId: lease.leaseId });
openrestores the session into a fresh fenced browser and hands you the session id. One lease per connection;409 lease_heldotherwise. Leases end after 15 minutes by default (a site can lower the ceiling) — an alarm then does whatreleasedoes.filltypes a saved login field into the focused input — only if that input is on one of the site's login origins and is the right kind of field (the password-manager rule).totpgenerates a code from the saved secret. The value never comes back to you.releasecaptures the rotated cookies, writes them back as the next revision (compare-and-swap, so a revocation or a reconnect mid-lease wins), and closes the browser. A session that ended on a sign-in page marks the connectionreauth_required(session_signed_out).reauthmints a Live View link for the person when the site demands something a machine cannot do. Text it to them; a release that finds the browser signed in afterwards reactivates the connection.
Manage links
Your user should be able to see and cut what the agent can reach without asking you:
const { url } = await vault.manage.create({ userRef: "u_123", returnUrl: "https://app.example/settings" });
The same Connect page lists every connection you hold for that person —
provider, account, status — with a Disconnect button on each. A
disconnection is a revocation with revokedBy: "end_user": the ciphertext is
deleted and every further release refused, immediately.
Webhooks
Register an endpoint and you hear about connections as they change:
const { endpoint, secret } = await vault.webhooks.create({
url: "https://api.example/hooks/seekrit-vault",
events: ["connection.ready", "connection.reauth_required", "connection.revoked"],
});
// store `secret` — it is shown once
// in your handler
import { verifyWebhook } from "@seekrit/sdk/vault";
const event = await verifyWebhook({ secret, headers: req.headers, body: await req.text() });
if (!event) return new Response("bad signature", { status: 400 });
switch (event.type) { … }
Every delivery carries seekrit-webhook-id, seekrit-webhook-timestamp and
seekrit-webhook-signature: v1=<hmac-sha256> over id.timestamp.body;
verifyWebhook checks all three and refuses deliveries older than five
minutes. Failed deliveries are retried with backoff for about a day, and
vault.webhooks.test(id) sends a ping. Polling vault.connect.get(id) keeps
working without webhooks. Payloads carry connection metadata — ids,
provider, status, revision numbers — never a value.
Rate limits
Opening links and reading metadata are limited per platform key, credential
releases per executor, and the Connect page per address and per session. A
429 carries a plain message; the limits are generous for real traffic and
exist to make a stolen key or a runaway agent loud rather than expensive.
Identity: userRef
userRef is your opaque identifier for the user — u_123, never an email
address. The person has no seekrit account, and this is the one field about
them Vault holds in the clear. Every executor call names a userRef and a
connectionId, and is refused unless the connection belongs to that user;
that is what stops an agent for user A reaching user B's connection by naming
its id.
Reconnecting and revoking
- A credential that stops working marks the connection
reauth_required, with astatusReasonsaying why:provider_rejected_credential(a401),refresh_rejected(the provider refused the refresh token),refresh_outcome_unknown(a rotating refresh whose result was lost — see above),refresh_token_expiredorrefresh_token_missing. Open a new link withpurpose: "reconnect"and the sameconnectionId; the user's fresh credential replaces the old one as the next revision. vault.connections.revoke(id), the dashboard, or the person's own manage link deletes the ciphertext and refuses every further release, immediately. A late write-back cannot bring it back.- Disabling an executor in the dashboard makes every connection it captured unreadable — a replacement cannot open them — and your users will reconnect.
Billing and limits
Vault is gated by the Vault feature on your plan, and the unit is active
connections across your organization. Credential releases are metered for
visibility, not billed. See your plan page for the numbers that apply to your
organization.
Reference
- REST API — every route on the three Vault surfaces.
@seekrit/vault-executor— the executor library and template.npx @seekrit/vault-executor status --url …is the smoke test: up, registered, every secret present.- Operations runbook — registering, rotating keys, replacing an executor, reading
reauth_requiredreasons, webhook failures. @seekrit/sdk/vault— the platform SDK.