# 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](/docs/concepts/vault) 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

```bash
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:

```ts
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](/docs/guides/agent-proxy) 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](#oauth-providers).

### 3. Register the executor

Dashboard → your project → *Issue registration code*, then where the Worker
is deployed:

```bash
npx @seekrit/vault-executor register <code> \
  --url https://my-vault-executor.<you>.workers.dev \
  --token <EXECUTOR_TOKEN>    # the value from step 2; or export EXECUTOR_TOKEN
```

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

```ts
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:

```ts
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`:

```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.**

```ts
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.

## Manage links

Your user should be able to see and cut what the agent can reach without
asking you:

```ts
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:

```ts
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](/docs/reference/api#vault) — every route on the three Vault surfaces.
- [`@seekrit/vault-executor`](https://www.npmjs.com/package/@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](https://github.com/mileszim/seekrit/blob/main/docs/vault-runbook.md) — registering, rotating keys, replacing an executor, reading `reauth_required` reasons, webhook failures.
- [`@seekrit/sdk/vault`](/docs/guides/sdks) — the platform SDK.
