seekrit
Docs/Vault (end-user credentials)

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.

note

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

  1. 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.
  2. 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 in vault.config.ts, so it is never in your repository or in the display catalog seekrit stores.
  3. 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 marked reauth_required with reason refresh_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 });
  • open restores the session into a fresh fenced browser and hands you the session id. One lease per connection; 409 lease_held otherwise. Leases end after 15 minutes by default (a site can lower the ceiling) — an alarm then does what release does.
  • fill types 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). totp generates a code from the saved secret. The value never comes back to you.
  • release captures 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 connection reauth_required (session_signed_out).
  • reauth mints 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.

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 a statusReason saying why: provider_rejected_credential (a 401), refresh_rejected (the provider refused the refresh token), refresh_outcome_unknown (a rotating refresh whose result was lost — see above), refresh_token_expired or refresh_token_missing. Open a new link with purpose: "reconnect" and the same connectionId; 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_required reasons, webhook failures.
  • @seekrit/sdk/vault — the platform SDK.