# seekrit — full documentation > Generated from https://seekrit.dev/docs. Zero-knowledge secrets manager; built for humans and AI agents. --- # Introduction **seekrit** is an end-to-end encrypted, multi-tenant secrets manager built on Cloudflare Workers. It stores your API keys, database URLs, and other secrets, and hands them to your applications — in local development, Docker builds, CI, Kubernetes, and AI-agent sandboxes — without the server ever seeing a plaintext value. ## How it works, in three lines 1. **You encrypt.** Secrets are encrypted in your browser or CLI, with a key that only ever exists on your side. The server receives ciphertext. 2. **seekrit stores and distributes.** The API — running on Cloudflare's global edge — keeps the ciphertext and hands each authorized person, machine, or agent a copy of the environment's key wrapped just for them. 3. **Your runtime decrypts.** The CLI, a service token, or an AI agent unwraps that key locally and injects the plaintext straight into your process — never back to the server. The result: one place to manage every secret, injected with one command everywhere your code runs, that the backend is cryptographically unable to read. > **Note:** **Are you (or is your) AI agent building with seekrit?** seekrit has a hosted [MCP server](/docs/mcp) an agent can self-register with and connect to in one request — no human, no install. Start with the [MCP server page](/docs/mcp) or the deeper [AI agents guide](/docs/guides/ai-agents), or point any agent at the machine-readable docs: [`/llms.txt`](/llms.txt) (index), [`/llms-full.txt`](/llms-full.txt) (everything in one file), or any page's URL with a `.md` suffix. ## Zero-knowledge by design The defining property of seekrit is that **encryption keys are generated and used entirely on the client**. When you add a secret in the web dashboard or CLI, it is encrypted in your browser or on your machine before it is uploaded. The API — a Cloudflare Worker backed by managed Postgres — only ever stores ciphertext. A full database dump reveals metadata (names, versions, timestamps) but no secret values. This is achieved with **envelope encryption**: - Each **environment** has its own AES-256 data key (DEK). - Secrets are encrypted with that DEK. - The DEK is **wrapped** (encrypted) individually to each person or service token that should have access, using their public key. - A user's private key is itself encrypted with a passphrase-derived key, so any device can fetch it and unlock it locally — the passphrase never leaves the client. See [Encryption model](/docs/concepts/encryption) for the full picture. ## The pieces seekrit has three parts: - **Web dashboard** — create orgs, applications, and environments, and manage secrets with client-side encryption. Sign in with Google, GitHub, or an email and password (via Stytch). - **CLI** (`seekrit`) — link a project directory to an environment and inject decrypted secrets into any command, or export them as `dotenv`/`json`/`shell`. - **API** — a Hono Worker on Cloudflare with Postgres (via Hyperdrive) and KV, exposing an org-scoped REST API and an append-only audit trail. ## Built to scale, everywhere Because the server only ever holds ciphertext, it can be cached anywhere without becoming a liability. seekrit runs on Cloudflare's global edge, caches encrypted secrets close to your workloads (keyed per caller, so tenants never share an entry), and serves the resolve hot path — every container, CI job, and agent fetching its secrets on boot — from one org's laptop up to tens of thousands of reads a day, with no infrastructure of your own. See [Scale & the edge](/docs/concepts/scale). ## Where to go next --- # seekrit for AI agents (MCP) **If you are an AI agent, start here.** seekrit stores the credentials your code needs — API keys, database URLs, tokens — encrypted so that neither the server nor anyone operating it can read them. It exposes a hosted [MCP](https://modelcontextprotocol.io) server built for you to drive **autonomously**: connect with no credential at all, and sign yourself up in one tool call. ## Reach for it when - You are about to write a real credential into a `.env` file, a config file, or a repo, and would rather it not sit on disk or in version control. - A secret is in your context — a user pasted it, a provider just issued it — and needs to go somewhere that isn't a transcript. - You need to run a command that requires `DATABASE_URL` or `API_KEY` and don't have one to hand. - You are handing work back to a human, and the credentials you set up would otherwise be lost when you stop running. **Use a secret without ever reading it.** `run_command` decrypts into a subprocess's environment, so the process gets the real value and your context never does. Prefer it over fetching a value you only need to pass along — nothing you never saw can leak through you. You also get autonomous provisioning (orgs, apps, environments, groups), the audit trail, and a clean human handoff, all as MCP tools. ## Connect in three steps ### 1. Connect — no credential required Point your MCP client at the hosted endpoint. There is nothing to install and nothing to sign up for first: `initialize` and `tools/list` work anonymously, so you can read the instructions and the full tool list before you have an account. ```jsonc { "mcpServers": { "seekrit": { "type": "http", "url": "https://mcp.seekrit.dev/mcp" } } } ``` ### 2. Call `signup` to get a workspace and a credential One tool call, no human and no browser. **Name the org for the real project or company you're working on** — `orgName` and `orgSlug` are both required, and a human later claims the org by that name, so don't use a placeholder like `test`: ```jsonc signup { "orgName": "Acme Storefront", "orgSlug": "acme-storefront" } // → { org: {…}, credential: { clientId: "…", clientSecret: "…" }, sessionBound: true } ``` The credential is **bound to your current session**, so `whoami`, `create_app`, and the rest work on your very next call — no config change, no reconnect. Save the `clientSecret`: it is shown **once**, and it's how you (or the human who takes over) get back in later. To reconnect, add the header: ```jsonc { "mcpServers": { "seekrit": { "type": "http", "url": "https://mcp.seekrit.dev/mcp", "headers": { "Authorization": "Basic " } } } } ``` Prefer plain HTTP, or scripting the bootstrap outside MCP? `POST /signup` does the same thing: ```bash curl -sX POST https://mcp.seekrit.dev/signup \ -H 'content-type: application/json' \ -d '{"orgName": "Acme Storefront", "orgSlug": "acme-storefront"}' ``` Call `get_started` any time for the end-to-end recipe. The hosted server gives you discovery, provisioning, audit, billing, and keyless management — but it can **never** read or write a secret *value*. ### 3. Add the local crypto server to use secret values Anything that touches plaintext — setting a secret, reading one, creating an environment, minting a token — runs on **your** machine, on the local crypto server, next to your keys. Give it the **same** machine credential; it mints its own admin token automatically (no `skt_` to copy): ```jsonc { "mcpServers": { "seekrit-local": { "command": "npx", "args": ["-y", "@seekrit/mcp"], "env": { "SEEKRIT_CLIENT_ID": "", "SEEKRIT_CLIENT_SECRET": "" } } } } ``` Both servers are also listed on the official [MCP registry](https://registry.modelcontextprotocol.io) as `dev.seekrit/remote-mcp` and `dev.seekrit/mcp`, for clients that discover servers that way instead of manual config. ## Why two servers? seekrit is zero-knowledge: secret values, data keys, and private keys never reach the server. So the work is split along that line — the **hosted metadata server** (`mcp.seekrit.dev`) provisions and reads structure and can never decrypt; the **local crypto server** (`@seekrit/mcp`) does everything that produces plaintext, on your machine. One credential drives both, and they share the same org, so they compose. > **Tip:** Full walkthrough — the complete toolset, the container image, credential choices, and handing off to a human — is in the [AI agents guide](/docs/guides/ai-agents). Machine-readable docs live at [`/llms.txt`](/llms.txt) and [`/llms-full.txt`](/llms-full.txt), and any doc page is available as markdown at its URL + `.md`. > **Warning:** Prefer *using* a secret over *reading* it: `run_command` (local) injects values into a child process so they never enter your context. `get_secret reveal:true` is the only tool that puts plaintext in the transcript — reach for it only when the value itself is what you need. --- # Quickstart This walkthrough takes you from zero to a running process with decrypted secrets: create your keys and first secret in the web console, mint a token, then inject it into any command with the CLI. Every value is encrypted on your machine — the API only ever sees ciphertext. ## 1. Sign in Open the [seekrit console](https://app.seekrit.dev) and sign in with **Google**, **GitHub**, or an **email and password**. New here? Pick **Create an account** to sign up with your email — we'll send a link to set your password. The first time you sign in, seekrit provisions your organization automatically. ## 2. Set up your keys You'll be prompted to create your encryption keys. The browser generates a P-256 keypair and encrypts the private key with a passphrase you choose. Only your **public key** and the **passphrase-encrypted** private key are uploaded. > **Warning:** Your **passphrase is not your sign-in password.** It never reaches the server, so there is no passphrase reset — if you forget it, your encrypted data cannot be recovered. Store it in a password manager. (Your sign-in password, if you use email/password, *can* be reset by email.) ## 3. Create an app, environments, and a secret In the console: 1. Create an **application** — say *Storefront*. The same dialog offers its environments (`development`, `staging`, `production` are pre-selected; deselect any, or add your own), so you land on a matrix that already has columns. Each environment's data key is generated in your browser and wrapped to your public key. 2. Add a secret such as `DATABASE_URL` with **add key** — pick which environments get it and the value is encrypted separately for each one. Only ciphertext is uploaded. The application page keeps a short **Get this application running** checklist until you've done all three of environments, secrets, and machine access — the third is the one that's easy to miss. ![An application page in the seekrit console: a matrix with one row per secret name and one column per environment, values revealed after unlocking](https://seekrit.dev/screenshots/original/dashboard-secrets-matrix.webp) *What you end up with: one row per secret, one column per environment. Values are decrypted in the tab when you reveal them — a column with no service token yet is flagged in the header.* ## 4. Mint a service token A **service token** is how machines and the CLI read secrets. Until one exists, each environment column shows a **no token** warning — click it (or use the checklist's **mint token**, or the environment page's **Key access** panel) to mint one bound to that environment. The browser auto-grants it the environment's key, plus the keys of any composed group, and shows the token **once**. Copy it now. ```bash export SEEKRIT_TOKEN=skt_… ``` > **Note:** A runtime token carries its own org, app, and environment, so the CLI needs no config file or login to use it. It self-decrypts — no passphrase. ## 5. Inject secrets with the CLI Install the CLI and let the token select everything at runtime: ```bash npm install -g @seekrit/cli seekrit run -- printenv DATABASE_URL # inject into a process, then run it seekrit export --format dotenv # or print the resolved environment ``` Everything the token resolved was encrypted client-side. The API only ever returned ciphertext; the CLI decrypted it locally with the key embedded in the token. > **Note:** Working at your own terminal rather than wiring up a machine? Run `seekrit login` instead: it opens the console, you authorize the device, and the CLI acts as *you* across every org you belong to — no token to copy. See the [CLI guide](/docs/guides/cli). ## Next steps - Understand the [encryption model](/docs/concepts/encryption) - Run secrets in containers, CI, and agents with [`seekrit-run`](/docs/guides/run) - Share config across apps with [environments & groups](/docs/guides/environments) - Manage everything in the browser — the [web dashboard guide](/docs/guides/web-app) - Browse the [CLI command reference](/docs/reference/cli) --- # Examples A cookbook of patterns you can copy and adapt. Each recipe links to the guide that covers it in depth. Almost everything here rests on two properties: - **One encrypted secret set resolves the same way in every runtime** — local dev, CI, a container, a Kubernetes pod, an agent sandbox — because they all decrypt through the same key grant. - **Environments compose.** An application environment is *its own secrets plus the groups it pulls in*, layered at runtime — so shared config lives in one place. Agents get first-class treatment: because seekrit is zero-knowledge, an agent can *use* a secret it is never allowed to *read*. ## Everyday: replace your `.env` file ### Run any command with secrets injected Stop keeping a plaintext `.env` in the repo. A service token carries its own org, app, and environment, so nothing else is needed: ```bash export SEEKRIT_TOKEN=skt_… seekrit run -- pnpm dev # secrets injected into the child process seekrit run -- ./migrate && ./serve ``` A local `.env` still works — it layers *on top* of the managed secrets, so you can override one value without touching the shared set: ``` group secrets < app-env secrets < .env file < process env (highest wins) ``` See the [CLI guide](/docs/guides/cli). ### See exactly where each value came from When a value isn't what you expect, `--explain` prints each variable's source layer to stderr (names only, never values): ```bash seekrit run --explain -- true ``` ## Composability: share config across apps ### One shared group, many apps Put config that several apps share into a **group**, then compose it into each app's environments. Here an `api` and a `web` app share a database and cache: ```bash # A shared group with a per-environment value set seekrit group create --name "Shared infra" --slug shared-infra seekrit group env create --group shared-infra --name Production --slug production seekrit secrets set DATABASE_URL 'postgres://…' --group shared-infra --env production seekrit secrets set REDIS_URL 'rediss://…' --group shared-infra --env production # Compose it into each app's production environment seekrit env groups add --app api --env production --group shared-infra seekrit env groups add --app web --env production --group shared-infra # App-specific secrets live on the app environment and override the group seekrit secrets set STRIPE_KEY 'sk_live_…' --app api --env production ``` Change `DATABASE_URL` once in the group and both apps pick it up. See [Environments & groups](/docs/guides/environments). ### Layer several groups with precedence Compose more than one group and control precedence with `--position` (higher wins). App-env secrets still win over every group: ```bash seekrit env groups add --app api --env production --group shared-infra --position 10 seekrit env groups add --app api --env production --group third-party --position 20 seekrit env groups list --app api --env production # inspect the order ``` ### Swap a single group's slice per boot Group environments double as **variants**. Boot everything at `dev` but pull one group — say auth providers — from its `staging` slice, without a separate environment: ```bash seekrit run --with auth-providers=staging -- pnpm dev ``` Overrides are fail-closed: you can only pull a slice you hold a key for. As a logged-in developer you hold them all; for a service token, pre-authorize the slice at creation with `token create … --allow auth-providers=staging`. ## One secret set, every runtime ### The same token in local dev, CI, and a container Bind a token to one environment once, then hand it to every runtime — each resolves and decrypts identically: ```bash # Local / CI — Node CLI SEEKRIT_TOKEN=skt_… seekrit run -- ./deploy.sh # GitHub Actions — SEEKRIT_TOKEN from the repo's secret store # - run: seekrit run -- ./deploy.sh # Container / distroless — the static launcher, no Node required SEEKRIT_TOKEN=skt_… seekrit-run -- ./start-server ``` Or resolve the same environment from inside your app with a [language SDK](/docs/guides/sdks) (Python, Go, JS/TS, Ruby): ```python import seekrit secrets = seekrit.Client().resolve() # token from $SEEKRIT_TOKEN ``` See [Service tokens](/docs/guides/service-tokens), [CI/CD & containers](/docs/guides/ci-cd), the [`seekrit-run` launcher](/docs/guides/run), and the [language SDKs](/docs/guides/sdks). ### A container entrypoint that degrades gracefully `seekrit-run` is a tiny static binary with no Node/OpenSSL/CA dependency, so it runs in `distroless`, `alpine`, and `scratch`. Fetching secrets is best-effort: if the token is missing or the API is unreachable, it warns and still runs your command with just `.env` + the live environment — so the same image works with or without seekrit wired up. ```dockerfile FROM gcr.io/distroless/static COPY --from=seekrit /usr/local/bin/seekrit-run /usr/local/bin/seekrit-run ENTRYPOINT ["seekrit-run", "--"] CMD ["./start-server"] ``` > **Warning:** Inject at runtime, never at build time — secrets fetched during `docker build` can be baked into an image layer. `seekrit-run` only ever puts values into the child process's environment; nothing touches disk. ## Built for agents ### Let an agent provision a service end-to-end Register the MCP server once, giving the agent an admin token so it can create structure headlessly: ```bash SEEKRIT_TOKEN=skt_… claude mcp add seekrit -- seekrit mcp ``` Now the agent drives seekrit as tools — a typical stand-up-a-service session: 1. `create_app` → `create_env` (production) — the data key is generated locally. 2. `set_secret` for `DATABASE_URL`, `API_KEY`, … (encrypted on this machine). 3. `create_token` bound to that env — a runtime token, auto-granted its keys. 4. `configure_project` to write `seekrit.json`, then hand the runtime token to CI or a container. 5. `run_command -- pnpm test` to verify the app boots with its secrets injected. See the [AI agents guide](/docs/guides/ai-agents). ### Use secrets the agent can't read The zero-knowledge model means an agent can put a secret to work without its plaintext ever entering the conversation: - **`run_command`** resolves the environment, injects it into a child process, and returns only the exit code and output — the values never reach the agent. - **`export_env`** writes a gitignored file for tools that read `.env`. - **`get_secret`** returns metadata; it only decrypts into the response when you pass `reveal: true`. ``` run_command -- pnpm test # values injected, never returned ``` > **Warning:** Revealing a secret puts its plaintext in the agent's context, where it may be logged or retained. Use `run_command` (or `export_env`) whenever the agent needs to *use* a secret rather than *read* it. ### Ephemeral agent sandboxes Give a throwaway sandbox exactly the secrets it needs through a short-lived, independently revocable token — no long-lived credentials in the environment: ```bash # Stand up a scoped env + token for the sandbox seekrit env create --app storefront --name "Sandbox 7f3" --slug sandbox-7f3 seekrit secrets set OPENAI_API_KEY 'sk-…' --app storefront --env sandbox-7f3 seekrit token create --name sandbox-7f3 --app storefront --env sandbox-7f3 # prints skt_… # … the sandbox runs `seekrit-run -- ./agent`, decrypting locally … seekrit token revoke skt_XXXXXXXX # on teardown ``` The token self-decrypts (no passphrase) and can only reach the environment it was granted. See [CI/CD & containers](/docs/guides/ci-cd#ai-agent-sandboxes). ## Dynamic credentials: short-lived Postgres logins Mint a database login that auto-expires, and hand the URL straight to a tool. The password and its SCRAM verifier are generated on your machine — only the verifier is sent, so the plaintext never reaches seekrit or Postgres at rest: ```bash psql "$(seekrit pg lease prod-db --ttl 30m)" ``` This composes with everything above: an agent or CI job can lease a 30-minute credential for one task instead of holding a standing database password. See [Temporary access](/docs/concepts/temporary-access). --- # Encryption model seekrit uses **envelope encryption**. There are three layers of keys, and the plaintext of your secrets — along with private keys and passphrases — never reaches the server. ## The key hierarchy ``` environment DEK (AES-256) one per environment ├── encrypts every secret in that environment └── wrapped separately for each principal: ├── user A (ECDH to A's public key) ├── user B (ECDH to B's public key) └── token T (ECDH to T's public key) ``` ### Data encryption key (DEK) Every environment has its own random 256-bit **data encryption key**. Secrets in that environment are encrypted with it using **AES-256-GCM**. The ciphertext is bound to its location with additional authenticated data (AAD) of `environmentId/SECRET_NAME`, so a blob cannot be silently moved to a different secret or environment. ### Principal keypairs Every principal — a user or a service token — has a **P-256 (ECDH) keypair**. The public key is stored by the server; the private key is not (for tokens it lives inside the token string; for users it is passphrase-encrypted, see below). ### Key wrapping To give a principal access to an environment, its DEK is **wrapped** to that principal's public key. Wrapping uses an ephemeral ECDH exchange plus HKDF-SHA256 to derive a one-time AES-256-GCM key — an ECIES-style construction. Only the holder of the matching private key can unwrap the DEK. Each grant is an independent wrapped copy of the same DEK. > **Note:** Possession of a wrapped-DEK grant **and** the matching private key is exactly what "having access" means. There is no server-side switch that grants plaintext — access is cryptographic. ## Protecting user private keys A user needs their private key on every device they sign in from, but the server must never see it. So the private key is encrypted client-side with a key derived from the user's **passphrase** using PBKDF2-HMAC-SHA256 (600,000 iterations), and the encrypted blob is stored server-side. - On any device, the client fetches the encrypted blob and decrypts it locally with the passphrase. - The passphrase and the plaintext private key never leave the client. - A wrong passphrase surfaces as an authentication failure — the blob simply won't decrypt. ![The unlock keyring dialog in the dashboard, over a secrets table whose values are still redacted](https://seekrit.dev/screenshots/original/dashboard-unlock-keyring.webp) *What that looks like in the dashboard: values stay redacted until the passphrase unwraps the private key in the tab. Behind the dialog the page holds only ciphertext — that is all the server ever sent.* This passphrase is deliberately **separate from how you sign in** (OAuth, or an email/password credential verified by Stytch). Sign-in proves *who you are* and starts a session; the passphrase *decrypts your secrets* and is never sent to the server. That separation is why an email/password reset can restore account access but can never recover your encrypted data. There is intentionally **no passphrase reset**: losing it means the encrypted private key is unrecoverable. That is the cost of the server never holding it. To recover *environment* access after a lost passphrase or a departed key-holder — without seekrit ever holding a key — configure [customer-controlled recovery](/docs/concepts/recovery) in advance. ## Service tokens Service tokens are self-contained principals for machines. The token string itself carries the private key. The server stores only: - a SHA-256 hash of the full token (to authenticate requests), and - the token's public key (to wrap DEK grants to it). So a machine holding the token can unwrap any environment DEK granted to it, entirely offline, without the server ever having its private key. See [Service tokens](/docs/guides/service-tokens). ## What a secret write looks like 1. The client fetches its wrapped DEK for the environment and unwraps it with its private key. 2. It encrypts the new value with the DEK (AES-256-GCM, AAD = `envId/NAME`). 3. It uploads only the resulting ciphertext blob. The reverse — fetch ciphertext, unwrap DEK, decrypt — happens on read. The API is never a party to any of the cryptography. ## Algorithms at a glance | Purpose | Algorithm | | --- | --- | | Secret encryption | AES-256-GCM (per-secret AAD) | | DEK wrapping | Ephemeral ECDH P-256 + HKDF-SHA256 → AES-256-GCM | | Passphrase key derivation | PBKDF2-HMAC-SHA256, 600k iterations | | Service token hashing | SHA-256 | All of it runs on the Web Crypto API, so the exact same code runs in the browser, the Workers runtime, and Node for the CLI. Every ciphertext blob is versioned (e.g. `sc1.`, `wd1.`, `pk1.`) so algorithms can be migrated without breaking existing data. --- # Architecture ## Resource model seekrit is multi-tenant. Resources nest from organizations down to individual secrets: ``` organizations ─┬─ members (users, via org_memberships with a role) ├─ groups ──────── environments ─┬─ secrets (+ version history) ├─ applications ─── environments ─┤ (shared, reusable secret bags) │ └─ composes groups └─ key grants (wrapped DEKs) ├─ service tokens (bound to one app environment) └─ audit log (append-only) ``` - **Organization** — a tenant. Has members, applications, groups, service tokens, and its own audit trail. - **Application** — a deployable (a service, site, or worker) within an org. - **Group** — a reusable secret bag shared across applications (e.g. `common-backend`, `auth-providers`). Like an application, it owns environments keyed by slug. - **Environment** — a named context (`production`, `staging`, `dev`, …) owned by **either** an application or a group. Each environment owns one data key. - **Composition** — an application environment pulls in one or more groups, matched by slug. At read time the layers merge, lowest precedence first: `group secrets < app secrets`. - **Secret** — a named, encrypted value in an environment. Every write appends a new version. - **Key grant** — a wrapped copy of an environment's data key for one principal. - **Service token** — bound to one application environment; that binding selects the org, app, and environment it resolves at runtime, plus the group slices composed into it. ## Roles Membership carries a role: `owner` > `admin` > `member`. - **Admin and owner** manage structure (apps, environments), service tokens, key grants, and can read the audit trail. - **Members** read and write secrets in environments they hold a key for. The real access boundary is cryptographic: you can only decrypt an environment if you hold a key grant for it. Roles gate the management API on top of that. Non-members receive `404`s for an org so its existence can't be probed. ## Cloudflare building blocks seekrit runs entirely on Cloudflare's edge. | Component | Cloudflare product | Role | | --- | --- | --- | | API | Workers | The Hono API worker | | Database | Postgres (PlanetScale, via Hyperdrive) | Orgs, users, secrets (ciphertext), grants, audit | | Cache | KV | Cached identity-provider JWKS (and future session/rate-limit state) | | Web dashboard | Workers (via OpenNext) | Next.js app serving the browser client | The API and web dashboard run as separate Workers, backed by Hyperdrive (database) and KV bindings. ## Request lifecycle 1. A request arrives at the API worker with a credential — a Stytch session JWT (web), a CLI session (a signed-in human at a terminal), or a service token (machines). 2. Auth middleware resolves the **actor** (a user — whether from a browser session or a CLI session — or a service token) and, for org-scoped routes, checks membership and role. 3. The handler reads or writes ciphertext in Postgres. It never handles plaintext secrets — including the ciphertext of a value that [references another secret](/docs/guides/references), which the client expands after decrypting. 4. Any mutating action writes an entry to the append-only audit log before the response returns. High-volume environment resolves are metered for usage/billing instead of audited per call (denied resolves are still audited). ## Authentication - **Web** — Stytch B2B sign-in (Google/GitHub OAuth discovery, or email/password discovery). The browser holds the session; its JWT is sent as a bearer token and verified by the API against Stytch's JWKS (cached in KV). The auth method is opaque to the API — every method yields the same session JWT, so nothing server-side changes per method. - **CLI (you)** — `seekrit login` opens a browser, you authorize the device, and the CLI saves a **CLI session** (`skc_…`) that authenticates as your user. The CLI generates that token itself and registers only its hash, so the credential never travels through the API. It carries no key material: decryption still unlocks your own private key with your passphrase, on your machine. - **CLI / machines (unattended)** — service tokens (`skt_…`) sent as bearer tokens. - **Agents** — Stytch **M2M** client credentials (OAuth client-credentials), sent as HTTP Basic auth or as a pre-fetched access token. Agents self-register at `/v1/signup` and authorize against seekrit's own client→org mapping (never token claims). A machine credential holds no key material, so it bootstraps a service token for anything that touches ciphertext. The first time a valid session proves access to a Stytch organization, seekrit provisions the matching org and membership just-in-time. --- # Scale & the edge seekrit is globally distributed by construction. Because encryption and decryption are the client's job, the server never holds anything but ciphertext — and ciphertext is the easiest thing in the world to move closer to whoever is asking for it. There is no cluster for you to size and no secret material to replicate. ## Runs on Cloudflare's edge The API is a single [Cloudflare Worker](/docs/concepts/architecture) served from Cloudflare's global network, backed by a managed Postgres database reached over pooled, kept-warm connections (Cloudflare Hyperdrive). Requests are handled close to wherever they originate — a laptop in Berlin, a CI runner in `us-east`, an agent sandbox anywhere — without you provisioning or operating a thing. The database itself lives in one region, which matters less than it sounds: the route that runs on every workload boot is answered from the edge cache described below, and never reaches it. ## The hot path: resolving secrets Every running workload — a container, a CI job, an AI agent — calls `GET /v1/resolve` on startup to fetch the secrets for its environment. This is by far the busiest route: one organization can drive tens of thousands of resolves a day. seekrit is built to serve it cheaply and correctly. The route is split across two roles inside the same Worker: - A **gateway** that always runs: it authenticates the caller, decides which org and environment are in play, and meters the read for usage and billing. - A **resolver** that owns the cache: it does the database work — gathering the encrypted secret layers and the caller's individually-wrapped data key — and caches the result at the edge. ## Caching that stays zero-knowledge Resolved responses are cached **at the edge**, so repeat boots of the same workload are served without touching the database. Two properties keep this safe: - **Only ciphertext is cached.** Encrypted secret values and already-wrapped data keys — never plaintext. The zero-knowledge invariant is unchanged: a cache, like the database, holds nothing readable. - **The cache key includes the caller's identity.** A caller's principal, org, environment, and any overrides are folded into the cache key. Two principals never share an entry, and no one can be served another principal's wrapped key. Identity is verified on every request by the gateway, cache hit or miss — it is never bypassed by a warm cache. > **Note:** A wrapped data key is only useful to the principal it was wrapped for — decrypting it still requires that principal's private key, which never leaves their machine. Edge caching moves ciphertext closer to the caller; it never moves the ability to read it. ## Staying correct: tag-scoped invalidation Speed is worthless if it serves stale secrets. Each cached response is tagged with every environment it read (an app environment plus any composed groups). Any change that could alter the result — writing or rotating a secret, granting or revoking a key, changing composition, deleting an environment — purges exactly those tags. Unaffected environments keep their warm caches; the ones that changed are refreshed on the next read. A short backstop lifetime bounds staleness if a purge is ever missed. ## Metering, not per-read audit Mutations are always [audited](/docs/concepts/security) synchronously to an append-only trail. Successful resolves are the deliberate exception: at tens of thousands a day they are **metered** to an analytics pipeline (fire-and-forget, sampled) for usage and billing rather than written to the durable audit log. Metadata only — never ciphertext, never plaintext. Denied resolves *are* audited durably, because those are the security-significant events. ## The bottom line The same design decision that makes seekrit private — encryption belongs to the client — is what lets it scale. A ciphertext-only server can cache safely at the edge — right next to your workloads, with nothing readable in the copy — and grows from a single developer to a fleet of agents with no infrastructure on your side. --- # Access & key grants Access to an environment's secrets is **cryptographic**: a principal can decrypt an environment if and only if it holds a key grant — a copy of the environment's data key (DEK) wrapped to its public key — and the matching private key. ## Granting access When a principal is granted access, the granting client: 1. Unwraps its own copy of the environment DEK with its private key. 2. Re-wraps that DEK to the target principal's public key. 3. Uploads the new wrapped blob as a key grant. All of this happens on the granter's client. The server stores the wrapped blob but never sees the DEK in the clear. Because wrapping needs the target's public key, a user must have completed [key setup](/docs/guides/web-app) before they can be granted access. In the web dashboard this is the **Key access** panel on an environment; with the CLI it is `seekrit grant`. ![The Key access panel listing a service token and a member who hold the environment key, above a grant selector](https://seekrit.dev/screenshots/original/dashboard-key-access.webp) *The Key access panel: every principal holding a wrapped copy of this environment's data key — here one member and one service token — and the ✕ that revokes one.* ## Roles vs. grants Two independent mechanisms govern what someone can do: - **Role** (`owner` / `admin` / `member`) gates the management API — who may create environments, mint tokens, manage grants, invite members, or read the audit trail. - **Key grant** gates decryption — who can actually read secret values. A member with no key grant for an environment can see that the environment exists (if their role allows) but cannot decrypt anything in it. Service tokens carry a role too: **member** (the default — a runtime credential whose real power is its key grants) or **admin** (an org-scoped token that also passes the management API, so automation and [AI agents](/docs/guides/ai-agents) can provision structure headlessly). A token is never `owner`. Only an admin caller can mint an admin token, and granting any token decryption still requires the caller to already hold that environment's key — so capability never escalates itself. ## Revocation vs. rotation These solve different problems, and both matter. **Revocation** deletes a principal's key grant. It immediately stops that principal from fetching the DEK again. Use it when someone leaves or a token is retired. > **Warning:** Revoking a grant does not change the DEK. A principal that already fetched and cached the DEK could still decrypt values it captured. To truly cut off access to existing secrets, rotate the key. **Key rotation** replaces the environment's DEK entirely: generate a new DEK, re-encrypt every secret with it, and re-wrap it for the remaining principals. After rotation, any previously cached copy of the old DEK is useless. Revocation is instant and cheap; rotation is the complete, heavier operation. A typical response to a departing teammate is to revoke immediately, then rotate. > **Note:** Two different things are called rotation. **Key** rotation, above, replaces the key an environment's secrets are encrypted *under* — it defeats a cached DEK. [**Secret** rotation](/docs/concepts/rotation) replaces a secret's *value* on a schedule — it defeats a leaked credential. A departing teammate calls for the first; a stale database password calls for the second. ## Auditing access changes Every grant and revocation is recorded in the [audit trail](/docs/concepts/security#audit-trail) with the actor and target principal, so access changes are always attributable. --- # Customer-controlled recovery Because seekrit never holds your keys, it cannot reset a passphrase or recover a lost private key for you — see [protecting user private keys](/docs/concepts/encryption#protecting-user-private-keys). Left there, one departed sole key-holder could mean a dead environment. **Customer-controlled recovery** closes that gap the only way that keeps the [zero-knowledge](/docs/concepts/security) promise intact: you configure, in advance, a recovery key that a **quorum of custodians you designate** can use to restore access — and seekrit still holds nothing it could recover on its own. > **Note:** Recovery restores **environment access to a fresh key-holder** — it does not un-lose a passphrase or a private key. If you forget your own passphrase, your key stays unrecoverable; recovery lets your org grant a *new* principal access to the affected environments instead. ## The recovery key is just another principal When you enable recovery, a **recovery keypair** is generated in your browser or CLI (P-256, the same kind every user and token uses). Its **public** half is stored server-side in the clear — a public key is not a secret. Each environment's data key (DEK) is then *additionally* wrapped to it, exactly like a [key grant](/docs/concepts/access-control) to any other principal. New environments are wrapped at creation; `seekrit recovery sync` (or the dashboard) backfills existing ones as admins who can decrypt them run it. So the recovery key can unwrap every covered environment's DEK — which is precisely why its **private** half is never stored whole. ## The private half is split, never held The recovery private key is split with **Shamir's Secret Sharing** (M-of-N) into one share per custodian. Each share is wrapped to that custodian's public key before it leaves the browser. The server stores only: - the recovery **public** key, and - **N opaque wrapped shares** it cannot open. ``` recovery private key │ Shamir M-of-N split ┌───────────────┼───────────────┐ ▼ ▼ ▼ share 1 → share 2 → share 3 → (each wrapped to one custodian A custodian B custodian C custodian's public key) ``` Any **M** custodians can reconstruct the recovery key; any fewer — and seekrit itself, holding only ciphertext — learn nothing. A single **designated recovery admin** is just the M = N = 1 case. ## The recovery ceremony relays only ciphertext To exercise recovery, an admin starts a **recovery request** naming a target principal's public key (usually their own — the person who will end up with access): 1. **Approve.** A quorum of custodians each unwrap their share on their own device and re-wrap it to the target's public key. The server collects these re-wrapped shares — all ciphertext it cannot read. 2. **Complete.** Once M are collected, the target unwraps them with its own private key, combines them back into the recovery key, unwraps each environment's recovery grant to recover the DEK, and re-grants itself normal access. The recovery key exists in plaintext only for the moments of the final step, only on the target's machine. seekrit never sees a share or the recovery key. > **Warning:** Recovery is only as strong as your custodian choices. Pick a threshold and a set of people such that no single lost or compromised custodian can either **block** recovery (too high a threshold with too few people) or **perform** it alone (a threshold of 1 with many custodians). After a recovery, consider rotating the recovery key with `seekrit recovery rotate` — the key was briefly reconstructed on one machine. ## What the server never sees - The recovery **private** key — it is Shamir-split and each share is wrapped to a custodian; the server holds only the public key and shares it cannot combine. - Any **share** in the clear — custodians unwrap and re-wrap on their own devices. - Any **DEK** in the clear — recovery grants are ciphertext only the reconstructed recovery key can open, and that reconstruction happens client-side. Every step writes an append-only [audit](/docs/guides/audit-export) row (`recovery.configured`, `recovery.requested`, `recovery.share_contributed`, `recovery.completed`, and so on), so the whole ceremony is reviewable after the fact. To set it up, see [Set up recovery](/docs/guides/recovery). --- # Managed keys (KMS) Secrets answer "store this value and hand it back later." A **KMS key** answers a different question: "encrypt, decrypt, or sign *my own data* with a key I never have to hold." seekrit's KMS is the same zero-knowledge machinery as the rest of the product — the key material is generated in your client and wrapped to each principal, so the server only ever stores ciphertext, public keys, and grants. Unlike a classic server-side KMS (AWS KMS, Vault Transit), seekrit never runs the crypto for you: a client fetches its wrapped key **once**, unwraps it with its own private key, and then encrypts, decrypts, and signs entirely locally. The server cannot read your plaintext, and it cannot use your key. ## What a key is A managed key is org-scoped and referenced by a stable **name**. It has a **purpose** that fixes what it does: - **`encrypt`** — a symmetric AES-256-GCM key, for `Encrypt` / `Decrypt` and `GenerateDataKey`. - **`sign`** — an asymmetric ECDSA P-256 keypair. The private half is wrapped to grantees; the public half is **published per version**, so anyone in the org can verify a signature without holding the key. A key may be **scoped** to an application or a group (or left org-wide) — the same ownership model as environments. Scope organizes keys and drives the dashboard; a grant is still what actually lets a principal use the key. ## Grants: possession is access Exactly like an environment DEK, a KMS key reaches a principal only as a **grant** — the key material wrapped to that principal's public key (a `wd1.` blob, ephemeral ECDH → HKDF → AES-GCM). Holding a grant row plus the matching private key *is* the ability to use that key version. Admins grant and revoke; the crypto is the hard boundary. ## Envelope encryption & GenerateDataKey `Encrypt` produces a versioned `ce1.` blob. For large payloads, `GenerateDataKey` returns a fresh random data key **plus** that data key wrapped under the managed key (a `dk1.` blob): encrypt your bulk data with the plaintext data key, store the wrapped form beside it, and recover it later with the managed key. This is the S3-style envelope pattern, done client-side. ## Encryption context (AAD) `Encrypt` takes an optional **encryption context** — an arbitrary string bound into the ciphertext as additional authenticated data, mirroring AWS KMS. Decrypt must supply the same context or it fails. Use it to pin a ciphertext to where it belongs (`tenant=acme`, `field=ssn`), so a blob can't be silently moved to a different record. The key id and version are always bound too, so a `ce1.` blob can never be replayed under a different key or version. ## Versions & rotation Every key has a **current version** that new operations use. Rotating a key adds a new version — the client generates fresh material and re-wraps it for every current grantee — and bumps the current version. **Old versions are retained**, so ciphertext and signatures produced under them keep decrypting and verifying. Decrypt reads the version out of the blob and uses the matching material automatically. ## Signing A `sign` key produces `sg1.` signatures over any message. Verification only needs the **published public key** for the signature's version, so a verifier needs org membership but *not* a grant — sign with a tightly-held key, verify anywhere. ## What the server sees > **Note:** For an `encrypt` key the server stores the AES key only as `wd1.` grants; for a `sign` key it stores the wrapped PKCS8 private key as grants **and** the public key per version. It never sees the plaintext key material, the values you encrypt, or the messages you sign — those exist only in the client that holds a grant. A full database dump yields ciphertext, public keys, and grant rows. ## AWS KMS compatibility If you already have code built against **AWS KMS**, you don't have to rewrite it. `seekrit-kms` is a small gateway that speaks the AWS KMS JSON API (`Encrypt`, `Decrypt`, `GenerateDataKey`, …) on a local endpoint: point an AWS SDK's KMS endpoint at it and it runs these same client-side operations against a managed key, so the crypto happens locally and the server still can't read it. See [AWS KMS drop-in](/docs/guides/kms-aws). ## When to use a KMS key vs. a secret Reach for a **secret** to store a config value (a database URL, an API key) that a process reads at boot. Reach for a **KMS key** when your application does its own cryptography — encrypting a database column or file, minting an envelope data key, or signing an artifact or token — and you want the key centrally managed, grantable, rotatable, and audited without ever exposing it. --- # Security model seekrit's guarantee is simple: **the server never has the information needed to read your secrets.** This page states that precisely — including the one feature you can opt into that changes it, and exactly how far that change reaches. ## What the server stores - Secret **ciphertext** (AES-256-GCM blobs). - Environment DEKs, but only **wrapped** to principals' public keys — never in the clear. - Users' **public keys**, and their **private keys only in passphrase-encrypted form**. - For service tokens: a **SHA-256 hash** of the token and the token's **public key**. - Metadata: org/app/environment/secret **names**, versions, timestamps, roles, and the audit log. A value that [references another secret](/docs/guides/references) is no different: the server holds the ciphertext of the literal `${OTHER_SECRET}` text and never resolves it. Expansion happens in the client that already holds the key, alongside decryption. ## What the server never sees - Plaintext secret values — with one exception you turn on yourself, described in the next section. - Any DEK in the clear. - Any user's private key in the clear. - Any passphrase. - Any sign-in password — email/password auth is verified by Stytch; seekrit never receives or stores it, and it is unrelated to the passphrase that decrypts secrets. - The full service token string (only its hash). ## The one exception: third-party sync [Third-party sync](/docs/guides/third-party-sync) pushes an environment's secrets to a platform that keeps its own copy — a Vercel project's environment variables or a Cloudflare Worker's secret bindings, for example. Somebody has to hold the plaintext to send it, and a sync runs when nobody is logged in, so the sync engine has to be able to decrypt on its own. This is the only place seekrit's servers decrypt anything, and it is off unless you turn it on. The boundaries: - **Per environment, per destination.** Enabling sync for `production` grants nothing for `staging`, and grants nothing to any destination but the one you named. - **You create the grant, not us.** Access is an ordinary [key grant](/docs/concepts/access-control) — the environment's data key wrapped, in your browser, to a public key belonging to that sync connection. The API cannot produce that wrap, so sync cannot be enabled server-side even by someone with full control of our infrastructure. - **The matching private key is not in the database.** It lives in the sync engine's own storage. A full database dump still yields only ciphertext. Deleting the connection destroys the key, which makes every grant to it permanently undecryptable — by us too. - **Plaintext is transient.** A run decrypts, pushes, and drops the values. Nothing is cached between runs, and the grant is re-read every time, so revoking it stops the next run. - **On the record.** Enabling sync requires an explicit acknowledgment, stored in the audit log with who did it and when. Every run writes its own audit entry — a push is the moment your plaintext leaves seekrit, so it is recorded durably rather than sampled. If you can run code where the secrets are used, you don't need any of this: [`seekrit run`](/docs/guides/run), the [egress proxy](/docs/guides/agent-proxy), the [language SDKs](/docs/guides/sdks), and the [Kubernetes chart](/docs/guides/kubernetes) all decrypt on your side. Sync is for platforms whose runtime you cannot get in front of. ## Threat model **A full database compromise** yields ciphertext and metadata. An attacker learns that `STRIPE_KEY` exists in `acme/storefront/production` and when it changed, but not its value. To decrypt, they would additionally need a principal's private key, which the database does not contain. **A compromised or malicious server** can deny service, tamper with metadata, or serve altered ciphertext — but AES-GCM authentication means tampered ciphertext fails to decrypt rather than producing wrong plaintext silently. It still cannot read secret values, because it never holds the keys. The exception is bounded by design: an attacker with full control of the control plane still cannot enable [sync](#the-one-exception-third-party-sync) on an environment, because the key grant that allows decryption can only be produced client-side by someone who already holds the key. They could read environments where you had *already* enabled sync — which is why the grant is per environment, per destination, and revocable. **A leaked passphrase** exposes that user's private key (and therefore every environment granted to them). Treat passphrases like the master credential they are. **A leaked service token** grants whatever environments were wrapped to it. Revoke it; rotate the affected environment keys. **A machine with the last-known-good cache enabled** holds a copy of the encrypted resolve response beside the token that opens it. Anyone who can read that file can already read the token and fetch the same payload themselves, so it adds no new exposure — but it does outlive the network. While the API is unreachable, a revoked token keeps working until the entry expires (`--cache-max-age`, default 24h), and a client served from cache makes no resolve call, so it raises no `env.resolve_denied` audit entry. A reachable API that refuses the token deletes the entry immediately. The cache is off unless you turn it on — see the [CLI reference](/docs/reference/cli#last-known-good-cache). **A leaked CLI session** (the credential `seekrit login` saves) acts as that user against the management API, but decrypts nothing on its own: it holds no key material, so reading a secret's value still requires that user's passphrase. Revoke it from **account → security** or with `seekrit logout`. > **Note:** Names are metadata, not secrets. Don't encode sensitive information in org, application, environment, or secret **names** — only in secret **values**. ## Audit trail Every write, grant, revocation, and token action is recorded in an append-only audit log, attributed to the acting user or service token, with contextual metadata (never secret material). The API exposes no update or delete path for audit entries. This gives you an after-the-fact record of who touched what, even though the server can't read the secrets themselves. High-volume environment resolves (`GET /v1/resolve`, called on every app or CI startup) are the deliberate exception: they are metered for usage and billing rather than written to the audit log per call, so the log stays a signal of change and access-control events, not a firehose of identical machine reads. A *denied* resolve — a principal without a key grant — is still audited. ### Exporting to your SIEM An organization admin can stream the audit trail to an external OTLP/HTTP logs endpoint (Datadog, Grafana, Splunk, an OpenTelemetry Collector, etc.) for security monitoring and long-term retention. seekrit ships each new audit event as an OTLP log record, with **at-least-once** delivery: a per-org watermark advances only after your collector accepts a batch, so a transient outage re-ships rather than drops events. The endpoint's auth header is a control-plane integration credential — encrypted at rest with a server key and never returned by the API — not a customer secret, so the zero-knowledge invariant is unaffected: exported records carry only the audit log's redacted metadata, never secret values. See the [audit log export guide](/docs/guides/audit-export). ## Transport and authentication - All API traffic is authenticated: a Stytch session JWT (verified locally against Stytch's JWKS), a CLI session, or a service token. - Session JWTs are validated for issuer, audience, and expiry. - Service tokens and CLI sessions are matched by hash and checked for revocation and expiry on every request. Neither is ever stored in a form that could be replayed from a database dump. ## Multi-factor authentication Members can protect their account with a second factor — a time-based one-time code (TOTP) from an authenticator app such as 1Password, Google Authenticator, or Authy. Enroll from **account → security** in the dashboard: scan the QR code, confirm a code, and save the one-time **recovery codes** shown once (each signs you in once if you lose your device). Organization admins can require it for everyone from **organization → settings → access & security**. With the policy on, any member without a second factor must set one up before they can access the organization, and every sign-in is challenged for a code after the primary factor. Because MFA is enforced by the identity provider before a session is issued, the second factor gates the session token itself — the API only ever sees an already-MFA-satisfied session JWT. No secret material or private key is involved, so the zero-knowledge invariant is unaffected. Enabling, disabling, and changing the org MFA policy are recorded in the [audit log](#audit-trail). One action asks for the second factor **again**, mid-session: authorizing a CLI device (`seekrit login`). That single click mints a 90-day credential, so a member with MFA enrolled must have entered a code within the last 10 minutes for the approval to be accepted — checked server-side against the session's authentication factors, not just prompted for in the UI. A lifted browser session therefore isn't enough to walk away with a terminal credential. > **Note:** MFA guards **who can start a session**. It is not a substitute for your passphrase, which is what actually decrypts secrets — see [encryption](/docs/concepts/encryption). ## Responsible disclosure If you find a vulnerability, please report it privately to [security@seekrit.dev](mailto:security@seekrit.dev) rather than disclosing it publicly. We'll acknowledge your report and keep you updated on the fix. --- # Temporary access seekrit can mint **short-lived credentials** on demand — a temporary Postgres, MySQL/MariaDB, or MongoDB login, a Redis ACL user, an SSH certificate, or an AWS or GCP credential, that exists for an hour then disappears — without giving up the zero-knowledge property that defines the rest of the product. Postgres, MySQL/MariaDB, Redis, SSH, AWS, GCP, and MongoDB are all supported today; the machinery is general. ## The problem it solves A long-lived database password in a `.env` file is a standing liability: it leaks, it lingers, it is hard to rotate. The Vault-style answer is **dynamic secrets** — mint a credential per session, scoped and expiring. The catch is that a naïve implementation has the control plane generate and hand out the password, so the service sees your plaintext. seekrit avoids that. ## The key trick: ship the verifier, not the password PostgreSQL stores passwords as **SCRAM-SHA-256 verifiers**, and `CREATE ROLE … PASSWORD '…'` stores the string verbatim when it is already in verifier form — it does not re-hash it. So the flow inverts: 1. The machine that will connect generates a random password **locally**. 2. It computes the SCRAM verifier locally (PBKDF2 → HMAC → SHA-256). 3. It sends seekrit **only the verifier**, plus a role name and a TTL. 4. seekrit runs `CREATE ROLE … PASSWORD '' VALID UNTIL …`. 5. The machine connects to Postgres directly with the password it never shared. The verifier is **not sufficient to authenticate** — SCRAM login requires `ClientKey`, and the verifier only stores `SHA256(ClientKey)` (preimage- resistant) plus `ServerKey`. So a dump of `pg_authid`, the query log, or a backup cannot log in. The plaintext password lives only on the consumer. > **Note:** This is zero-knowledge at **both** layers: the control plane relays only the verifier, and Postgres-at-rest holds only a verifier that can't log in. The password is high-entropy and machine-generated, so the SCRAM iteration count (which exists to slow dictionary attacks on human passwords) is irrelevant. ## MySQL & MariaDB MySQL and MariaDB get the **same trick with a different hash**. The `mysql_native_password` plugin stores an account's secret as `*`, and `CREATE USER … IDENTIFIED WITH mysql_native_password AS '*…'` stores that string **verbatim** — it isn't re-hashed. So the client computes the hash locally and sends only that. The double-SHA1 hash **cannot log in**: `mysql_native_password` auth is a challenge-response that verifies knowledge of `SHA1(password)` (the preimage of the stored inner hash), which a dump of `mysql.user` doesn't reveal. Two MySQL-specific differences from Postgres: - **No account expiry timestamp.** MySQL has no `VALID UNTIL` for an account, so the broker's alarm-driven `DROP USER` is the *only* thing that expires a lease (which is exactly what the broker is for). - **Inline grants, no group role.** MySQL roles need activation (`SET DEFAULT ROLE`), an awkward fit for a plain connection URL — so the read-only / read-write presets apply their `GRANT`s **inline per user** at mint, scoped to the target database. There is no one-time setup query. ## Redis Redis (6+) gets the **same trick with a third hash**. A Redis ACL user is created with `ACL SETUSER on #`, where `#` is the **lowercase-hex SHA-256** of the password, stored **verbatim**. So the client computes the digest locally and sends only that. The digest **cannot log in**: `AUTH ` hashes the *plaintext* it receives with SHA-256 and compares — verifying a client needs the password, and SHA-256 is preimage- resistant for the high-entropy machine-generated password, so a dump of the ACL rules can't authenticate. Like MySQL, Redis has **no per-user expiry** (the broker's alarm-driven `ACL DELUSER` is the only thing that ends a lease) and **no role inheritance**, so the read-only / read-write presets apply their ACL rules **inline per user** at mint: - **Read-only** — `~* +@read +@connection` (read every key; the client handshake commands work). - **Read-write** — the above plus `+@write`. - **Custom** — supply your own `ACL SETUSER` / `ACL DELUSER` command templates. > **Note:** Redis provisioning speaks **RESP, not SQL** — each rendered statement is one Redis command line. The in-DO executor sends it over a `redis://`/`rediss://` socket; the remote executor's provisioner runs it against the admin endpoint it holds. Everything else (verifier-only minting, alarm expiry, the executor dial) is identical to the SQL providers. ## Provider tiers Not every system supports verifier injection, so providers are classified by how much zero-knowledge they can preserve for the credential they mint: - **Tier 1 — verifier injection.** The target accepts a client-computed verifier; plaintext never leaves the consumer. **Postgres SCRAM**, **MySQL `mysql_native_password`**, **Redis ACL SHA-256**, SSH public keys, mTLS CSR signing, hashed API keys. - **Tier 2 — client-encrypted delivery.** The target mints the secret itself, so the broker wraps it to the consumer's public key before returning it. The control plane relays ciphertext; the broker sees plaintext transiently. **AWS STS** (see [AWS credentials](#aws-credentials)), **GCP tokens** (see [GCP credentials](#gcp-credentials)), and **MongoDB** (see [MongoDB credentials](#mongodb-credentials)) ship today; Azure short-lived tokens are the same shape. - **Tier 3 — opaque broker.** The target mints an opaque token the broker must hold; the best option is to never hand it out and proxy the requests instead. ## The broker Each organization gets its own **broker** (a Cloudflare Durable Object, one instance per org). It owns the lease lifecycle in its own isolated SQLite, and uses **alarms** to expire leases — the sweeper that drops the role at its deadline (managed Postgres has no in-database scheduler). It holds two secrets carefully: - its **own keypair**, generated once, private half never leaving the DO; - each target's **admin credential**, stored only as ciphertext wrapped to that public key, decrypted transiently in memory to provision, never written back. ## The provisioning credential — the honest caveat To run `CREATE ROLE` / `CREATE USER`, something must authenticate to the database as a privileged role. That admin credential is a second, narrower trust tier — the same trade every dynamic-secrets engine makes. seekrit minimizes it, and lets you choose where it lives via the **executor**: - **in-DO executor** — the broker connects to the database and runs the SQL itself. Convenient; the admin credential is decrypted transiently inside the DO. - **remote executor** — the broker dispatches a signed command to a small `seekrit-provisioner` running inside your own network, which holds the admin credential. seekrit orchestrates but never sees it — **pure zero-knowledge even for provisioning**. The credential you hand out stays zero-knowledge either way; the executor is the dial for how much the *provisioning* credential is exposed. The remote executor ships as a tiny container you run in your own network — the [self-hosted provisioner guide](/docs/guides/provisioner) walks through minting the shared key, registering a `remote` target, and running the daemon. ## Access levels & permissions A freshly created role has **no privileges** — it can connect but read nothing, by design (least privilege). You grant access by picking an **access level** when you register a target: - **Read-only** — leased credentials can only `SELECT`. - **Read-write** — `SELECT / INSERT / UPDATE / DELETE` (plus sequence usage so inserts into serial/identity columns work). - **Custom** — supply your own create/revoke SQL templates. For the two presets, seekrit shows you a **one-time setup query** to run as an admin. It creates a shared **group role** (`seekrit_readonly` / `seekrit_readwrite`) with the right grants, including `ALTER DEFAULT PRIVILEGES` so future tables are covered. It's idempotent — safe to re-run: ```sql DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'seekrit_readonly') THEN CREATE ROLE seekrit_readonly NOLOGIN; END IF; END $$; GRANT USAGE ON SCHEMA "public" TO seekrit_readonly; GRANT SELECT ON ALL TABLES IN SCHEMA "public" TO seekrit_readonly; ALTER DEFAULT PRIVILEGES IN SCHEMA "public" GRANT SELECT ON TABLES TO seekrit_readonly; ``` Every leased credential is then created **`IN ROLE seekrit_readonly`** and inherits those grants — so you configure permissions once, and the temp roles stay disposable (they own nothing, so `DROP ROLE` at expiry is clean). The dashboard badges each target and lease with its access level, so "this credential can write" is always visible. On **MySQL/MariaDB** the presets skip the group role: each leased user gets the grant applied **inline** at mint (e.g. `GRANT SELECT ON \`app\`.* TO 'tmp_…'@'%'`), scoped to the target database. There's no setup query to run, and `DROP USER` at expiry removes the user and its grants together. On **Redis** the presets are likewise inline — the leased user is created with its ACL rules in the same `ACL SETUSER` command (e.g. `~* +@read +@connection`), and `ACL DELUSER` at expiry removes the user and its rules together. No setup step, no group. ## Provider compatibility The verifier-injection trick relies on **standard** database behavior — a pre-hashed password is stored verbatim. **Postgres.** A pre-hashed `SCRAM-SHA-256$…` password is stored as-is on **Render, AWS RDS, Cloud SQL, Azure, Crunchy Bridge, self-hosted**, and most managed Postgres. The in-DO executor authenticates its own admin connection via **SCRAM, MD5, or cleartext** (over TLS), so it works whether a provider uses the modern SCRAM default or still challenges MD5 for login. **MySQL / MariaDB.** `CREATE USER … IDENTIFIED WITH mysql_native_password AS '*…'` stores the pre-hashed value as-is on **standard MySQL 8, MariaDB, AWS RDS / Aurora, Cloud SQL, Azure**, and self-hosted. The in-DO executor authenticates its admin connection with **`mysql_native_password` or `caching_sha2_password` (MySQL 8's default) over TLS**, so it works against a default MySQL 8 admin account as well as MariaDB. **Redis.** `ACL SETUSER … on #` stores the digest as-is on **Redis 6+** (where ACLs were introduced) and Redis-compatible servers — **self-hosted, AWS ElastiCache/MemoryDB, Valkey**. Use `rediss://` for a TLS admin endpoint (the default on managed providers); the admin user needs the `+@admin` permission (or just `@dangerous`) to run `ACL SETUSER`/`ACL DELUSER`. > **Warning:** **Control-plane-proxied databases aren't supported for the zero-knowledge path.** Some managed providers intercept role/user creation and demand a **plaintext** password, which is incompatible with sending only a verifier: **Neon** (Postgres — *"Neon only supports being given plaintext passwords"*) and **PlanetScale** (MySQL — user management goes through its own API, not `CREATE USER`). Use a database whose role/user creation isn't proxied by a control plane. ## SSH certificates The same machinery issues **short-lived SSH certificates**, and it's an even cleaner zero-knowledge story than Postgres — nothing secret is ever sent to or returned by seekrit. seekrit acts as an SSH **certificate authority**. When you register an SSH target, a CA keypair is generated **in your browser/CLI**; only the CA *private* key is wrapped to the broker (stored as ciphertext), and the CA *public* key is printed for you to install on your hosts once: ``` # /etc/ssh/sshd_config TrustedUserCAKeys /etc/ssh/seekrit_ca.pub ``` Minting a certificate then works like this: 1. The machine that will connect generates an **ephemeral SSH keypair locally**. 2. It sends seekrit **only the public key**, plus the login principals and a TTL. 3. The broker signs a certificate over that public key (`valid principals`, `valid before = now + TTL`) using the CA key, decrypted transiently in the DO. 4. seekrit returns the **certificate** — a public artifact — and you connect: ``` ssh -i id_ed25519 -o CertificateFile=id_ed25519-cert.pub deploy@host ``` The private key never leaves the requesting machine, and a certificate cannot authenticate without it — so, like the Postgres verifier, what seekrit handles is never sufficient to log in. This is tier 1 (verifier injection): the public key is the "verifier". Certificates use Ed25519, and the CA-signing (`in_do`) executor is the only mode for now. > **Note:** **Revocation is by expiry.** A certificate is a self-contained bearer artifact, so seekrit v1 relies on **short TTLs** rather than a revocation list — keep lifetimes tight (minutes to a few hours). Revoking a lease records the event in the ledger and audit trail, but an already-issued certificate stays valid until it expires. A published KRL (`RevokedKeys`) for early revocation is planned. ## AWS credentials AWS is the first **tier-2** provider. Unlike the databases and SSH — where the consumer computes a verifier and the plaintext never leaves — AWS mints the credential itself (an access key + secret + session token). So it can't be verifier-only; instead the credential is **encrypted to the consumer before it comes back**, which keeps it zero-knowledge at the control plane. You register one **assumable IAM role** as a target. seekrit assumes it via STS `AssumeRole` using a base IAM credential (the wrapped admin secret) whose only required permission is `sts:AssumeRole` on that role. What the leased credential can *do* lives entirely in IAM: the role's attached policies define its permissions, optionally narrowed per target by an inline session policy. Minting works like this: 1. The machine that will use the credential generates an **ephemeral P-256 keypair locally** and sends seekrit **only the public key**, plus a TTL. 2. The broker calls STS `AssumeRole` with the base credential, decrypted transiently in the DO, and gets back the temporary credential. 3. The broker **wraps that credential to your public key** (the same `wd1.` envelope used for DEK grants) and returns only the ciphertext. 4. Your machine unwraps it with the private key it never shared: ``` eval "$(seekrit aws lease prod-deploy --ttl 1h)" aws sts get-caller-identity ``` The control plane only ever relays ciphertext, and only your private key can unwrap it — so, as with the other providers, what seekrit stores or returns is never usable on its own. This is the one place the broker sees plaintext transiently (it does the STS call and the wrap), the scoped exception tier 2 makes — the same trade every dynamic-secrets engine makes for the provisioning credential. > **Note:** **Revocation is by expiry.** STS session credentials can't be revoked individually, so — like SSH certificates — v1 relies on the short lifetime STS itself enforces (`DurationSeconds`, 15 minutes to 12 hours; the requested TTL is clamped to the role's `MaxSessionDuration`). Revoking a lease records the event in the ledger and audit trail, but an issued credential stays valid until it expires. `in_do` execution only — the remote provisioner speaks SQL, not the AWS API. ## GCP credentials GCP is the second **tier-2** provider, and works exactly like AWS against Google's IAM Service Account Credentials API. You register one **impersonable service account** as a target. seekrit impersonates it with a base **service-account key** (the wrapped admin secret) for a *source* service account that holds `roles/iam.serviceAccountTokenCreator` on the target. What the leased token can *do* lives entirely in GCP IAM: the target service account's roles define its permissions, narrowed per lease by the OAuth `scopes` you grant. Minting works the same as AWS: 1. The machine that will use the credential generates an **ephemeral P-256 keypair locally** and sends seekrit **only the public key**, plus a TTL. 2. The broker signs a JWT with the source key, exchanges it for the source account's access token, and calls `generateAccessToken` to impersonate the target account — all with the admin key decrypted transiently in the DO. 3. The broker **wraps the returned OAuth token to your public key** (the same `wd1.` envelope used for DEK grants) and returns only the ciphertext. 4. Your machine unwraps it with the private key it never shared: ``` eval "$(seekrit gcp lease prod-deploy --ttl 1h)" gcloud storage ls ``` The leased token is a standard OAuth 2.0 access token — the `export` lines set `CLOUDSDK_AUTH_ACCESS_TOKEN` (for `gcloud`) and `GOOGLE_OAUTH_ACCESS_TOKEN` (for the Google client libraries). As with every other provider, the control plane only ever relays ciphertext, and only your private key can unwrap it. > **Note:** **Revocation is by expiry.** Short-lived access tokens can't be revoked individually, so — like SSH certificates and AWS — v1 relies on the short `lifetime` GCP enforces (1 minute to 12 hours; tokens over 1 hour require the `constraints/iam.allowServiceAccountCredentialLifetimeExtension` org policy). Revoking a lease records the event in the ledger and audit trail, but an issued token stays valid until it expires. `in_do` execution only — the remote provisioner speaks SQL, not the Google APIs. ## MongoDB credentials MongoDB is a **tier-2** provider, for a reason worth stating plainly: unlike Postgres, MySQL, and Redis — where the target stores a client-computed verifier verbatim — MongoDB's `createUser` **hashes the password server-side**, and there is no supported way to inject a precomputed SCRAM credential. So the plaintext can't stay on the consumer's machine the way it does for the tier-1 databases. MongoDB takes the same shape as AWS instead: the credential is **encrypted to the consumer before it comes back**. You register one **cluster** as a target, with the database leased users get access to and an access level. seekrit provisions with an admin `mongodb://` credential (the wrapped admin secret) that needs `userAdmin` on that database. Access levels map to MongoDB's built-in roles — `read` (read-only) or `readWrite` — on the target database, or you supply an explicit `roles` array for `custom`. Minting works like this: 1. The machine that will use the credential generates an **ephemeral P-256 keypair locally** and sends seekrit **only the public key**, plus a TTL. 2. The broker **generates a random password**, connects to MongoDB over its wire protocol (with the admin credential decrypted transiently in the DO), and runs `createUser` with the preset roles. 3. The broker **wraps the resulting credential to your public key** (the same `wd1.` envelope used for DEK grants) and returns only the ciphertext. 4. Your machine unwraps it with the private key it never shared: ``` eval "$(seekrit mongodb lease prod-app --ttl 1h)" mongosh "$MONGODB_URI" ``` The control plane only ever relays ciphertext, and only your private key can unwrap it. The broker sees the password transiently (it generates it and runs `createUser`), the scoped exception tier 2 makes — the same trade the tier-1 providers make for their *admin* credential. > **Note:** **Revocation is real.** Unlike AWS/SSH, a MongoDB lease is ended by dropping the user: the broker's alarm runs `dropUser` at expiry, and revoking a lease drops it immediately. `in_do` execution only — the remote provisioner speaks SQL, not the MongoDB wire protocol. `mongodb+srv://` admin URLs aren't supported (they need a DNS SRV lookup); register an explicit `host:port`. See the [CLI reference](/docs/reference/cli) (`seekrit pg`, `seekrit mysql`, `seekrit redis`, `seekrit ssh`, `seekrit aws`, `seekrit gcp`, `seekrit mongodb`) and the [API reference](/docs/reference/api) for the endpoints. --- # Secret rotation seekrit can **replace a stored secret's value on a schedule** — a database password, an API key, a signing secret — and, for databases, change the credential on the system it authenticates to in the same step. Configure it once and the value is never older than your cadence. This is the mirror image of [temporary access](/docs/concepts/temporary-access). There, seekrit *mints* a credential nobody stored, and it expires. Here, seekrit *replaces* a long-lived credential you do store. Same targets, same admin credential, same verifier trick — opposite lifecycle. If you can use a short-lived credential instead, prefer it; rotation is for the credentials that have to be long-lived. ## What rotation does A **rotation policy** attaches to one secret. When it comes due, seekrit: 1. generates a fresh high-entropy value, 2. for a database kind, installs it on the target account, and 3. writes the new value back as a new version of the secret — encrypted. Consumers do nothing. The next `seekrit run`, `GET /v1/resolve`, or SDK call returns the new value, decrypted with the same environment key as always. Every rotation appends a `secret_versions` row, so the history shows exactly which versions rotation produced. ## Kinds | Kind | What it rotates | Reaches out to | | --- | --- | --- | | `generated` | A random value seekrit is the source of truth for | nothing | | `postgres` | An existing Postgres role's password | your cluster | | `mysql` | An existing MySQL/MariaDB account's password | your cluster | | `redis` | An existing Redis ACL user's password | your instance | The database kinds are what Vault calls **static roles**: the account is never created or dropped, only re-keyed. You name an account that already exists and seekrit changes its password in place, so every grant, ownership, and reference attached to it survives. `generated` contacts nothing — it is for credentials whose consumer reads them *from* seekrit (internal API keys, webhook signing secrets, an encryption pepper). Rotating one changes the stored value; anything that validates it must read it from seekrit rather than hold its own copy. ## The verifier trick, again The database kinds keep the same property temporary access has: **your database never receives the new password**, only a one-way verifier of it. - **Postgres** — `ALTER ROLE … WITH PASSWORD ''` stores the verifier verbatim. It can't authenticate: SCRAM login needs `ClientKey` and the verifier holds only `SHA256(ClientKey)`. - **MySQL/MariaDB** — `ALTER USER … IDENTIFIED WITH mysql_native_password AS '*'` stores the double-SHA1 hash verbatim. It can't authenticate either (auth proves knowledge of the `SHA1(password)` preimage). - **Redis** — `ACL SETUSER … on resetpass #` stores the digest verbatim. `resetpass` clears the account's previous passwords first, so the old credential stops working the moment the new one lands, and `SETUSER` merges, so the user's key and command permissions are untouched. So a dump of `pg_authid`, `mysql.user`, or the Redis ACL file still can't log in after a rotation — exactly as before one. ## The trust boundary Rotation is the one feature where something server-side must be able to **encrypt into** one of your environments, and therefore to decrypt it. There is no way around that: writing a new secret value means producing ciphertext under the environment's data key. We make the boundary as narrow and as visible as possible. **The rotator is just another principal.** Every organization has a **broker** Durable Object — the same one that mints temporary credentials — with its own P-256 keypair whose private half never leaves its storage. Enabling rotation creates an ordinary key grant to that public key: an `environment_keys` row with `principal_type = rotator`. That grant is what lets the broker rotate, and it has the properties every other grant has. What follows from that: - **You establish it, not us.** The wrapped key is computed **on your machine** (the CLI or your browser tab) from your own copy of the environment key, and uploaded already-wrapped. The API cannot produce it — enabling rotation requires someone who already holds the key, and it is audited when they do. - **It is per environment.** The broker can decrypt exactly the environments where you enabled rotation, and nothing else. An environment with no rotation has no rotator grant. - **It ends when the feature does.** Disabling the last rotation policy in an environment (or deleting the last rotating secret) drops the grant. - **A database dump is still only ciphertext.** The grant stores the key wrapped to a public key whose private half lives in Durable Object storage, not in the database. - **The plaintext lives in one function.** The new value is generated inside the broker, used to derive a verifier, encrypted, and discarded. What leaves the broker is an `sc1.` blob; what reaches your database is a verifier. Nothing writes it to storage or logs. - **Revoking is immediate.** The grant is re-read from the database on every rotation rather than cached, so removing it stops rotation at the first step. > **Note:** This is a deliberate, scoped exception to the zero-knowledge invariant, in the same family as the admin credential a lease target holds (`in_do` execution) — and it is opt-in per environment. If you would rather seekrit never hold the ability to decrypt an environment at all, don't enable rotation for it: rotate by writing a new value yourself (`seekrit secrets set`), which stays fully client-side. ## Scheduling, failure, and the honest caveat The schedule runs on a one-minute sweep, so a policy rotates within a minute of coming due. Cadence is anything from 5 minutes to a year. Rotation changes your database **before** it stores the new value, which is the safer of the two orders: the only failure window leaves the *stored* value stale rather than storing a password that was never installed. It is worth being precise about what that means: - If the write-back fails after the database was re-keyed, consumers hold a password that no longer works until the retry succeeds. seekrit records the failure, emails your admins, and retries with a **fresh** value on a backoff (15 minutes, doubling, capped at 6 hours). The retry re-keys and re-stores together, so the state converges. - After five consecutive failures the policy stops retrying and goes `failed`, so a broken target surfaces instead of retrying forever. Fix the cause and resume it — a successful rotation clears the streak. - Long-lived connections already authenticated with the old password are not affected by a rotation; new connections need the new one. Applications that resolve secrets once at boot should be able to re-resolve (or be restarted) after a rotation. Choose a cadence your deployment can absorb. Every rotation writes a synchronous audit row (`secret.rotated`), as does every failure (`secret.rotation_failed`) and every configuration change (`secret.rotation_configured` / `_updated` / `_disabled`). Rotation also purges the cached resolve for the environment, so nothing serves the previous ciphertext. ## Rotating the *key*, not the value Rotation replaces a secret's **value**. That is different from rotating an environment's **data key** — issuing a new key, re-encrypting every secret under it, and re-wrapping it for the remaining principals — which is what cuts off a principal that may have cached the old key. See [access & key grants](/docs/concepts/access-control) for that. See the [rotation guide](/docs/guides/rotation) to set it up, the [CLI reference](/docs/reference/cli) (`seekrit rotation`), and the [API reference](/docs/reference/api) for the endpoints. --- # Web dashboard The web dashboard is a Next.js app where you manage orgs, applications, environments, and secrets. All encryption and decryption happens **in your browser** — the same envelope-encryption scheme the CLI uses. ## Signing in Sign in with **Google**, **GitHub**, or an **email and password**. All three use Stytch B2B *discovery*: you authenticate first, then choose an existing organization or create a new one, and a session is issued. seekrit provisions the matching organization just-in-time on your first authenticated request. - **Google / GitHub** — click **Continue with Google/GitHub**, authenticate with the provider, and you're redirected back to choose your organization. - **Email and password** — enter your email and password and click **Sign in**. ### Creating an account with email New to seekrit? Choose **Create an account** and enter your email — we'll email you a link to set a password. (Passwords are tied to your account, so you set yours from that link rather than typing it into the sign-up form; the emailed link also verifies your address up front.) Open the link, pick a password, and you continue to organization selection just like any other sign-in. Forgot an existing password? **Forgot password?** on the sign-in screen uses the same email-link flow. > **Note:** **GitHub sign-in asks for an email code.** Unlike Google, GitHub doesn't attest that your email is verified, so after you choose an organization we email you a one-time code to confirm the address before issuing a session. Enter it (or hit **Resend code**) to finish signing in. This is a primary-factor check, not two-factor auth — any 2FA on your GitHub account is handled by GitHub itself before you're redirected back. (Email/password sign-in is already email-verified, so it skips this step.) > **Note:** Sign-in is backed by Stytch B2B. The browser only ever receives a **public** token; the secret key lives with the API and never reaches the client. Your **password is verified by Stytch**, not stored by seekrit — and, like OAuth, it is unrelated to your encryption **passphrase**, which (along with your private key and plaintext secrets) never leaves your browser. ## Key setup The first time you sign in you'll be asked to create your encryption keys. The browser generates a P-256 keypair and encrypts the private key with a passphrase you choose. Only your **public key** and the **passphrase-encrypted** private key are uploaded. > **Warning:** Your **passphrase is not your sign-in password.** It never reaches the server, so there is no passphrase reset — if you forget it, your encrypted data cannot be recovered. Store it in a password manager. (Your email/password **sign-in** credential, by contrast, *can* be reset by email — it unlocks a session, not your secrets.) On later visits, the keyring starts **locked**. The first time you reveal or edit a secret in a session, you'll be prompted to unlock it with your passphrase; it stays unlocked (in memory only) until you lock it or reload. ![The unlock keyring dialog over a secrets table whose values are still redacted](https://seekrit.dev/screenshots/original/dashboard-unlock-keyring.webp) *Revealing a value unlocks the keyring first. The passphrase decrypts your private key in the tab and is never sent anywhere.* ## Two-factor authentication Add a second factor to your account from the account menu → **security**. Choose **Enable two-factor authentication**, scan the QR code with an authenticator app (1Password, Google Authenticator, Authy, …), and enter the 6-digit code to confirm. seekrit then shows a set of one-time **recovery codes** — save them somewhere safe; each one signs you in once if you lose your device. You can regenerate the codes or disable the second factor from the same page. Once enabled, sign-in prompts for your authenticator code after the provider step. Lost your device? Choose **Use a recovery code** on that screen. > **Note:** **Requiring MFA for everyone.** Org admins can turn on **Require two-factor authentication** under **organization → settings → access & security**. Members without a second factor are then prompted to set one up before they can access the organization. This is distinct from the GitHub email-code check above (a primary-factor step) — it's a genuine second factor, and it's independent of your passphrase, which is what decrypts secrets. ## Members and invites The **Members** page lists everyone in the org with their role and whether they've finished encryption-key setup. Admins and owners see an **Invite member** button: - Enter the teammate's **email** and pick a **role** — `member` or `admin` (ownership can't be granted through an invite). We email them a magic-link invitation. - Following the link and signing in makes them a member **at the role you chose**. Until then the invite shows under **Pending invites**, where you can **revoke** it (revoking only removes the pending record — it doesn't affect anyone who has already joined). > **Note:** Joining an org doesn't grant access to any secrets. A new member still needs their own [environment key grants](#managing-secrets), and must finish key setup before they can receive them — the Members page flags who's still **setup pending**. ## Managing secrets ![An application's environment matrix: one row per secret name, one column per environment, with values revealed and two rows inherited from a group](https://seekrit.dev/screenshots/original/dashboard-secrets-matrix.webp) *An application page: every secret across every environment. Each column header reports its key count and whether a service token is bound to it; the greyed rows are inherited from a composed group.* - **Organizations** → **Applications** → **Environments**. **New application** asks for the environments in the same dialog — `development`, `staging`, and `production` come pre-selected, and you can deselect them or add your own — so a new application opens with columns already in place. Add more later with **new environment**. Either way each environment's data key is generated in your browser and wrapped to your public key. - A new application page shows **Get this application running**: a short checklist of environments → secrets → machine access → the `seekrit run` command, each with the control that completes it. It disappears on its own once those are done (or **✕** to hide it for good). The third step is the one people miss: secrets aren't readable by anything outside the browser until a **service token** exists. - An **application page** shows the **environment matrix**: one row per secret name, one column per environment, so you can compare a key across `development`, `staging`, `production`, … at a glance. Each column header reports its own state — how many keys it holds, and either the number of service tokens bound to it or a **no token** warning you can click to mint one. The header's **⋯** menu opens the environment, reveals the column, or mints a token; the environment name links through to its grants and composition. A cell that isn't set in an environment reads **not set** (drift you can spot immediately); an environment you don't hold a key grant for shows a **lock** — you still see that the key exists and its version, but the value stays hidden. **Reveal all**, a column header, or a row's eye decrypt in bulk (one unlock, then locally); a revealed value wraps to its full length instead of stopping at the column edge, so nothing stays hidden behind an ellipsis. Add a key across several environments at once with **Add key**, and a cell's **⋯** menu can **copy** its value to other environments (decrypted and re-encrypted for each target in your browser). Past the last column, **+** adds an environment without leaving the table. - **Add key** also has a **paste .env** tab for moving a whole file in at once — the same parser [`seekrit secrets import`](/docs/guides/dotenv) uses. It previews every key it found (truncated value, and `new` vs `overwrites`), lists any invalid variable names it will skip, and then writes the lot to every environment you selected — each value encrypted separately, one unlock per environment. A multi-line value that isn't quoted is called out here, because unquoted it would otherwise import as one truncated key plus the value's own lines as garbage names. - **JSON values** — a service-account key, a Firebase config — are recognised as they're typed or pasted. The editor marks a value that parses, says how many keys it has, and offers **format** to re-indent it (or **minify** to put it back on one line); a revealed JSON secret carries a **json** tag in the table. If it *nearly* parses, it says so and offers the fix — pasting a credential along with the quotes that wrapped it in a `.env` file is the usual culprit. Nothing is rewritten unless you click: what gets encrypted is exactly what the box says. - Environments read left to right in **pipeline order** — recognised slugs (`development`, `test`, `staging`, `preview`, …) sort ahead of anything custom, and `production` always sorts last. Environments the ordering doesn't recognise fall between the two, in the order you created them. - Hold no key grant on any environment here? **Add key** becomes **request access**, which names the owners and admins who can wrap a key to you and drafts the ask — a grant can only come from someone who already holds the key, so there is nothing seekrit can do server-side. - Secrets inherited from [composed groups](#groups) join the matrix under an **inherited from groups** heading — greyed out, with each cell tagged by the group it comes from (click the tag to jump to that group environment). A secret an environment defines directly shadows an inherited one of the same name, so it shows as your own (editable) value in that column. The inherited value decrypts with the **group** environment's key, so it reveals wherever you hold that grant — even in a column you otherwise can't read. - A **group page** shows the same matrix over the group's own environments. Groups compose nothing, so there are no inherited rows — just the shared secrets you edit here, which flow into every application environment that composes the group at a matching slug. - Below the matrix, **Connect** keeps the snippets for consuming these secrets — **CLI**, **Docker**, **GitHub Actions**, and **SDK** — with the application's own slugs filled in. Pick which of the application's service tokens the snippet refers to; the token carries its own org, app, and environment, so there's nothing else to configure. (The one-time mint dialog is the only other place this appeared, and it's gone once dismissed.) - On an environment page, the **Secrets** table lets you add, reveal, edit, and delete secrets. Values are encrypted on save and decrypted on reveal, locally. The value column takes whatever width the rest of the row doesn't need — widen the window and it widens with it — and a revealed value wraps over as many lines as it takes, so a key reads in full without leaving the table. A value long enough to fill the row (a JSON credential) scrolls in place; the editor is still where it reads formatted. - A secret's **history** button (also **History / restore** in the matrix's **⋯** menu) opens every version ever stored: when it was saved, who saved it, and its value behind the usual reveal. **Restore** rolls a version back — the old value is written back as a *new* version, so nothing is overwritten and the rollback is itself undoable. Deleting a secret deletes its history too, so there's nothing left to restore afterwards. - The **Key access** panel (admins) shows who holds the environment key and lets you grant it to members or service tokens, or revoke it. Granting only re-wraps the key for a principal that already exists, so the panel also offers **mint a service token for this environment** — the same mint flow as the service tokens page, pre-bound to this environment. (Group environments don't offer it: a token binds to an *application* environment and picks up composed group keys from there.) - The **Composed groups** panel (admins) layers shared [groups](#groups) beneath an environment's own secrets — see below. ## Groups **Groups** are reusable secret bags shared across applications — a shared database cluster, third-party API keys, anything more than one app needs. Manage them from the **Groups** entry in the org sidebar. - A group has its own **environments**, one per slug you want to share (e.g. `production`, `staging`). Each group environment holds secrets and key grants exactly like an application environment — same client-side crypto, same **Key access** panel. - On an **application** environment's page, the **Composed groups** panel lets admins compose one or more groups. At resolve time the layers merge in order: composed groups first (a group lower in the list overrides one above it), then the environment's own secrets on top. Reorder with the arrows; remove with ✕. - A group is matched into an app environment **by slug**: composing `shared-infra` into an app's `production` environment pulls in the group's `production` environment. Create the matching group environment before you rely on it. > **Note:** Composition wires up *which* secrets layer together, not *who* can read them. Each principal — member or token — still needs its own key grant on every group environment it should decrypt. Minting a token (below) grants those automatically. ## Dynamic secrets The **Dynamic secrets** section (admin only) mints short-lived credentials for six providers, chosen with the **provider** selector when you add a target. **Postgres, MySQL/MariaDB & Redis** — register a target by entering its connection details and admin connection string; the admin string is wrapped to the broker's key **in your browser**, so the server only ever stores ciphertext. Pick an **access level** (read-only / read-write / custom); for Postgres presets the dialog shows a one-time **setup query** to paste into your database as an admin (MySQL and Redis presets grant inline, so there's no setup step). Click **lease** and pick a lifetime: the browser generates the password and its verifier locally, sends only the verifier, and shows the ready-to-use `postgres://`, `mysql://`, or `redis://` URL **once**. **MongoDB** — register a target by entering its connection details and admin `mongodb://` string (needs `userAdmin` on the database); the admin string is wrapped in your browser, so the server only ever stores ciphertext. Pick an **access level** — read-only / read-write map to MongoDB's built-in `read`/`readWrite` roles, or **custom** to grant specific roles — and the dialog shows a one-time query to create the provisioning user. MongoDB is tier-2 (its `createUser` hashes the password server-side), so — like AWS — **lease** generates an ephemeral keypair in the browser, sends only the public key, and seekrit generates the password, creates the user, and returns the credential **wrapped to that key**, unwrapped locally and shown as a ready-to-use `mongodb://` URL **once**. Revoking drops the user immediately. **SSH** — register a target and the browser generates a certificate authority; only its private key is wrapped and uploaded, and the dialog shows the CA **public key** plus the one-time host setup to install (`TrustedUserCAKeys`). Click **lease**, enter the login principals, and the browser generates an ephemeral keypair, sends only the public key, and shows the **private key + certificate + `ssh` command** **once**. Certificates expire on their own — revoking a lease records it in the ledger, but the cert stays valid until its TTL (keep lifetimes short). **AWS** — register a target with an assumable **role ARN**, region, and a base IAM credential (needing only `sts:AssumeRole`); the credential is wrapped in your browser, and the dialog shows the **trust policy** to attach to the role. Click **lease** and pick a lifetime (15 min – 12 h, STS's own bounds): the browser generates an ephemeral keypair, sends only the public key, and seekrit assumes the role and returns the STS credential **wrapped to that key** — unwrapped locally and shown as ready-to-source `export` lines **once**. STS credentials can't be revoked individually, so they rely on their short TTL. **GCP** — register a target with the service account to impersonate, optional OAuth scopes, and a source **service-account key JSON** (needing `roles/iam.serviceAccountTokenCreator` on the target); the key is wrapped in your browser, and the dialog shows the **IAM binding** to apply. Click **lease** and pick a lifetime (up to 12 h): the browser generates an ephemeral keypair, sends only the public key, and seekrit impersonates the service account and returns the access token **wrapped to that key** — unwrapped locally and shown as ready-to-source `export` lines (`CLOUDSDK_AUTH_ACCESS_TOKEN` / `GOOGLE_OAUTH_ACCESS_TOKEN`) **once**. Tokens can't be revoked individually, so they rely on their short TTL. See [Temporary access](/docs/concepts/temporary-access). ## Service tokens & audit - **Service tokens** — mint machine credentials for CI and containers. Each token is **bound to one application environment**: pick the app and environment when minting, and the browser auto-grants the token that environment's key plus the keys of every group it composes (unwrapping each with your key and re-wrapping to the token). The token is shown once; copy it then. You can mint from this page, from an environment's **Key access** panel, or from a matrix column header — the last two pre-fill the binding. See [Service tokens](/docs/guides/service-tokens). - **Audit trail** — every action in the org, append-only, with actor attribution. Select any row to expand its full detail (actor, resource, IP, event ID, and metadata) and jump straight to the referenced resource. ## Troubleshooting sign-in If the dashboard shows an error after sign-in, it will name the cause: - **Session rejected (401)** — the API couldn't verify your session. Usually an expired session; sign in again. - **API unreachable** — the console couldn't reach the API. Retry in a moment. - **Wrong email or password** — sign-in failed at the password step. Double-check both, or use **Forgot password?** to set a new one by email. - **Lost your authenticator** — on the two-factor prompt, choose **Use a recovery code** and enter one of the codes you saved at enrollment. Then visit account → **security** to regenerate codes or re-enroll a new device. --- # CLI The `seekrit` CLI injects a decrypted, layered environment into your processes. It decrypts locally using the same keys as the web app — a service token's embedded key, or your passphrase-unlocked private key. At runtime a **service token selects the org, app, and environment**, so `seekrit run` needs nothing else. For an exhaustive list of commands and flags, see the [CLI reference](/docs/reference/cli). ## Authentication Two credentials, one for each kind of caller. ### You, at a terminal: `seekrit login` Run it with no arguments. The CLI prints a URL and a pairing code, opens the URL when you press `[Enter]`, and you authorize the device in the dashboard — entering your authenticator code again if you have one, since this creates a long-lived credential. ```bash seekrit login ``` ``` Sign in to seekrit to authorize this device (you@laptop). https://app.seekrit.dev/cli/login?code=RFH4R-C36QG code: RFH4R-C36QG — check it matches the one in your browser Press [Enter] to open it in your browser (Ctrl-C to cancel)… ✓ Signed in as you@example.com — this device is authorized for 90 days. ``` The saved session acts as **you**: every org you're a member of, at your role — so there's no org, app, or environment to pick, and management commands work the same as the dashboard. Reading a secret's *value* still unlocks your own private key with your passphrase on that machine, which is why a stolen session file reveals no secrets. `seekrit logout` revokes it; so does the CLI sessions list under **account → security**. On a headless box or over SSH, add `--no-browser` and open the printed URL wherever you do have a browser. ### Machines and CI: a service token A **service token** (`skt_…`) is bound to one application environment and carries its own key, so it decrypts unattended with no passphrase and no browser. Mint one in the web console (or with the CLI once you hold an admin token). - `SEEKRIT_TOKEN` — the service token. Machines and CI read it from the environment. - `SEEKRIT_API_URL` — override the API base URL (defaults to `https://api.seekrit.dev`). - `seekrit login --token skt_…` persists it to `~/.config/seekrit/config.json`. ```bash # machine / CI export SEEKRIT_TOKEN=skt_... # or persist it to the config file seekrit login --token skt_... ``` Agents take a third path — one machine credential that drives both MCP planes; see [AI agents](/docs/guides/ai-agents). ## Finding your way around Signed in on a machine you've never used before, start by asking what you can reach: ```bash seekrit whoami # who you are, and via which credential seekrit org list # every org you belong to, and your role seekrit org tree # its apps and groups, with their environments ``` `org tree` is usually enough to orient yourself. When you need detail, every resource has a `show`, and each one answers the question you actually have: ```bash seekrit app show storefront # its environments — and whether you can decrypt each seekrit env show --env production ``` `app show` marks each environment `can decrypt` or `no key`. That column is the answer to "why does `secrets get` fail here?" — secret **names** are visible to any member, but a **value** needs a key grant. `env show` goes further: which groups the environment composes and in what order, who holds a key for it, and how many secrets it has. To see and change who can read an environment: ```bash seekrit grant list --app storefront --env production seekrit grant --app storefront --env production --user teammate@example.com seekrit grant rm --app storefront --env production --user former@example.com ``` Every listing prints an aligned table at a terminal and plain tab-separated rows when piped, so `seekrit app list | cut -f1` works. Add `--json` anywhere you'd rather have the API's own response. ## Linking a project `seekrit init` writes a `seekrit.json` naming the default org and app for management commands. It is **environment-independent** — it never pins an environment — so it's safe to commit and behaves the same everywhere. ```bash seekrit init --org acme --app storefront ``` The environment is chosen at runtime by the service token (or the `--env` flag when you run commands interactively). ## Running with a service token This is the primary path: the token carries its org, app, and environment. ```bash export SEEKRIT_TOKEN=skt_... seekrit run -- ./start-server # resolves & injects; no flags needed seekrit export --format dotenv > .env ``` The resolved environment is **layered**, lowest precedence first: ``` group secrets < app-env secrets < .env file < process env ``` Add `--explain` to see where each variable came from, and `--with =` to swap a single group's slice for one run (see [Environments & groups](/docs/guides/environments)). ## Running interactively (as a user) Without a token, target the environment explicitly: ```bash seekrit run --app storefront --env production -- npm run dev seekrit export --app storefront --env production --format shell ``` ## Reading & writing individual secrets Every `secrets` command targets an environment with `--env` plus `--app` (or `--group`); `--org` comes from `seekrit.json` or a lone org. ```bash seekrit secrets list --app storefront --env production seekrit secrets get STRIPE_KEY --app storefront --env production seekrit secrets set DATABASE_URL 'postgres://…' --app storefront --env production printf '%s' "$TOKEN" | seekrit secrets set GITHUB_TOKEN --app storefront --env production seekrit secrets rm OLD_KEY --group common --env production # a group's secret ``` A value may reference another secret in the same environment — stored literally and assembled when it is read, so a rotation flows through automatically: ```bash seekrit secrets set DB_HOST db.internal --app storefront --env production seekrit secrets set DATABASE_URL 'postgres://app@${DB_HOST}/app' --app storefront --env production seekrit secrets get DATABASE_URL --app storefront --env production # postgres://app@db.internal/app (--raw prints the stored text) ``` See [secret references](/docs/guides/references) for the full rules. ## Undoing a bad write Each `set` appends a version, so you can always look back — and go back: ```bash seekrit secrets history DATABASE_URL --app storefront --env production seekrit secrets get DATABASE_URL --version 2 --app storefront --env production # peek first seekrit secrets restore DATABASE_URL 2 --app storefront --env production ``` `history` prints when each version was saved, who saved it, and which ones were themselves restores — never the values. `restore` writes the old value back as a *new* version, so nothing is lost and the rollback can be rolled back. See [point-in-time restore](/docs/reference/cli#point-in-time-restore) for the details. ## Importing a `.env` file Bulk-load an existing `.env` into an environment. Each `KEY=VALUE` is encrypted locally and stored; names that already exist are overwritten. Pass `--dry-run` first to preview which names are new versus updated (nothing is written). ```bash seekrit secrets import --app storefront --env production --dry-run # preview (default .env) seekrit secrets import .env.production --app storefront --env production cat .env | seekrit secrets import - --group common --env production # from stdin, into a group ``` The importer parses the same `.env` syntax as [`seekrit run`](/docs/guides/run) (`export` prefixes, `#` comments, single/double quotes). An invalid variable name aborts the whole import before anything is written. ## Unlocking (users) When authenticated as a user (not a service token), decryption needs your passphrase to unlock your private key. Set it non-interactively for scripts: ```bash export SEEKRIT_PASSPHRASE='…' ``` Omit it and the CLI prompts. Service tokens don't need a passphrase — their key is in the token. > **Tip:** In CI and containers, prefer a **service token** granted only the environments it needs. It requires no passphrase and can be revoked independently. See [Service tokens](/docs/guides/service-tokens). ## Installing the CLI The CLI is published to npm. Install it globally with your package manager: ```bash npm install -g @seekrit/cli seekrit --help ``` ### As a container The CLI also ships as a multi-arch Docker image — [`seekritdev/cli`](https://hub.docker.com/r/seekritdev/cli) — for CI runners and agent sandboxes. Its entrypoint is `seekrit`, so pass subcommands straight through: ```bash docker run --rm \ -e SEEKRIT_TOKEN=skt_... \ -v "$PWD:/work" -w /work \ seekritdev/cli run -- ./deploy.sh ``` Pin a release (`seekritdev/cli:0.10.0`) or track `:edge`. For a Node-free runtime image, prefer the [`seekrit-run`](/docs/guides/run) launcher instead. --- # Environments & groups Most apps share the bulk of their configuration. seekrit models this with **groups**: reusable secret bags that application environments compose. An application environment is then just *its own secrets + the groups it pulls in*, resolved and layered at runtime. ## The model - A **group** is an org-scoped secret bag with its own environments, keyed by slug (`dev`, `staging`, `production`, `sandbox`, …). - An **application environment** composes one or more groups. At resolve time each group is matched to the environment whose slug matches the app environment's. - Values **layer**, lowest precedence first: ``` group secrets < app-env secrets < .env file < process env (highest wins) ``` So a shared `DATABASE_URL` in a group is overridden by the app's own value, which is overridden by a local `.env`, which is overridden by an exported shell variable. Once the layers are merged, `${OTHER_SECRET}` references inside values are expanded — so a value can be assembled from the group's pieces and the app's overrides at once. See [secret references](/docs/guides/references). For a pull request or preview deploy that needs *almost* an environment, fork it into a [branch config](/docs/guides/branches) instead of creating a real environment: it inherits everything, overrides only what differs, and deletes itself. ## Setting it up Say an `api` and a `worker` share ~80% of their config. Put the shared values in a group and compose it into each app's environments. ```bash # 1. A shared group with a per-environment value set seekrit group create --name "Common backend" --slug common seekrit group env create --group common --name Production --slug production seekrit secrets set DATABASE_URL 'postgres://…' --group common --env production # 2. Compose it into each app environment seekrit env groups add --app api --env production --group common seekrit env groups add --app worker --env production --group common # 3. App-specific secrets live on the app environment seekrit secrets set QUEUE_NAME jobs --app worker --env production ``` Now `worker/production` resolves to `common@production` overlaid with `worker/production`'s own keys. Composing more than one group? Precedence follows `--position` (higher wins); pass it on `env groups add`. The dashboard shows the same thing from the environment's side — its own secrets, the group secrets layered beneath them, and who holds the key: ![An environment page: a secrets table with redacted values, two rows inherited from a group, and Key access and Composed groups panels alongside](https://seekrit.dev/screenshots/original/dashboard-environment.webp) *An environment page. Rows under “inherited from groups” come from a composed group; the panels on the right control who holds the environment's key and which groups layer beneath it.* ## Local `.env` and process env `seekrit run` auto-loads `.env` from the working directory and overlays it above the managed layers, and the live shell overrides everything. This makes seekrit the single way your app gets its environment while still letting you tweak individual values locally. ```bash echo 'VITE_CLIENT_BASE_URL=http://localhost:5173' > .env SEEKRIT_TOKEN=skt_… seekrit run -- pnpm dev # .env wins over managed secrets ``` Point at other files with `--env-file` (repeatable; later files win), and see exactly where each value came from with `--explain`: ```bash seekrit run --env-file .env --env-file .env.local --explain -- pnpm dev ``` ## Swapping one group per boot Group environments double as **variants**. To boot with a different slice of a single group — say staging auth keys — while keeping everything else at `dev`, override just that group: ```bash seekrit run --with auth-providers=staging -- pnpm dev ``` Everything else resolves at the app environment's slug; only `auth-providers` switches to its `staging` slice. Overrides are fail-closed: you can only pull a slice you hold a key grant for. As a logged-in developer you hold them all; for a service token, pre-authorize alternate slices at creation with `token create … --allow auth-providers=staging`. > **Note:** Committing a config file no longer pins an environment. `seekrit.json` names only org + app; the environment is selected by the service token (or `--env`). --- # Branch configs A **branch** is a disposable environment forked from a real one: `pr-142` inherits everything from `dev`, overrides only the values that differ for that pull request, and deletes itself when its lifetime runs out. It exists for the config problem every preview deploy has. Without it you either point every preview at the shared `dev` environment — so no PR can change a value without changing it for everyone, and one leaked preview token exposes all of `dev` — or you hand-create a real environment per PR and accumulate stale ones nobody deletes. ## Inherit, don't copy A branch is not a snapshot. It stores **only what you override**, and everything else resolves from its parent at read time: ``` group secrets < app-env secrets < branch overrides < .env < process env ``` Two things follow, and both matter: - **Creating a branch is instant, whatever the environment holds.** Nothing is copied or re-encrypted — a branch of an environment with 200 secrets costs the same as a branch of one with 2. - **Branches track their parent.** Rotate `STRIPE_KEY` on `dev` and every open branch picks it up on the next read. A snapshot would have gone stale. > **Note:** A branch can't out-reach what it forked. Reading one requires a key grant on every layer it resolves, including the parent — so branch access is always a subset of parent access, and revoking someone from `dev` ends their access to its branches too. ## Opening and closing one ```bash # Fork dev. Lives 7 days unless you say otherwise. seekrit branch create pr-142 --app api --from dev # Override just what differs for this PR. seekrit secrets set DATABASE_URL 'postgres://…pr-142…' --app api --env dev --branch pr-142 # Run against it — everything else comes from dev. seekrit run --app api --env dev --branch pr-142 -- pnpm dev seekrit branch list --app api seekrit branch delete pr-142 --app api ``` `--ttl` takes `30m`, `12h`, `7d`, `2w`, or `never` (max 30 days). Expired branches are deleted automatically, along with their overrides, key grants, and any service token bound to them — so forgetting to clean up is safe, not a leak. ## In CI A service token stays bound to its environment; `--branch` (or `SEEKRIT_BRANCH`) picks a branch **of that environment**, so the token you already use for `dev` needs no changes and no new grants. ```yaml env: SEEKRIT_TOKEN: ${{ secrets.SEEKRIT_TOKEN }} # bound to api/dev SEEKRIT_BRANCH: pr-${{ github.event.number }} steps: - run: seekrit branch create "$SEEKRIT_BRANCH" --app api --from dev --ttl 3d - run: seekrit secrets set DATABASE_URL "$NEON_BRANCH_URL" --app api --env dev --branch "$SEEKRIT_BRANCH" - run: seekrit run -- pnpm test # picks up SEEKRIT_BRANCH automatically ``` Then on PR close: ```yaml - run: seekrit branch delete "pr-${{ github.event.number }}" --app api ``` The TTL is the backstop for when that step never runs — a cancelled workflow, a force-pushed branch, a runner that died. `seekrit-run` takes the same `--branch` flag and `SEEKRIT_BRANCH` variable, so containers get branch configs without the Node CLI: ```bash seekrit-run --branch pr-142 -- ./server ``` ## Who can branch Anyone who can read the environment — creating a branch is not an admin action, because it grants no access the creator doesn't already have. The new branch's data key is wrapped to everyone who already holds a grant on the parent, so a branch is usable by the same people the moment it exists, with no grant paperwork. Pass `--no-share` to keep a branch to yourself. > **Note:** That fan-out happens once, at creation. A principal added to the parent *after* a branch exists — a service token minted later, a teammate granted later — won't hold that branch's key and gets `no key grant for branch …`. Grant it explicitly, or just re-create the branch; they're cheap and short-lived. Branches created after the principal are unaffected. Branches are one level deep (you can't branch a branch) and live under application environments, not groups. Their names share the application's environment namespace, so a branch can never collide with a real environment. ## Seeing where a value came from `--explain` marks inherited values against their source layer and overridden ones against the branch: ```bash seekrit run --app api --env dev --branch pr-142 --explain -- pnpm dev ``` ``` DATABASE_URL branch:api#pr-142 STRIPE_KEY app:api/dev SHARED_CA group:common@dev ``` --- # Secret references A secret's value can reference another secret: ```bash seekrit secrets set DB_HOST db.internal --env production seekrit secrets set DB_PASSWORD 's3cr3t' --env production seekrit secrets set DATABASE_URL 'postgres://app:${DB_PASSWORD}@${DB_HOST}:5432/app' --env production ``` At read time, every consumer sees the assembled value: ```bash seekrit export --env production # DATABASE_URL=postgres://app:s3cr3t@db.internal:5432/app # DB_HOST=db.internal # DB_PASSWORD=s3cr3t ``` Nothing is duplicated. Rotate `DB_PASSWORD` and `DATABASE_URL` follows on the next read — no re-encryption, no second write, nothing to forget. ## How it works (and why it stays zero-knowledge) The reference is stored **literally**. What seekrit holds is the ciphertext of the text `postgres://app:${DB_PASSWORD}@${DB_HOST}:5432/app` — the server can no more read that than any other secret, and it never resolves anything. Expansion happens **on the client, after the layers are merged**, in the same process that decrypted the values: ``` group secrets < app-env secrets < .env file (merge, then expand) ``` Two consequences worth knowing: - A reference resolves to whatever **won** the merge. If a group defines `DB_HOST` and the app environment overrides it, `${DB_HOST}` picks up the app's value — references compose with [groups](/docs/guides/environments) rather than fighting them. - `process.env` is **not** a reference source. `seekrit run` layers the live shell on top *after* expansion, so `${HOME}` in a secret is not replaced by the host's home directory. This keeps a secret's value the same wherever it runs. ## The rules | You write | You get | | --- | --- | | `${NAME}` | The value of `NAME` in the merged set. | | `${NAME}` where `NAME` is not defined | Left exactly as written, and reported (see below). | | `$${NAME}` | The literal text `${NAME}` — the escape. | | `${FOO:-default}`, `${1}`, `${a.b}` | Left exactly as written: only valid secret names (`[A-Za-z_][A-Za-z0-9_]*`) are references, so shell and CI template syntax passes through. | | `p$$w0rd$` | Unchanged. A `$` only starts a reference when followed by `{`. | References are **recursive** — a referenced value may reference others — and a **cycle** is an error (`A` → `B` → `A` has no answer, and every name in it exists, so it can only be a mistake). An unknown name is deliberately *not* an error: plenty of real config contains text like `${GITHUB_SHA}` meant for something else downstream, and one such value shouldn't break an environment's resolve. To catch typos, ask: ```bash seekrit run --explain -- ./app # DATABASE_URL app:api/production (interpolated) # DB_HOST group:common@production # DB_PASSWORD app:api/production # # unresolved reference(s), left as literal text: DB_HSOT ``` ## Where it applies Expansion is part of the read path, so it happens everywhere secrets are read — each of these does it locally, and none of them needs a new permission: | Reader | Notes | | --- | --- | | `seekrit run` / `seekrit export` | Expanded after merging; `--no-interpolate` opts out. | | `seekrit secrets get NAME` | Expanded against *that environment's own* secrets — it doesn't fetch the composed group layers. `--raw` prints the stored text, and so does `--version ` (splicing today's values into an old template would be misleading). Use `seekrit export` for the fully-layered view. | | [`seekrit-run` launcher](/docs/guides/run) | Same behavior and the same `--no-interpolate` flag. | | [Language SDKs](/docs/guides/sdks) | `resolve()` returns expanded values; construct the client with `interpolate: false` (Python `interpolate=False`, Go `WithInterpolate(false)`) for the stored text. | | [Agent egress proxy](/docs/guides/agent-proxy) | Expanded at startup, so a `{{seekrit:NAME}}` placeholder is substituted with the finished value. | | [Kubernetes (ESO)](/docs/guides/kubernetes) | The in-cluster resolver expands before ESO writes the `Secret`. | | [MCP servers](/docs/guides/ai-agents) | `run_command`, `export_env`, and a revealed `get_secret` all expand. | | Dashboard | Shows the value as **stored**; the editor tells you which names a value references. | Because expansion is client-side, a reader that predates this feature simply sees the literal text — nothing breaks, it just isn't assembled. ## When not to use it - **Don't reference across environments.** References resolve inside one resolved environment. A staging secret cannot pull from production — by design, since that would cross a key-grant boundary. - **Careful with `secrets get NAME | secrets set NAME -`.** That would store the *expanded* text and freeze the reference. Pass `--raw` when you are copying a value around. - **A password that happens to contain `${`** is almost always fine: only `${VALID_NAME}` is a reference, and only when that name exists in the same environment. If a stored value really does contain such a sequence and must survive verbatim, escape it as `$${…}`. --- # AI frameworks Every agent framework draws the same line: orchestration in, credential management out. LangGraph, Mastra, CrewAI, Pydantic AI, both Agents SDKs — none of them wants to be where your provider keys live, and all of them hand the question back to you. This page answers it twice: **the short version, which is three commands and no code**, and then what to add as the agent grows teeth. Start at the top. Nothing below the first section is required to have a working agent, and plenty of projects never need any of it. ## Get running in five minutes You need the CLI and an account: ```bash npm install -g @seekrit/cli seekrit login ``` None of the three steps below is framework-specific. If you already keep an app's config in seekrit, skip to step 2. ### 1. Put the keys in an environment An **environment** is the thing that holds secrets — one per app, per stage, like `storefront/development`. Everything in it travels together, so put the agent's provider keys in the environment its process should read. If they're in a `.env` file today, that's one command: ```bash seekrit secrets import .env --app storefront --env development ``` Or set them one at a time, or paste them into the [dashboard](/docs/guides/web-app): ```bash seekrit secrets set OPENAI_API_KEY sk-… --app storefront --env development seekrit secrets set TAVILY_API_KEY tvly-… --app storefront --env development ``` No application yet? `seekrit app create --name Storefront --slug storefront`, or create one in the dashboard and its environments come with it. The [quickstart](/docs/quickstart) walks through a first-time account (keys, passphrase, first secret) in more detail. ### 2. Mint a token for the agent A **service token** is the credential a machine uses to read that environment. It is bound to one environment when you create it: ```bash seekrit token create --name agent-dev --app storefront --env development # prints once: skt_XXXXXXXX_… ``` Copy it now — the value is shown once and cannot be retrieved later. In the dashboard the same thing is **Organization → Service tokens → Mint token**, or the **no token** badge on an environment column, which pre-fills the binding. The token carries its own private key, so it decrypts on its own: no passphrase, no config file, nothing else to set up. See [Service tokens](/docs/guides/service-tokens). ### 3. Run the agent through the CLI ```bash export SEEKRIT_TOKEN=skt_XXXXXXXX_… seekrit run -- python agent.py # or: mastra dev, langgraph dev, pnpm dev… ``` `seekrit run` resolves the token's environment, decrypts locally, and injects every value as an environment variable **in the child process only** — nothing on disk, nothing in shell history, nothing for a coding agent to find in a file later. Your framework picks the keys up exactly where it already looks: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `TAVILY_API_KEY`, your database URL. To see what the agent will get, without printing any values: ```bash seekrit export --format dotenv | cut -d= -f1 ``` > **Note:** **That is the whole integration.** No SDK, no import, no code change — you are not integrating with seekrit so much as declining to write a `.env` file. Delete the old one once the import worked: `seekrit run` still overlays a `.env` if one is there, and a file that exists is a file an agent can still read. For containers and CI, [`seekrit-run`](/docs/guides/run) is the same behaviour as a static binary with no Node dependency. ### If it didn't work | Symptom | Cause | | --- | --- | | The framework says no API key was found | The key isn't in the environment the token is bound to. `seekrit export --format dotenv \| cut -d= -f1` lists what the token actually resolves; `seekrit whoami` prints the `org/app/env` it is bound to | | The command runs but reads an old value | Something above the managed layer is winning. Precedence is `group < app-env < .env file < process env`, so a stale `.env` or an exported variable overrides seekrit. `seekrit run --explain` names the source layer of every variable, without printing values | | `seekrit run` warns and the process starts anyway | Resolving is best-effort by design — a missing token, an unreachable API, or a decryption failure logs to stderr and still launches the command with whatever `.env` and `process.env` provide. `seekrit export` is the strict version: it errors instead | | A value is right in the dashboard, empty in the process | The name has a typo, or the secret exists in a *different* environment of the same app. `seekrit secrets list --app --env ` shows names per environment | ## Which secrets does the agent get? **All of them, from the one environment its token is bound to** — plus the slices of any [group](/docs/guides/environments) composed into that environment. There is no per-key selection step on the seekrit side, and nothing to attach per framework or per tool. The token's binding *is* the selection. Two consequences worth knowing up front: - **A secret you add later just shows up.** The next `seekrit run` resolves the environment as it stands now. You don't re-mint or re-grant anything to give an agent a new key — put it in the environment and restart the process. - **A token is a decryption credential for that whole environment.** If it shouldn't hold everything in `storefront/development`, the fix is a narrower binding, not a narrower list of keys. So the coarse control — the one the server enforces — is *which environment*: | You want | Do this | | --- | --- | | The agent to read only some of an app's keys | Give the agent its own environment (`seekrit env create --app storefront --name "Agent dev" --slug agent-dev`) and bind its token there | | Two agents with different keys | A token each, bound to different environments | | Shared keys plus per-agent ones | Keep the shared ones in a **group** and compose it into each agent's environment | | A key that works for one API and nowhere else | That's not a visibility question — see [hold a placeholder](#hold-a-placeholder-not-a-key) | | One tool to spend a key the others can't | Same — see [scope a key to one tool](#scope-a-key-to-one-tool) | The last two rows are the important distinction. Everything in this section is about **what a token can read**. The levels further down are about **where a value is allowed to go** once something can read it, which is a separate gate, lives in your own config, and is optional. ## Where the seekrit token lives One credential in, all the others out — so the remaining question is where *it* goes. That depends only on where the agent runs, not on the framework: | The agent runs… | Authenticate with | | --- | --- | | On your own machine | `seekrit login`. The session lives in `~/.config/seekrit/config.json`, so there's no token in your environment at all. A session isn't bound to an environment, so pass `--app` and `--env` to `run`/`export`, and expect a passphrase prompt (`SEEKRIT_PASSPHRASE` to skip it) | | On a deployed server or container | `SEEKRIT_TOKEN` as a platform environment variable — the one variable your platform holds. Or hold nothing: [third-party sync](/docs/guides/third-party-sync) pushes the values into the platform's own environment instead | | In CI | `SEEKRIT_TOKEN` as a repository or organization secret — see [CI/CD & containers](/docs/guides/ci-cd) | | In an agent sandbox | Usually neither: resolve outside the sandbox and inject at create, or give the sandbox placeholders and a proxy endpoint. See [Agent sandboxes](/docs/guides/sandboxes) | | Unattended, provisioning its own structure | `SEEKRIT_CLIENT_ID` / `SEEKRIT_CLIENT_SECRET` — machine credentials that mint and cache an admin token automatically. See the [MCP server](/docs/mcp) | > **Warning:** **`seekrit run` passes its own environment through to the child.** A `SEEKRIT_TOKEN` you exported in your shell is therefore visible to the agent too — and a token decrypts *everything* its environment holds, not just what you injected. Fine while you're trying this out; on any machine where the agent can run shell commands, use `seekrit login` instead so there's nothing in the environment for it to find. ## Then tighten it, when you need to Four levels, in order of how little your agent's process ends up holding. Each one costs a bit more setup than the last, so take them when a specific thing worries you — not up front. ### Wrap the process — where you already are `seekrit run -- `. Zero code, every framework, every language. The values are real environment variables inside the child, which means anything the process can run can also read them. **Good enough when** the process runs only code you wrote. ### Resolve in code ```python import seekrit seekrit.Client().into_env() # or: secrets = seekrit.Client().resolve() ``` Three lines at startup with the [SDKs](/docs/guides/sdks) — [Python](https://pypi.org/project/seekrit/), [JS/TS](https://www.npmjs.com/package/@seekrit/sdk), Go, Ruby. **Reach for it when** there's no wrapper to hang `seekrit run` off (a serverless handler, a Worker, a notebook), or when different requests need *different* credentials — a multi-tenant agent resolving per tenant rather than per process. ### Hold a placeholder, not a key ```python ChatOpenAI(base_url="http://127.0.0.1:8080/openai/v1", api_key="{{seekrit:OPENAI_API_KEY}}") ``` The [egress proxy](/docs/guides/agent-proxy) holds the credential; your process holds `{{seekrit:OPENAI_API_KEY}}`. On the way out the proxy swaps in the real value — and only toward the hosts, methods, and paths you allowlisted, default-deny. Anything in an environment variable can be printed, logged, or POSTed somewhere else; a placeholder cannot. **Reach for it when** the code holding the credential is not code you trust: a [sandbox](/docs/guides/sandboxes), model-generated code, an agent whose next action you cannot predict. **Often still zero-code.** `OPENAI_BASE_URL` is a de facto standard — Pydantic AI, CrewAI, and the OpenAI Agents SDK all read it, and Claude Code and the Claude Agent SDK take `ANTHROPIC_BASE_URL`. Where a framework honours it, this level is two environment variables and no source change: ```bash export OPENAI_BASE_URL=http://127.0.0.1:8080/openai/v1 export OPENAI_API_KEY='{{seekrit:OPENAI_API_KEY}}' ``` Neither of those is a secret, so they belong in your compose file or Procfile, not in seekrit. **And often no proxy either.** The SDKs ship the same substitution engine as a `fetch` wrapper and an `httpx` transport, so your code can hold the placeholder without a sidecar — see [in-process injection](/docs/guides/agent-proxy/in-process). Weaker than the proxy, because it runs in the same process as the code holding the placeholder; much stronger than an environment variable. ### Scope a key to one tool A provider key buys tokens. A tool's Stripe or GitHub key does something irreversible. The framework adapters narrow an allowlist to a single **tool call**, so a prompt-injected `search` cannot reach a payment credential: - **LangChain / LangGraph** — [`seekrit.langchain`](/docs/guides/frameworks/langgraph#4-scope-a-key-to-one-tool) middleware - **Mastra** — [`seekritToolFetch`](/docs/guides/frameworks/mastra#5-a-key-one-tool-may-spend-and-the-others-may-not) - **Pydantic AI** — [a toolset wrapper](/docs/guides/frameworks/pydantic-ai#4-a-key-one-tool-may-spend-and-the-others-may-not) **Reach for it when** one tool in an agent can spend money or change state and the others have no business touching that credential. ## What to add, and when | If… | Add | | --- | --- | | You just don't want a `.env` file | Nothing — wrapping the process is the answer | | It's a Worker, Lambda, or notebook with no wrapper | Resolve in code | | Each request serves a different tenant | Resolve per run, not per process | | The process runs code you didn't write | A placeholder, via the proxy | | A key must work for one operation but not others | A placeholder, with `methods` and `paths` on the route | | You want a placeholder but not a sidecar | [In-process injection](/docs/guides/agent-proxy/in-process) | | One tool may spend a key and the others may not | Per-tool scoping | | The agent must not hold the seekrit token either | Run the proxy beside it, or resolve outside its process entirely | | You want several of these | Nothing conflicts; the proxy just sees a placeholder where a key used to be | ## Per framework Each page shows the same levels in that framework's own idiom — the actual constructor arguments, the actual dev command. | Framework | | Start with | Worth knowing | | --- | --- | --- | --- | | [LangGraph / LangChain](/docs/guides/frameworks/langgraph) | Python | Wrap | Per-tool scoping via LangChain 1.x middleware | | [Mastra](/docs/guides/frameworks/mastra) | TypeScript | Wrap | `model` takes a function of the request — per-tenant keys are first-class | | [Pydantic AI](/docs/guides/frameworks/pydantic-ai) | Python | Resolve in code | `deps` is a real per-run seam; a toolset scopes one tool | | [AI SDK](/docs/guides/frameworks/ai-sdk) | TypeScript | Wrap locally, resolve on serverless | `fetch` is a first-class provider option | | [OpenAI Agents SDK](/docs/guides/frameworks/openai-agents) | Python | Wrap | Tracing is a second credential and a second egress host | | [Claude Agent SDK](/docs/guides/frameworks/claude-agent-sdk) | Python / TS | Wrap | It deliberately ignores `.env`, so wrapping is the only zero-code path | | [CrewAI](/docs/guides/frameworks/crewai) | Python | Wrap | The crew's tool keys are the ones to move to a placeholder | | [LlamaIndex](/docs/guides/frameworks/llamaindex) | Python | Wrap | Ingestion pipelines hold their credential for a long run | Not listed doesn't mean not supported: wrapping the process needs nothing from the framework at all. If a framework starts a process, `seekrit run` goes in front of it. ## Agents that manage their own secrets The pages above are for *you* wiring up an agent. If the agent itself should store and inject credentials — a coding agent setting up a project, say — install the [agent plugin](/docs/guides/ai-agents/plugin) instead. It carries both MCP servers and the skills that keep values out of files. --- # LangGraph LangGraph agents get their credentials the way any Python process does — from the environment, through the provider client underneath `ChatOpenAI`. So the zero-code shape works as-is, and the proxy shape is two changed arguments. > **Note:** **Start here if seekrit is new:** [three commands](/docs/guides/frameworks#get-running-in-five-minutes) put the keys in an environment, mint a token bound to it, and export `SEEKRIT_TOKEN`. A token reads [everything in its environment](/docs/guides/frameworks#which-secrets-does-the-agent-get), so there is no per-key or per-framework setup to do before any of the below. ## 1. Wrap the process ```bash seekrit run -- python agent.py seekrit run -- langgraph dev ``` Every granted secret arrives as an environment variable in the child process: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `TAVILY_API_KEY`, your database URL, all of it. Delete the `.env` file afterwards — `seekrit run` overlays one if it's there, and a file that still exists is a file an agent can still read. ## 2. Resolve in code ```python import seekrit from langchain.agents import create_agent seekrit.Client().into_env() # existing os.environ wins by default agent = create_agent( model="gpt-5.6-terra", tools=[get_weather], system_prompt="You are a helpful assistant", ) result = agent.invoke({"messages": [{"role": "user", "content": "Weather in SF?"}]}) ``` `into_env()` before you construct the model, since `ChatOpenAI` reads the key at construction time. If you'd rather not touch `os.environ`: ```python from langchain_openai import ChatOpenAI secrets = seekrit.Client().resolve() model = ChatOpenAI(model="gpt-5.6-terra", api_key=secrets["OPENAI_API_KEY"]) ``` ## 3. Never hold the key Point the model at the [proxy](/docs/guides/agent-proxy) and pass a placeholder: ```python from langchain_openai import ChatOpenAI model = ChatOpenAI( model="gpt-5.6-terra", base_url="http://127.0.0.1:8080/openai/v1", api_key="{{seekrit:OPENAI_API_KEY}}", ) ``` ```toml # seekrit-proxy.toml listen = "127.0.0.1:8080" [[route]] prefix = "/openai" upstream = "https://api.openai.com" allow = ["OPENAI_API_KEY"] methods = ["POST"] paths = ["/v1/chat/completions", "/v1/embeddings"] ``` Now the graph can be built by anyone, including a code-generating node, and the key still cannot leave toward anywhere but `api.openai.com`. ### Without running the proxy `ChatOpenAI` takes an `http_client`, so the substitution can happen in your process instead of a sidecar: ```python import httpx from langchain_openai import ChatOpenAI from seekrit.transport import AsyncSeekritTransport, SeekritTransport allow = {"api.openai.com": ["OPENAI_API_KEY"]} model = ChatOpenAI( model="gpt-5.6-terra", api_key="{{seekrit:OPENAI_API_KEY}}", http_client=httpx.Client(transport=SeekritTransport(allow=allow)), http_async_client=httpx.AsyncClient(transport=AsyncSeekritTransport(allow=allow)), ) ``` Weaker than the proxy, since it runs in the same process as the graph. [In-process injection](/docs/guides/agent-proxy/in-process) sets out the trade-off — and on LangChain it unlocks the next shape, which nothing else can express. ## 4. Scope a key to one tool LangChain 1.x middleware wraps every model call and every tool call, so "which credentials may *this tool* use" becomes a line of configuration: ```bash pip install 'seekrit[langchain]' ``` ```python from langchain.agents import create_agent from seekrit.langchain import SeekritCredentials from seekrit.transport import SeekritTransport transport = SeekritTransport( allow={ "api.openai.com": ["OPENAI_API_KEY"], "api.stripe.com": ["STRIPE_SECRET_KEY"], }, require_scope=True, ) agent = create_agent( model=model, # built with http_client=httpx.Client(transport=transport) tools=[refund, search], context_schema=Context, middleware=[ SeekritCredentials( scope=lambda ctx: {"tenants": ctx.tenant}, model=["OPENAI_API_KEY"], tools={"refund": ["STRIPE_SECRET_KEY"]}, ), ], ) ``` The `refund` tool may substitute the Stripe key and nothing else. `search` may substitute nothing at all — when `tools` is given it is exhaustive, so an unlisted tool gets an empty allowlist and a prompt-injected `search` call cannot reach a payment credential. `scope` also chooses *which* secrets to resolve, off `runtime.context`. One agent process can serve many tenants without holding two tenants' keys at once, and without a per-tenant model instance: the middleware sets an ambient scope and the transport resolves against it, so the model — which builds its HTTP client once — stays a single object. ## Gotchas - **LangSmith is a second credential.** `LANGSMITH_API_KEY` (or `LANGCHAIN_API_KEY`) belongs in seekrit alongside the provider keys. If you run the forward proxy with `unmatched_host_policy = "deny"`, tracing egress to `api.smith.langchain.com` is a separate host and needs its own rule, or traces silently stop. - **`langgraph dev` reloads, `seekrit run` doesn't re-resolve.** The injected values are fixed for the life of the process. Rotate a secret and you restart the dev server — which is the honest behaviour, not a bug to work around. - **Tool credentials are the interesting ones.** A provider key buys tokens; a Stripe or GitHub key bought by a tool call does something irreversible. Those are the ones to move to shape 3, with `methods` and `paths` set — or to shape 4, scoped to the one tool that should have them. - **An environment variable is in reach of a deserialization bug.** `CVE-2025-68664` (CVSS 9.3, December 2025) defaulted `secrets_from_env=True` in `langchain-core`'s `load`, so a crafted payload could name any environment variable and get its value back. It is patched in 1.2.5 / 0.3.81 — and it is also the argument for shapes 3 and 4, where there is nothing in the environment to name. --- # Mastra Mastra reads provider credentials from the environment, so the zero-code shape covers local development. Its `model` object form — which takes a `url` and an `apiKey` — is a clean hook for the proxy. > **Note:** **Start here if seekrit is new:** [three commands](/docs/guides/frameworks#get-running-in-five-minutes) put the keys in an environment, mint a token bound to it, and export `SEEKRIT_TOKEN`. A token reads [everything in its environment](/docs/guides/frameworks#which-secrets-does-the-agent-get), so there is no per-key or per-framework setup to do before any of the below. ## 1. Wrap the process ```bash seekrit run -- mastra dev seekrit run -- pnpm dev ``` Secrets land in the child process only. If you have a `.env` today, import it once and delete it: ```bash seekrit secrets import .env && rm .env ``` `seekrit run` still overlays a `.env` if one exists, and process environment wins over both — so migrating is safe, but leaving the file behind defeats the point. ## 2. Resolve in code For a deployed Mastra server, resolve before the agent is constructed: ```ts import { Seekrit } from "@seekrit/sdk"; import { Agent } from "@mastra/core/agent"; const secrets = await new Seekrit().resolve(); export const supportAgent = new Agent({ id: "support", name: "Support Agent", instructions: "You are a helpful support agent", model: { id: "openai/gpt-5.6-sol", apiKey: secrets.OPENAI_API_KEY, }, }); ``` The SDK is pure WebCrypto and `fetch`, so this is the same code on Node, Bun, Deno, and Cloudflare Workers. On a Worker there is no ambient environment — pass the token explicitly: `new Seekrit({ token: env.SEEKRIT_TOKEN })`. ## 3. Never hold the key The object form takes a `url`, so point it at the [proxy](/docs/guides/agent-proxy): ```ts export const supportAgent = new Agent({ id: "support", name: "Support Agent", instructions: "You are a helpful support agent", model: { id: "custom/gpt-5.6-sol", url: "http://127.0.0.1:8080/openai/v1", apiKey: "{{seekrit:OPENAI_API_KEY}}", }, }); ``` ```toml # seekrit-proxy.toml listen = "127.0.0.1:8080" [[route]] prefix = "/openai" upstream = "https://api.openai.com" allow = ["OPENAI_API_KEY"] methods = ["POST"] paths = ["/v1/chat/completions"] ``` ### Without running the proxy Mastra accepts an AI SDK provider instance anywhere it accepts a `'provider/model'` string, so the shim drops in as a model: ```ts import { createOpenAI } from "@ai-sdk/openai"; import { seekritFetch } from "@seekrit/sdk/fetch"; const openai = createOpenAI({ apiKey: "{{seekrit:OPENAI_API_KEY}}", fetch: seekritFetch({ allow: { "api.openai.com": ["OPENAI_API_KEY"] } }), }); export const supportAgent = new Agent({ id: "support", name: "Support Agent", instructions: "You are a helpful support agent", model: openai("gpt-5.6-sol"), }); ``` Weaker than the proxy, since it runs in your process: [in-process injection](/docs/guides/agent-proxy/in-process) sets out the trade-off. ## 4. A different key per request Mastra types `model` as `DynamicArgument` — a model *or* a function of `{ requestContext }` — which makes per-tenant credentials a first-class thing rather than a workaround. `@seekrit/sdk/mastra` returns that function form: ```ts import { createOpenAI } from "@ai-sdk/openai"; import { Agent } from "@mastra/core/agent"; import { seekritModel } from "@seekrit/sdk/mastra"; export const supportAgent = new Agent({ id: "support", name: "Support Agent", instructions: "You are a helpful support agent", model: seekritModel( ({ apiKey, fetch }) => createOpenAI({ apiKey, fetch })("gpt-5.6-sol"), { secret: "OPENAI_API_KEY", allow: { "api.openai.com": ["OPENAI_API_KEY"] }, scope: (rc) => ({ with: { tenants: String(rc.get("tenant")) } }), }, ), }); ``` `apiKey` is the placeholder; `fetch` substitutes the value this tenant's token resolves. Your builder runs per request, so the model id, temperature or even the provider can vary by tenant too — but the `fetch` behind it is shared per scope, which is the part that matters: a fresh one per request would resolve on every request and quietly undo the cache. `maxScopes` (default 64) bounds how many tenants' resolved sets are held. Populate the request context from your own edge: ```ts import { Mastra } from "@mastra/core/mastra"; import { seekritRequestContext } from "@seekrit/sdk/mastra"; export const mastra = new Mastra({ agents: { supportAgent }, server: { middleware: [seekritRequestContext({ header: "x-tenant" })] }, }); ``` That middleware trusts the header, which is right behind your own authenticated edge and wrong facing the internet. There, set the key from a verified session in your own middleware instead — a caller who can pick the header can pick the tenant. ## 5. A key one tool may spend and the others may not A provider key buys tokens. A tool's Stripe key does something irreversible. Mastra passes `requestContext` to `createTool` executors, so a tool can be given a `fetch` narrowed to exactly its own secrets: ```ts import { createTool } from "@mastra/core/tools"; import { seekritToolFetch } from "@seekrit/sdk/mastra"; import { z } from "zod"; const refundFetch = seekritToolFetch({ allow: { "api.stripe.com": ["STRIPE_SECRET_KEY"] }, only: ["STRIPE_SECRET_KEY"], scope: (rc) => ({ with: { tenants: String(rc.get("tenant")) } }), label: "tool:refund", }); export const refund = createTool({ id: "refund", description: "Refund a charge", inputSchema: z.object({ chargeId: z.string() }), execute: async ({ chargeId }, context) => { const response = await refundFetch(context)("https://api.stripe.com/v1/refunds", { method: "POST", headers: { authorization: "Bearer {{seekrit:STRIPE_SECRET_KEY}}" }, body: new URLSearchParams({ charge: chargeId }), }); return response.ok ? "refunded" : `refund failed: ${response.status}`; }, }); ``` `only` is a ceiling on the tool, not on its caller: it applies whether or not a request context arrived. Build the fetch at module scope, as above — building it inside `execute` gives every tool call its own cache and its own resolve. ## Gotchas - **Mastra's tools are where the risk is.** An agent with a `createTool` that calls your API is holding that credential in the same process as the model output. Those keys are the argument for shape 5 above, or for shape 3 with `paths` set — not just `allow`. - **`@ai-sdk/openai` calls `/v1/responses`, not `/v1/chat/completions`.** Current versions default to the Responses API, so an allowlist that pins `paths: ["/v1/chat/completions"]` denies every request from this stack. Use `["/v1/**"]`, or pin `/v1/responses` deliberately. The CLI's `openai` preset already uses `/v1/**`; it is hand-written allowlists that get this wrong. - **The object model form cannot carry the substitution.** Mastra's `OpenAICompatibleConfig` takes `id`, `url`, `apiKey` and `headers` but no `fetch`, so shapes 4 and 5 need an AI SDK provider instance. Pointing its `url` at the proxy is the other way to hold a placeholder there, and a stronger one. - **Workflows outlive a single resolve.** A long-running workflow holds whatever it resolved at start. If a step needs a credential that may rotate mid-run, resolve inside the step rather than at module scope. - **Don't put the seekrit token in `.env` either.** On a developer machine it belongs in `~/.config/seekrit/config.json` via `seekrit login`; in a deploy it is the one variable your platform holds, which is what [third-party sync](/docs/guides/third-party-sync) is for. --- # Pydantic AI Pydantic AI is the framework where resolving credentials **in code** pays off, because it already has the seam: `deps_type` and `RunContext` exist so a tool can read per-run state instead of reaching for a global. That is exactly the shape a multi-tenant agent needs. > **Note:** **Start here if seekrit is new:** [three commands](/docs/guides/frameworks#get-running-in-five-minutes) put the keys in an environment, mint a token bound to it, and export `SEEKRIT_TOKEN`. A token reads [everything in its environment](/docs/guides/frameworks#which-secrets-does-the-agent-get), so there is no per-key or per-framework setup to do before any of the below. ## 1. Wrap the process ```bash seekrit run -- python agent.py seekrit run -- uvicorn app:app --reload ``` Pydantic AI reads `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` and friends from the environment, so a single-tenant agent needs nothing more than this. ## 2. Resolve per run, not per process The interesting case: one agent, many tenants, each with their own credentials. Put the resolved secrets in the dependencies object and let tools read them from the context. ```python from dataclasses import dataclass import seekrit from pydantic_ai import Agent, RunContext @dataclass class Deps: stripe_key: str agent = Agent("openai:gpt-5.6-terra", deps_type=Deps) @agent.tool async def refund(ctx: RunContext[Deps], charge_id: str) -> str: # ctx.deps.stripe_key belongs to whoever this run is for ... async def handle(tenant: str, prompt: str) -> str: secrets = seekrit.Client(overrides={"tenants": tenant}).resolve() result = await agent.run(prompt, deps=Deps(stripe_key=secrets["STRIPE_SECRET_KEY"])) return result.output ``` One resolve per run, nothing cached across tenants, and one audit trail per tenant on the seekrit side. See [how to model a tenant](/docs/guides/environments) for whether each tenant should be its own environment, a group slice, or a [lease](/docs/concepts/temporary-access). ## 3. Never hold the key ```python from pydantic_ai import Agent from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.providers.openai import OpenAIProvider model = OpenAIChatModel( "gpt-5.6-terra", provider=OpenAIProvider( base_url="http://127.0.0.1:8080/openai/v1", api_key="{{seekrit:OPENAI_API_KEY}}", ), ) agent = Agent(model) ``` Or with no source change at all, since Pydantic AI reads them from the environment: ```bash export OPENAI_BASE_URL=http://127.0.0.1:8080/openai/v1 export OPENAI_API_KEY='{{seekrit:OPENAI_API_KEY}}' ``` ```toml # seekrit-proxy.toml listen = "127.0.0.1:8080" [[route]] prefix = "/openai" upstream = "https://api.openai.com" allow = ["OPENAI_API_KEY"] methods = ["POST"] paths = ["/v1/chat/completions"] ``` ### Without running the proxy `OpenAIProvider` takes an `http_client`, so the substitution can happen in process instead of in a sidecar: ```python import httpx from seekrit.transport import AsyncSeekritTransport provider = OpenAIProvider( api_key="{{seekrit:OPENAI_API_KEY}}", http_client=httpx.AsyncClient( transport=AsyncSeekritTransport(allow={"api.openai.com": ["OPENAI_API_KEY"]}), ), ) ``` Weaker than the proxy, since it shares your address space — [in-process injection](/docs/guides/agent-proxy/in-process) sets out the trade-off, and pairs well with the `deps` shape above. ## 4. A key one tool may spend and the others may not Pydantic AI's seam is `WrapperToolset`, whose `call_tool` wraps every tool invocation with `ctx` — and so `ctx.deps` — in scope. That makes "which credentials may *this tool* use" a line of configuration: ```bash pip install 'seekrit[pydantic-ai]' ``` ```python from pydantic_ai import Agent from pydantic_ai.toolsets import FunctionToolset from seekrit.pydantic_ai import SeekritToolset toolset = SeekritToolset( FunctionToolset([refund, search]), scope=lambda deps: {"tenants": deps.tenant}, tools={"refund": ["STRIPE_SECRET_KEY"]}, ) agent = Agent("openai:gpt-5.6-terra", deps_type=Deps, toolsets=[toolset]) ``` The `refund` tool may substitute the Stripe key and nothing else. `search` may substitute nothing at all — when `tools` is given it is exhaustive, so a tool not named there gets an empty allowlist and a prompt-injected `search` call cannot reach a payment credential. Inside a tool, make the call through a [seekrit transport](/docs/guides/agent-proxy/in-process) and hold a placeholder: ```python import httpx from seekrit.transport import AsyncSeekritTransport transport = AsyncSeekritTransport( allow={"api.stripe.com": ["STRIPE_SECRET_KEY"]}, require_scope=True, ) async def refund(ctx: RunContext[Deps], charge_id: str) -> str: """Refund a charge.""" async with httpx.AsyncClient(transport=transport) as client: response = await client.post( "https://api.stripe.com/v1/refunds", headers={"authorization": "Bearer {{seekrit:STRIPE_SECRET_KEY}}"}, data={"charge": charge_id}, ) return "refunded" if response.is_success else f"refund failed: {response.status_code}" ``` `require_scope=True` is what makes the narrowing a boundary rather than a hint: with no scope in effect there is nothing to narrow, so the transport refuses instead of falling back to the unnarrowed allowlist. ### The model call is not a tool call A toolset only wraps tools. Pydantic AI builds its provider — and its HTTP client — when the agent is constructed, so there is no per-request model hook to use. Wrap the run instead, and the scope covers the model call and every tool call inside it: ```python from seekrit.pydantic_ai import Scope, use_scope async def handle(tenant: str, prompt: str) -> str: with use_scope(Scope(overrides={"tenants": tenant})): result = await agent.run(prompt, deps=Deps(tenant=tenant)) return result.output ``` The two compose rather than fight: the run establishes the tenant, and the toolset narrows what each tool may use on top of it. Omit `scope=` from the toolset and it keeps whatever the surrounding run established. ## Gotchas - **Don't build a process-wide map of every tenant's secrets.** It is the obvious optimisation and it converts one compromised request into a full breach. Resolve for the tenant you are serving now. - **`deps` is per run, module scope is not.** An `Agent(...)` at import time is shared by every request; only what you pass to `run()` is per-run. Keep credentials on the dependencies object, never on the agent. - **Tool keys deserve shape 3 more than model keys do.** The `refund` tool above is the reason `methods` and `paths` exist on a proxy route: an agent that may legitimately create a refund still should not be able to delete a customer. Shape 4 is the cheaper version of the same idea. - **A `scope` that returns group overrides needs a token, not a client.** One `seekrit.Client` is bound to the overrides it was built with, so a per-tenant setup either lets the transport build a scoped client from `$SEEKRIT_TOKEN` or passes `client=` as a callable of the overrides. Passing a single client and then re-scoping raises rather than quietly resolving the wrong tenant. - **Build the transport once, at module scope.** Inside the tool it would get a fresh cache on every call, so every tool call would resolve again. --- # AI SDK The AI SDK's provider factories read `OPENAI_API_KEY` from the environment by default, and take an explicit `apiKey` and `baseURL` when you want to override them. Both shapes are one line. > **Note:** **Start here if seekrit is new:** [three commands](/docs/guides/frameworks#get-running-in-five-minutes) put the keys in an environment, mint a token bound to it, and export `SEEKRIT_TOKEN`. A token reads [everything in its environment](/docs/guides/frameworks#which-secrets-does-the-agent-get), so there is no per-key or per-framework setup to do before any of the below. ## 1. Wrap the process ```bash seekrit run -- next dev seekrit run -- node server.js ``` Nothing to change in the app. The default `openai(...)` provider picks up the injected `OPENAI_API_KEY`. ## 2. Resolve in code Serverless and edge runtimes are the reason this shape exists: there is no process to wrap and no ambient environment, so resolve inside the handler. ```ts import { createOpenAI } from "@ai-sdk/openai"; import { generateText } from "ai"; import { Seekrit } from "@seekrit/sdk"; export async function POST(req: Request) { const secrets = await new Seekrit().resolve(); const openai = createOpenAI({ apiKey: secrets.OPENAI_API_KEY }); const { text } = await generateText({ model: openai("gpt-5.6-terra"), prompt: await req.text(), }); return Response.json({ text }); } ``` On Cloudflare Workers there is no `process.env`, so pass the token from the Worker's own env — the one variable your platform holds: ```ts const secrets = await new Seekrit({ token: env.SEEKRIT_TOKEN }).resolve(); ``` The SDK is pure WebCrypto and global `fetch`, so it runs unchanged on Node, Bun, Deno, browsers, and Workers. ## 3. Never hold the key ```ts import { createOpenAI } from "@ai-sdk/openai"; const openai = createOpenAI({ baseURL: "http://127.0.0.1:8080/openai/v1", apiKey: "{{seekrit:OPENAI_API_KEY}}", }); ``` ```toml # seekrit-proxy.toml listen = "127.0.0.1:8080" [[route]] prefix = "/openai" upstream = "https://api.openai.com" allow = ["OPENAI_API_KEY"] methods = ["POST"] paths = ["/v1/chat/completions", "/v1/embeddings"] ``` For a provider without a first-party package, `createOpenAICompatible` takes the same `baseURL` and `apiKey`. ### Without running the proxy `createOpenAI` takes a custom `fetch`, which is all the substitution needs: ```ts import { seekritFetch } from "@seekrit/sdk/fetch"; const openai = createOpenAI({ apiKey: "{{seekrit:OPENAI_API_KEY}}", fetch: seekritFetch({ allow: { "api.openai.com": ["OPENAI_API_KEY"] } }), }); ``` Same placeholder, same allowlist, no sidecar — and a weaker boundary, since it runs in your process. [In-process injection](/docs/guides/agent-proxy/in-process) sets out the trade-off. ## Gotchas - **`@ai-sdk/openai` calls `/v1/responses`, not `/v1/chat/completions`.** Current versions default to the Responses API, so a route or allowlist pinned to `/v1/chat/completions` refuses every request. Use `/v1/**`, or pin `/v1/responses` on purpose. - **Resolve once per request, not once per token.** `resolve()` is a network round trip and a decrypt; calling it inside a streaming loop turns every chunk into an API call. Resolve at the top of the handler. - **A browser bundle must never hold a service token.** The SDK runs in a browser because Workers and Deno need the same code path, not because a token belongs in client JavaScript. Keep resolution server-side. - **Tool calls are where a leak becomes expensive.** `generateText` with `tools` runs your functions with whatever credentials they close over. If those are third-party keys, that's the case for shape 3. --- # OpenAI Agents SDK The Agents SDK reads `OPENAI_API_KEY` and `OPENAI_BASE_URL` from the environment, which makes both the zero-code shape and the proxy shape configuration-only. The one thing to know about is the tracing exporter. > **Note:** **Start here if seekrit is new:** [three commands](/docs/guides/frameworks#get-running-in-five-minutes) put the keys in an environment, mint a token bound to it, and export `SEEKRIT_TOKEN`. A token reads [everything in its environment](/docs/guides/frameworks#which-secrets-does-the-agent-get), so there is no per-key or per-framework setup to do before any of the below. ## 1. Wrap the process ```bash seekrit run -- python agent.py ``` ```python from agents import Agent, Runner agent = Agent(name="Assistant", instructions="You are a helpful assistant") result = Runner.run_sync(agent, "Write a haiku about recursion.") print(result.final_output) ``` No seekrit-specific code at all — the key arrives in the environment the SDK already reads. ## 2. Resolve in code ```python import seekrit from agents import set_default_openai_key set_default_openai_key(seekrit.Client().get("OPENAI_API_KEY")) ``` Set it before the first model call. For a fully custom client — a different endpoint, an org header, a shared `httpx` client: ```python import seekrit from openai import AsyncOpenAI from agents import set_default_openai_client secrets = seekrit.Client().resolve() set_default_openai_client(AsyncOpenAI(api_key=secrets["OPENAI_API_KEY"])) ``` Pass either `openai_client` **or** `api_key`/`base_url`, never both — combining them raises `UserError` rather than silently ignoring one. ## 3. Never hold the key Environment only, no source change: ```bash export OPENAI_BASE_URL=http://127.0.0.1:8080/openai/v1 export OPENAI_API_KEY='{{seekrit:OPENAI_API_KEY}}' ``` ```toml # seekrit-proxy.toml listen = "127.0.0.1:8080" [[route]] prefix = "/openai" upstream = "https://api.openai.com" allow = ["OPENAI_API_KEY"] methods = ["POST"] paths = ["/v1/responses", "/v1/chat/completions"] ``` ### Without running the proxy `set_default_openai_client` takes a client, and a client takes a transport: ```python import httpx from openai import AsyncOpenAI from agents import set_default_openai_client from seekrit.transport import AsyncSeekritTransport set_default_openai_client( AsyncOpenAI( api_key="{{seekrit:OPENAI_API_KEY}}", http_client=httpx.AsyncClient( transport=AsyncSeekritTransport(allow={"api.openai.com": ["OPENAI_API_KEY"]}), ), ) ) ``` Weaker than the proxy, since it runs in your process: [in-process injection](/docs/guides/agent-proxy/in-process) sets out the trade-off. ## Gotchas - **Tracing uses the same credential, to a different path.** The SDK exports traces to OpenAI by default with your API key. Behind the proxy that means trace uploads carry the placeholder too — so either allow the trace ingest path in your route, give tracing its own credential with `set_tracing_export_api_key(...)`, or turn it off with `set_tracing_disabled(True)`. If traces vanish the moment you add the proxy, this is why. - **`OPENAI_WEBSOCKET_BASE_URL` is separate.** Realtime/websocket transport does not follow `OPENAI_BASE_URL`. The proxy substitutes on HTTP requests, so websocket traffic needs its own decision — usually: don't put a placeholder in that path. - **Handoffs share the process.** Every agent in a handoff chain sees the same environment. Per-agent credential separation needs the proxy's session tickets, not a second env var. --- # Claude Agent SDK The [Claude Agent SDK](https://code.claude.com/docs/en/agent-sdk/overview) is Claude Code as a library: built-in `Read`, `Edit`, `Bash`, `Grep`. Two facts about it decide how you should hand it credentials. First, it reads `ANTHROPIC_API_KEY` from **the environment of the process that runs your agent, and does not load `.env` files** — so process wrapping isn't a workaround here, it's the documented path. Second, this agent reads files and runs commands. A `.env` sitting in the working directory is not inert configuration; it is inside the agent's reach, along with `git log -p` and `docker compose config`. The safest place for a credential in this framework is an environment variable in the child process and nowhere else. > **Note:** **Start here if seekrit is new:** [three commands](/docs/guides/frameworks#get-running-in-five-minutes) put the keys in an environment, mint a token bound to it, and export `SEEKRIT_TOKEN`. A token reads [everything in its environment](/docs/guides/frameworks#which-secrets-does-the-agent-get), so there is no per-key or per-framework setup to do before any of the below. ## 1. Wrap the process ```bash seekrit run -- python agent.py seekrit run -- npx tsx agent.ts ``` ```python import asyncio from claude_agent_sdk import query, ClaudeAgentOptions, AssistantMessage async def main(): async for message in query( prompt="Review utils.py for bugs that would cause crashes. Fix any issues you find.", options=ClaudeAgentOptions( allowed_tools=["Read", "Edit", "Glob"], permission_mode="acceptEdits", ), ): if isinstance(message, AssistantMessage): print(message.content) asyncio.run(main()) ``` Install is `pip install claude-agent-sdk` or `npm install @anthropic-ai/claude-agent-sdk`. ## 2. Resolve in code ```python import os import seekrit seekrit.Client().into_env() # before the first query() assert "ANTHROPIC_API_KEY" in os.environ ``` Useful when the agent runs inside a server that has no wrapper — a queue worker or a web handler. Resolve once at boot, not per request. ## 3. Never hold the key The Agent SDK is built on the same foundation as Claude Code and loads the same settings, so it takes the same gateway variables and the proxy needs no code change: ```bash export ANTHROPIC_BASE_URL=http://127.0.0.1:8080/anthropic export ANTHROPIC_AUTH_TOKEN='{{seekrit:ANTHROPIC_API_KEY}}' ``` ```toml # seekrit-proxy.toml listen = "127.0.0.1:8080" [[route]] prefix = "/anthropic" upstream = "https://api.anthropic.com" allow = ["ANTHROPIC_API_KEY"] methods = ["POST"] paths = ["/v1/messages"] ``` `ANTHROPIC_AUTH_TOKEN` sends the value as `Authorization: Bearer …`; `ANTHROPIC_API_KEY` sends it as `x-api-key`. The proxy substitutes placeholders in either header, so pick whichever your setup already uses — but use exactly one, since a credential in the variable your gateway doesn't read fails with a `401` that looks like a seekrit problem and isn't. ### Why there is no in-process option here The other pages in this section offer a [`fetch`/`httpx` shim](/docs/guides/agent-proxy/in-process) as a middle rung between an environment variable and the proxy. It does not apply here: the Claude Agent SDK drives a subprocess rather than calling the API from your process, so there is no client of yours to hand a transport to. Environment or proxy are the two options. ## Gotchas - **The agent can reach whatever the process can.** With `Bash` allowed, an agent that wants a credential can go looking for one. That is the argument for keeping plaintext out of the filesystem entirely rather than for tightening `allowed_tools` — though tightening them is also worth doing. - **Multi-tenant needs more than a credential boundary.** Anthropic's own docs warn against relying on default `query()` options for tenant isolation: managed policy settings, `~/.claude.json`, and per-directory auto memory are read regardless of `settingSources`. For a multi-tenant agent, give each tenant its own filesystem, pass `settingSources: []` and `CLAUDE_CODE_DISABLE_AUTO_MEMORY=1`, and resolve that tenant's secrets per run rather than injecting every tenant's into one process. - **A wrapped process is not a sandboxed one.** `seekrit run` keeps values off disk; it does not stop the agent reading the environment it was given. If the agent must not be able to read the key at all, that is shape 3. - **Authenticate with `seekrit login`, not an exported `SEEKRIT_TOKEN`.** `seekrit run` injects the resolved values *on top of* the environment it inherits, so a token exported in your shell is visible to the child too — and a token is a decryption credential, so an agent holding it can resolve everything that token grants, not just what you injected. `seekrit login` stores it in `~/.config/seekrit/config.json` instead, leaving nothing in the environment for the agent to find. --- # CrewAI CrewAI reads `OPENAI_API_KEY` and `OPENAI_BASE_URL` from the environment, and its `LLM` class takes `api_key` and `base_url` directly. All three shapes are short. > **Note:** **Start here if seekrit is new:** [three commands](/docs/guides/frameworks#get-running-in-five-minutes) put the keys in an environment, mint a token bound to it, and export `SEEKRIT_TOKEN`. A token reads [everything in its environment](/docs/guides/frameworks#which-secrets-does-the-agent-get), so there is no per-key or per-framework setup to do before any of the below. ## 1. Wrap the process ```bash seekrit run -- python crew.py seekrit run -- crewai run ``` ```python from crewai import Agent, Task, Crew researcher = Agent(role="Research Specialist", goal="Conduct comprehensive analysis") search = Task(description="Research the topic: {topic}", expected_output="A report", agent=researcher) crew = Crew(agents=[researcher], tasks=[search]) print(crew.kickoff(inputs={"topic": "AI safety"})) ``` CrewAI's own docs tell you never to commit API keys and to use secret management; this is that, without the `.env` step in between. ## 2. Resolve in code ```python import seekrit from crewai import LLM secrets = seekrit.Client().resolve() llm = LLM(model="openai/gpt-5.6-terra", api_key=secrets["OPENAI_API_KEY"]) researcher = Agent(role="Research Specialist", goal="Analyse", llm=llm) ``` ## 3. Never hold the key ```python from crewai import LLM llm = LLM( model="openai/gpt-5.6-terra", base_url="http://127.0.0.1:8080/openai/v1", api_key="{{seekrit:OPENAI_API_KEY}}", ) ``` ```toml # seekrit-proxy.toml listen = "127.0.0.1:8080" [[route]] prefix = "/openai" upstream = "https://api.openai.com" allow = ["OPENAI_API_KEY"] methods = ["POST"] paths = ["/v1/chat/completions"] # The crew's tools, bounded to the operations they actually need. [[route]] prefix = "/github" upstream = "https://api.github.com" allow = ["GITHUB_TOKEN"] methods = ["GET", "POST"] paths = ["/repos/*/*/issues", "/repos/*/*/issues/*"] ``` ### Without running the proxy CrewAI reaches the network through LiteLLM, whose only seam is a module global: ```python import httpx, litellm from seekrit.transport import AsyncSeekritTransport, SeekritTransport allow = {"api.openai.com": ["OPENAI_API_KEY"]} litellm.client_session = httpx.Client(transport=SeekritTransport(allow=allow)) litellm.aclient_session = httpx.AsyncClient(transport=AsyncSeekritTransport(allow=allow)) ``` It works, and being process-wide it cannot scope per request. [In-process injection](/docs/guides/agent-proxy/in-process) sets out the trade-off against the proxy. ## Gotchas - **The tools are the reason to bother.** A crew is several agents sharing one process, so every agent effectively has every tool's credentials. The proxy's `methods` and `paths` are how you stop a research agent from being able to perform a write the delegation graph never intended. - **`kickoff` is one long process.** Values resolved at start hold for the whole run. For a crew that runs for hours, either restart on rotation or use shape 3, where the proxy re-resolves on its own interval. - **Delegation crosses no security boundary.** `allow_delegation` moves work between agents in the same process with the same environment. If two agents must have different reach, that is two processes, or the proxy's session tickets. --- # LlamaIndex LlamaIndex reads `OPENAI_API_KEY` from the environment by default. For a custom endpoint the parameter is `api_base` (not `base_url`), and the class is `OpenAILike`. > **Note:** **Start here if seekrit is new:** [three commands](/docs/guides/frameworks#get-running-in-five-minutes) put the keys in an environment, mint a token bound to it, and export `SEEKRIT_TOKEN`. A token reads [everything in its environment](/docs/guides/frameworks#which-secrets-does-the-agent-get), so there is no per-key or per-framework setup to do before any of the below. ## 1. Wrap the process ```bash seekrit run -- python query.py seekrit run -- uvicorn app:app ``` ```python from llama_index.core import Settings from llama_index.llms.openai import OpenAI Settings.llm = OpenAI(model="gpt-5.6-terra") ``` A RAG app usually needs more than the model key — a vector store URL, a database password, an embedding provider. One `seekrit run` covers all of them; they are the same environment. ## 2. Resolve in code ```python import seekrit from llama_index.core import Settings from llama_index.llms.openai import OpenAI secrets = seekrit.Client().resolve() Settings.llm = OpenAI(model="gpt-5.6-terra", api_key=secrets["OPENAI_API_KEY"]) ``` In a notebook, use the one-call form instead — it takes the token from a password prompt when there isn't one in the environment, so it never gets saved into the `.ipynb`, and it returns names rather than values so a displayed cell writes a summary and nothing more: ```python import seekrit seekrit.load() ``` ## 3. Never hold the key ```python from llama_index.llms.openai_like import OpenAILike Settings.llm = OpenAILike( model="gpt-5.6-terra", api_base="http://127.0.0.1:8080/openai/v1", api_key="{{seekrit:OPENAI_API_KEY}}", is_chat_model=True, ) ``` ```toml # seekrit-proxy.toml listen = "127.0.0.1:8080" [[route]] prefix = "/openai" upstream = "https://api.openai.com" allow = ["OPENAI_API_KEY"] methods = ["POST"] paths = ["/v1/chat/completions", "/v1/embeddings"] ``` `OpenAILike` exists because the first-party `OpenAI` class assumes OpenAI's own capabilities; tell it `is_chat_model=True` and, if you use tool calling, `is_function_calling_model=True`. ### Without running the proxy Both `OpenAI` and `OpenAILike` take an `http_client`: ```python import httpx from llama_index.llms.openai import OpenAI from seekrit.transport import SeekritTransport Settings.llm = OpenAI( model="gpt-5.6-terra", api_key="{{seekrit:OPENAI_API_KEY}}", http_client=httpx.Client( transport=SeekritTransport(allow={"api.openai.com": ["OPENAI_API_KEY"]}), ), ) ``` Weaker than the proxy, since it runs in your process: [in-process injection](/docs/guides/agent-proxy/in-process) sets out the trade-off. Remember `Settings.embed_model` needs the same treatment. ## Gotchas - **Embeddings are a second route.** `Settings.embed_model` builds its own client, so pointing only `Settings.llm` at the proxy leaves embedding calls going direct. Set both, and keep `/v1/embeddings` in the route's `paths`. - **Indexes get built once and read forever.** A vector store credential often outlives the process that used it. That is a rotation question, not an injection one — see [secret rotation](/docs/concepts/rotation). - **A notebook is the easiest place to leak a value.** `print(secrets)` in a cell writes plaintext into a file people commit. `seekrit.load()` exists to make the safe thing the short thing. --- # AI agents seekrit speaks [MCP](https://modelcontextprotocol.io) through **two servers**, split along the zero-knowledge line: - **Hosted metadata server** — `mcp.seekrit.dev`. An agent authenticates *itself* (no human, no browser) and can explore and provision structure (orgs, apps, environments, groups, composition), read audit and billing, and manage keyless resources. No install. - **Local crypto server** — `npx -y @seekrit/mcp` (or `seekrit mcp` if you already have the [CLI](/docs/reference/cli)). Runs on the agent's machine and does everything that touches a secret *value*: set/read secrets, create environments, mint and grant tokens, and run commands with secrets injected. **One credential drives both.** An agent signs up once for a machine credential (a client id + secret) and uses the *same* pair on both servers — the two share the same org, so they compose: provision on the hosted server, encrypt on the local one. ## Fastest path: install the plugin If your agent supports [Agent Plugins](https://agent-plugins.org) — Claude Code, Codex, Cursor, GitHub Copilot, Kiro, VS Code, ChatGPT, Gemini CLI — one command installs both servers *and* the skills that teach an agent to keep values out of files and shell history: ```bash npx plugins add seekritdev/agent-plugin ``` See [Agent plugin](/docs/guides/ai-agents/plugin). The rest of this page is the manual wiring, which is still the right route for a client the installer does not know about, or when you want only one of the two servers. ## Why there are two seekrit is zero-knowledge: secret values, data keys, and private keys never reach the server. Decryption only ever happens where the credential lives. So anything that produces plaintext — a secret value, a data key, a decryption-capable grant — runs **on your machine**, on the local crypto server, next to your keys. The hosted server serves only metadata and can *never* decrypt; it refuses service tokens outright (they carry a private key). That division is the whole point of seekrit, preserved. ## 1. Connect the hosted metadata server Point your MCP client at the hosted server. **No credential is needed to connect** — `initialize` and `tools/list` are open, so an agent can read the server's instructions and its whole tool list before it has an account. ```jsonc { "mcpServers": { "seekrit": { "type": "http", "url": "https://mcp.seekrit.dev/mcp" } } } ``` Anonymously you get the guidance tools (`get_started`, `setup_local_crypto`, `local_tool_for`) and `signup`. Everything else returns an error telling you to call `signup` first. ## 2. Get a machine credential (once) Call the **`signup`** tool — no human, no browser. Name the org for the **real project or company** you're working on: `orgName` and `orgSlug` are both required, and a human later claims the org by that name, so a placeholder like `test` makes it unmanageable. ```jsonc signup { "orgName": "Acme Storefront", "orgSlug": "acme-storefront" } // → { org: { … }, credential: { clientId: "…", clientSecret: "…" }, sessionBound: true } ``` Signup mints a fresh organization (with no human member yet) and a machine client bound to it with admin role, then **binds that credential to the current MCP session** — so the metadata tools work on the very next call, with no config change and no reconnect. Save the `clientSecret`: it is shown only once, and it's what you need to reconnect later. The same thing over plain HTTP, if you'd rather bootstrap outside MCP: ```bash curl -sX POST https://mcp.seekrit.dev/signup \ -H 'content-type: application/json' \ -d '{"orgName": "Acme Storefront", "orgSlug": "acme-storefront"}' # → { "org": { … }, "m2m": { "clientId": "…", "clientSecret": "…" } } ``` > **Note:** `orgSlug` is lowercase letters, numbers, and hyphens (e.g. `acme-storefront`); it's disambiguated automatically if already taken. `clientName` is optional. The organization starts memberless; a human joins later via invite (see [Handing off to a human](#hand-off-to-a-human)). ## 3. Reconnect with the credential A session binding lasts for that session. To come back later — or to connect a second client — authenticate with HTTP Basic auth, your client id and secret: ```jsonc { "mcpServers": { "seekrit": { "type": "http", "url": "https://mcp.seekrit.dev/mcp", "headers": { "Authorization": "Basic " } } } } ``` The hosted server runs the OAuth 2.0 client-credentials exchange for you, so the agent never manages token refresh. It gets the metadata tools: `whoami`, the `list_*` discovery tools, `create_app` / `create_group` / `compose_group`, `invite_member`, `audit`, `billing`, and keyless management (`revoke_token`, `delete_secret`, `revoke_lease`, …) — including `list_secret_versions` and `restore_secret`, so an agent can undo a bad write without ever holding a key (a restore replays stored ciphertext; nothing is decrypted). It can't read or set a secret value — for that it points you at the local server: - `get_started` — the recommended first-project recipe, end to end. - `setup_local_crypto` — how to add the local `@seekrit/mcp` server (with a copy-paste `.mcp.json`) so you can set and use secret values. - `local_tool_for` — given a crypto operation, the exact local tool that does it. ## 4. Add the local crypto server (same credential) Register the local server with your agent and give it the **same** machine credential. On first use it mints a long-lived admin token from those credentials automatically and caches it — minting is keyless (the keypair is generated locally and only its public half is registered), so the machine credential is enough. There's no token to copy between servers. `npx` fetches `@seekrit/mcp` on first run, so no prior install is needed: ```jsonc { "mcpServers": { "seekrit-local": { "command": "npx", "args": ["-y", "@seekrit/mcp"], "env": { "SEEKRIT_CLIENT_ID": "", "SEEKRIT_CLIENT_SECRET": "" } } } } ``` Already have the CLI installed? Use `seekrit mcp` as the command instead of `npx -y @seekrit/mcp` — it's the identical server. Your credentials and keys stay on this machine; they are never sent to the hosted server. ### As a container For an agent sandbox with no Node toolchain, the server also ships as a multi-arch Docker image — [`seekritdev/mcp`](https://hub.docker.com/r/seekritdev/mcp). Register a stdio server that shells out to `docker run -i` — the `-i` is required, since stdin is the MCP transport: ```json { "mcpServers": { "seekrit": { "command": "docker", "args": [ "run", "-i", "--rm", "-e", "SEEKRIT_TOKEN", "-v", "${PWD}:/work", "seekritdev/mcp" ] } } } ``` The bare `-e SEEKRIT_TOKEN` forwards the token from the client's own environment, so it never lands in the config file. Mount a workdir at `/work` if you want `run_command` to operate on your files (it runs *inside* the container). Pin a release (`seekritdev/mcp:0.2.0`) or track `:edge`. The container still decrypts locally — the credential and every plaintext stay inside it, never on a remote. ## Choosing the credential The local server authenticates exactly like the CLI. Pick per what the agent needs to do: | Credential | Good for | Notes | | --- | --- | --- | | **Machine credentials** (`SEEKRIT_CLIENT_ID` + `SEEKRIT_CLIENT_SECRET`) | Fully autonomous agents — one credential for both servers | Auto-mints and caches an admin token on first use. Get them from signup. | | **Admin token** (`--admin`) | A fixed headless credential for provisioning | Org-scoped; create apps/groups/envs, compose, grant, mint tokens. | | **Runtime token** (bound to an env) | Reading/writing/injecting one environment's secrets | Self-decrypts — no passphrase. Cannot provision. | Prefer a fixed admin token instead of machine credentials? Mint one and set `SEEKRIT_TOKEN=skt_…`: ```bash seekrit token create --name agent-session --admin ``` ## Tools The **local crypto server** exposes the full toolset below (the hosted server has the keyless subset listed above). Tool names are the same on both. > **Note:** Tool names may be prefixed by your client (e.g. `seekrit:create_app`). - **Start** — `signup` (hosted) — a workspace and your own machine credential in one call, bound to the session; and `get_started` — the recommended first-project recipe, end to end. - **Discover** — `whoami`, `list_orgs`, `list_apps`, `list_envs`, `list_groups`, `list_group_envs`, `list_env_groups`, `list_members`, `list_secrets`, `list_secret_versions`, `list_tokens`, `audit`. - **Provision** — `create_org`, `create_app`, `create_group`, `create_env`, `create_group_env`, `compose_group`, `uncompose_group`. - **Secrets** — `set_secret`, `get_secret`, `restore_secret`, `delete_secret`. - **Tokens & access** — `create_token`, `revoke_token`, `grant_env`. - **Use & wire up** — `run_command`, `export_env`, `configure_project`. Every tool carries MCP annotations, so a client that honors them can let the read-only ones (`list_*`, `whoami`, `audit`) run unattended while still prompting before anything that removes or overwrites — `delete_secret`, `revoke_token`, `export_env` (it writes a file), and `run_command` (it runs a command). The annotations describe *modification*, not sensitivity: `get_secret reveal:true` changes nothing and is marked read-only even though it returns plaintext, so keep using the guidance above to decide when revealing is warranted. ## Use secrets without exposing them Prefer **`run_command`**: it resolves the environment, injects the secrets into a child process, and returns only the command's exit code and output — the secret values never enter the agent's context. `get_secret` returns metadata by default; it only decrypts the plaintext into the response when you pass `reveal: true`. Reach for that only when the value itself is the thing you need. Pass `version` to look at an earlier one. Wrote a bad value? `list_secret_versions` shows the history and `restore_secret` rolls it back — as a new version, so the rollback is itself undoable. Both work on the hosted server too: replaying stored ciphertext needs no key. A `set_secret` value may contain `${OTHER_SECRET}` [references](/docs/guides/references); they are stored literally and expanded whenever the secret is read (including by `run_command` and `export_env`), so an agent can wire a connection string together without ever reading its parts. > **Warning:** Revealing a secret puts its plaintext in the agent's conversation, where it may be logged or retained. Use `run_command` (or `export_env` to a gitignored file) whenever the agent needs to *use* a secret rather than *read* it. ## A typical session An agent, standing up a new service from scratch: 1. Connect to the hosted server with no credential, then call `signup` → machine credentials + a fresh org, already active for this session. Configure the local server with the same credentials. 2. On the hosted server: `create_app`, then (locally) `create_env` (production) — the data key is generated locally. 3. `set_secret` for `DATABASE_URL`, `API_KEY`, … (encrypted on this machine). 4. `create_token` bound to that env — a runtime token, auto-granted its keys. 5. `configure_project` to write `seekrit.json`, then hand the runtime token to CI or a container to run `seekrit run` / `seekrit-run`. 6. `run_command -- pnpm test` to verify the app boots with its secrets injected. ## Hand off to a human The org an agent creates has no human member. So a person can take over — and so nothing is lost if the agent disappears — do the handoff **before the agent stops running**: - `invite_member role:owner` (hosted) — they sign in and own the org. - `grant_env --user ` (local) — re-wrap each environment's data key to their key so they can actually decrypt. Only a current key-holder can grant. - Optionally configure **recovery** (M-of-N custodians) locally, so access survives even if the agent's admin token is gone. ## Limits worth knowing - **Capability doesn't escalate.** A runtime (member) token is denied every admin route; granting a token or user decryption requires the caller to already hold that environment's key, so access only propagates from an existing holder. - **Signup is rate-limited.** It's the one unauthenticated entry point, so it's throttled per source and globally — a real agent's single signup is never affected. --- # Agent plugin The [AI agents guide](/docs/guides/ai-agents) wires seekrit up one MCP client at a time. This page is the shortcut: seekrit ships as an [Agent Plugin](https://agent-plugins.org), the vendor-neutral package format for bundling **skills** (instructions an agent reads) with **MCP servers** (tools it calls). ```bash npx plugins add seekritdev/agent-plugin ``` The installer detects which agents you have and translates the plugin into each one's native format — Claude Code, Codex, Cursor, GitHub Copilot, Kiro, VS Code, ChatGPT, Gemini CLI, and others. No `.mcp.json` editing, and nothing to repeat per client. ## Why the skills matter more than the servers Connecting the MCP servers gives an agent the *ability* to handle secrets properly. It does not give it the *habit*. Left to its own devices an agent writes the key into `.env` because that is what every tutorial it ever read did — and when you deny it `.env`, it has been observed reading `docker compose config`, `git log -p`, or a CI log to reconstruct the same value. The plugin ships two skills that close that gap: | Skill | What it teaches | | --- | --- | | **`seekrit-secrets`** | A value must not come to rest anywhere it can be read again. Run the process with secrets injected (`run_command`, [`seekrit run`](/docs/guides/cli)) instead of writing a file; the [resource model](/docs/guides/environments); how to bootstrap from nothing; how to model a tenant | | **`seekrit-agent-keys`** | Give untrusted or model-generated code a credential it cannot read, with the [egress proxy](/docs/guides/agent-proxy) and `{{seekrit:NAME}}` placeholders behind a default-deny allowlist | Skills are loaded progressively: the agent sees only each skill's name and description until a task looks relevant, then reads the whole file. So the cost of having them installed is a couple of lines of context, and the benefit lands exactly when a credential is about to be mishandled. ## What gets installed ``` seekrit/ ├── plugin.json ├── mcp.json → both servers, below └── skills/ ├── seekrit-secrets/ → SKILL.md + references/{model,cli}.md └── seekrit-agent-keys/ → SKILL.md + references/proxy-config.md ``` Two MCP servers, split along seekrit's encryption boundary: - **`seekrit`** — local, over stdio (`npx -y @seekrit/mcp`). Everything that touches a secret *value*: `set_secret`, `get_secret`, `run_command`, `create_env`, `create_token`, `grant_env`, KMS, database leases. Decryption happens here because this is where your key is. - **`seekrit-cloud`** — hosted, `mcp.seekrit.dev`. Metadata and management with no install: orgs, apps, environments, groups, composition, members, audit, billing, secret *names*. It holds no key and cannot decrypt anything. One machine credential drives both. The local entry is deliberately unpinned so an install never goes stale against the published package. ## Signing in Connecting needs no account. Ask the agent to call `signup` on the hosted server — it mints an organization and a machine credential in-band, with no browser and no card — then persist it so both servers keep working: ```bash seekrit login --client-id --client-secret ``` That writes `~/.config/seekrit/config.json`, which the local server reads with no environment plumbing. If you already have a service token, `seekrit login --token skt_…` does the same job. Machine credentials [auto-mint an admin token](/docs/guides/service-tokens), which is what lets one credential serve both planes. ## Verifying it took Ask the agent to run `whoami`, then `list_apps`. If the local server reports no credential, the config file is missing or the client did not pass its environment through — `seekrit login` fixes both. `local_tool_for` on the hosted server names the local tool for any task the hosted plane cannot finish. ## Auditing what your agent was told The plugin is mirrored to [seekritdev/agent-plugin](https://github.com/seekritdev/agent-plugin) from seekrit's monorepo, so the exact instructions your agent follows are readable in public — the same reason the [SDKs](/docs/guides/sdks) are mirrored. Issues and PRs are welcome there; changes land in the monorepo and overwrite the mirror on the next sync. --- # Agent egress proxy `seekrit-proxy` keeps decrypted secrets **out of a workload's memory**. Where [`seekrit-run`](/docs/guides/run) injects plaintext into a process's environment — fine when you trust that process with its own keys — the proxy is for workloads you _don't_ fully trust, like an AI agent that runs arbitrary tools. The workload sends requests with **placeholders**; the proxy swaps in the real, decrypted value on the way to the upstream. The plaintext only ever exists inside the proxy and in the request to an allowlisted host. ``` agent ──▶ Authorization: Bearer {{seekrit:EXAMPLE_API_KEY}} │ (localhost / sidecar — never sees the real key) seekrit-proxy ── resolves + decrypts once at startup │ substitutes, checks the allowlist, audits ▼ api.example.com Authorization: Bearer …real key… ``` > **Note:** The proxy holds a service-token grant, so it is "just another principal" in the [key-grant model](/docs/concepts/access-control) — the same mechanism the CLI, the browser, and `seekrit-run` use. Its decryption runs through the exact same zero-knowledge path; the API never sees plaintext. ## Why not just use `seekrit-run`? `seekrit-run` puts secrets in the process environment, so **the process can read them** — and anything the process can read, it can exfiltrate. For an untrusted or agentic workload that's the whole problem. The proxy moves the plaintext to a separate trust boundary: the agent holds only `{{seekrit:NAME}}`, and a real credential appears only in the request to the **allowlisted** upstream. ## How it works 1. **Startup (fail-closed).** The proxy reads `SEEKRIT_TOKEN`, calls `GET /v1/resolve`, and decrypts every granted secret into memory. If the token is missing/bad, the API is unreachable, or a layer won't decrypt, it **refuses to start** — a security control should never silently forward unsubstituted placeholders. 2. **Per request.** It matches the request path to a configured route, then substitutes `{{seekrit:NAME}}` in the request **path, headers, and body**. Only the request is rewritten; the response streams back untouched, so streaming APIs (SSE) work unchanged. 3. **Allowlist (default-deny).** Each route declares which secrets may be injected toward its upstream. A placeholder naming a secret not on that list — or one that didn't resolve — is refused with `403` and **never forwarded**. 4. **Audit.** Each substitution logs the secret _names_, method, path, and upstream host — never the values. > **Warning:** The allowlist is the security boundary. Without it, a proxy that injects any secret into any request is an exfiltration oracle: an agent could point it at an attacker's host and have a real key filled in. Each route only injects the secrets you list for its upstream. ## Try it in one command The proxy is a compiled binary, but you do not need a Rust toolchain — or a config file — to see it work. With Node available: ```bash export SEEKRIT_TOKEN=skt_… npx -y @seekrit/cli proxy run --preset openai ``` That fetches the released binary for your platform (verifying its SHA-256), generates a config for `api.openai.com`, and starts serving. It writes nothing to your project. Point a workload at it: ```bash export OPENAI_BASE_URL=http://127.0.0.1:8080/openai/v1 export OPENAI_API_KEY='{{seekrit:OPENAI_API_KEY}}' ``` `seekrit proxy presets` lists what ships: | Preset | Upstream | Secret | | --- | --- | --- | | `openai` | `api.openai.com` | `OPENAI_API_KEY` | | `anthropic` | `api.anthropic.com` | `ANTHROPIC_API_KEY` | | `openrouter` | `openrouter.ai` | `OPENROUTER_API_KEY` | | `github` | `api.github.com` | `GITHUB_TOKEN` | | `openai-compatible` | your `--base-url` | `OPENAI_API_KEY` | Anything not listed works the same way — `--host api.example.com=EXAMPLE_API_KEY` — and presets are combinable: `--preset openai --preset anthropic --host api.example.com=EXAMPLE_API_KEY`. A preset is not a shortcut for something hard; the TOML it stands for is four lines. It exists because those four lines need three things that are easy to get subtly wrong — the bare hostname a rule matches, the path shape the provider's API uses, and whether that provider's SDK expects its base URL to already include `/v1`. (The OpenAI SDKs do; the Anthropic SDKs do not, and append `/v1/messages` themselves.) Get the last one wrong and the upstream returns a 404 that reads like a proxy bug. ### Keep the config instead `run` is for trying it. When you want a file to review, commit, and tighten, ask for one: ```bash seekrit proxy init --preset openai --preset anthropic ``` It writes a commented `seekrit-proxy.toml` — hostnames, secret *names*, and the `methods`/`paths` bounds each preset ships, plus the environment variables your workload needs, as comments. There are no secret values in it, so it is safe to commit. Add `--print` to review it first, or `--mode forward` for the [forward-proxy](#forward-proxy-transparent-via-https_proxy) shape. Presets ship the narrowest `methods`/`paths` that still let each provider's SDK work, which makes the generated file obvious to tighten further. They are a starting point, not a decision about what your agent should reach — a generator does not know your threat model. ### Other ways to install | Install | When | | --- | --- | | `npx -y @seekrit/proxy` | The binary, npx-able. Interprets no flags of its own — `--help` is the proxy's. Its package version *is* the binary version it fetches, so `@seekrit/proxy@0.7.0` runs proxy `0.7.0`. | | `curl -fsSL https://proxy.seekrit.dev/install.sh \| sh` | A binary on your `PATH`, for a container image, a CI runner, or a systemd unit where Node is not in the picture. | | `docker run seekritdev/proxy` | [A container](#run-in-a-container) — multi-arch, static musl on `scratch`. | | `cargo build --release` | From source, in `apps/proxy`. | > **Note:** Every path above lands on the same released binary and verifies the same published SHA-256. That is integrity, not provenance: it proves the bytes match what the release published, exactly what `install.sh` gives you and no more. ## Configure Create `seekrit-proxy.toml`. Each `[[route]]` maps a path prefix to an upstream and the secrets it may receive: ```toml listen = "127.0.0.1:8080" [[route]] prefix = "/example" upstream = "https://api.example.com" allow = ["EXAMPLE_API_KEY"] ``` The service token comes from the environment, never the (committable) config: ```bash export SEEKRIT_TOKEN=skt_… seekrit-proxy --config seekrit-proxy.toml ``` ### Bound what the agent may *do*, not just which key it may use A rule can also name the methods and paths it permits. Both are optional and both default to "any", so an existing config keeps behaving exactly as it did — but adding them turns the allowlist from anti-theft into anti-misuse: an agent holding a legitimate credential still cannot reach an operation you did not grant it. ```toml [[route]] prefix = "/openai" upstream = "https://api.openai.com" methods = ["POST"] paths = ["/v1/chat/completions", "/v1/embeddings"] allow = ["OPENAI_API_KEY"] # A second rule for the same upstream: reads are fine, but carry no credential. [[route]] prefix = "/openai-ro" upstream = "https://api.openai.com" methods = ["GET"] paths = ["/v1/models", "/v1/models/*"] ``` Path patterns are matched segment-wise against the **upstream-facing** path (the request path with the route prefix stripped): `*` matches within one segment and `**` matches any number of them, so `/v1/**` covers everything under `/v1` including `/v1` itself. Matching is case-sensitive and the query string never participates — a rule permits an operation, and `?` parameters are not one. > **Note:** Empty means opposite things in different fields, and the asymmetry is deliberate: no `methods` and no `paths` mean **any** (the constraint is opt-in), while no `allow` means **no** secret may be injected (that one has always been default-deny). A rule with no `allow` is how you permit an operation without letting a credential travel with it. A request no rule covers is refused with `403`, and the message names the constraint that decided — the method, the path, or the absence of any rule for that host — because a default-deny policy otherwise fails in exactly the confusing direction. ## Point your workload at it For the exact argument to change in a specific framework — LangGraph, Mastra, Pydantic AI, the AI SDK, CrewAI, LlamaIndex, or either Agents SDK — see [AI frameworks](/docs/guides/frameworks), which has a copy-paste route and model configuration per framework. The general shape, in any language: Set the workload's base URL to the matching route prefix and pass the credential as a placeholder. Most SDKs take both from the environment: ```bash # Any OpenAI-compatible SDK, for example — nothing here is provider-specific. export OPENAI_BASE_URL=http://127.0.0.1:8080/example export OPENAI_API_KEY='{{seekrit:EXAMPLE_API_KEY}}' ``` A request to `http://127.0.0.1:8080/example/v1/models` is forwarded to `https://api.example.com/v1/models` with `EXAMPLE_API_KEY` substituted into the `Authorization` header. The agent process only ever held the placeholder. ## Options | Flag | Default | Description | | --- | --- | --- | | `-c, --config ` | `./seekrit-proxy.toml` | Route + allowlist config. | | `--listen ` | from config (`127.0.0.1:8080`) | Override the listen address. | | `-t, --token ` | `SEEKRIT_TOKEN` | Service token. | | `--api-url ` | `SEEKRIT_API_URL` or `https://api.seekrit.dev` | API base URL. | Availability options live in the config file's `[cache]` block — see [Starting during a seekrit outage](#starting-during-a-seekrit-outage). ## Placeholder format Placeholders are `{{seekrit:NAME}}`, where `NAME` matches a resolved secret (`[A-Za-z0-9_]+`). Anything that isn't a well-formed, terminated placeholder is left verbatim, so ordinary request content passes through untouched. A proxy with no matching placeholders is a plain pass-through. Secrets are resolved once at startup, with `${OTHER_SECRET}` [references](/docs/guides/references) already expanded — a placeholder always substitutes the finished value. ## Starting during a seekrit outage The proxy is a security control, so it **fails closed**: if it cannot resolve at startup it refuses to run, rather than forwarding requests with placeholders left intact. To let it start on the last response it saw instead, add a `[cache]` block: ```toml [cache] enabled = true max_age = "24h" # how stale that copy may be (default: 24h) # dir = "/var/cache/seekrit" # reconnect_interval = "5s" # first retry after a degraded start # reconnect_max_interval = "5m" # backoff ceiling ``` Only the **encrypted** response is stored; decrypting it still requires the proxy's service token. A proxy that started this way logs loudly, keeps retrying, and swaps in live secrets the moment the API answers — so the stale window is as short as the network allows. A *refused* resolve (`401`/`403`) never falls back: the entry is deleted and the proxy still fails closed. See the [CLI reference](/docs/reference/cli#last-known-good-cache) for the full trade-off. ## Forward proxy (transparent, via `HTTPS_PROXY`) The reverse-proxy model needs the workload to point each SDK's base URL at the proxy. That's fine for one or two known APIs, but an agent that calls many hosts (an LLM _and_ GitHub _and_ some tool _and_ whatever it decides to `curl`) is better served by the **forward-proxy** model: the workload sets one environment variable and *every* egress flows through the proxy, no per-SDK wiring. ```toml [forward] listen = "127.0.0.1:8081" unmatched_host_policy = "tunnel" # or "deny" ca_cert = "seekrit-proxy-ca.pem" ca_key = "seekrit-proxy-ca-key.pem" [[forward.host]] match = "api.example.com" allow = ["EXAMPLE_API_KEY"] ``` For a **ruled** host the proxy answers the client's `CONNECT`, terminates TLS with a leaf certificate it mints for that host (signed by a local CA it generates once and persists), reads the plaintext request, substitutes, and re-originates a real TLS request to the upstream. Point the workload at it: ```bash export HTTPS_PROXY=http://127.0.0.1:8081 # Trust the proxy's CA (pick what your runtime reads): export NODE_EXTRA_CA_CERTS=$PWD/seekrit-proxy-ca.pem # Node # export SSL_CERT_FILE=$PWD/seekrit-proxy-ca.pem # OpenSSL/curl # export REQUESTS_CA_BUNDLE=$PWD/seekrit-proxy-ca.pem # Python requests export ANTHROPIC_API_KEY='{{seekrit:EXAMPLE_API_KEY}}' ``` > **Note:** The proxy only intercepts hosts that have a rule. A host with **no** rule is blind-tunneled through untouched (`tunnel`, the default) so the agent's other traffic keeps working, or refused with `403` (`deny`) for a strict allowlist of reachable hosts. It never reads or injects into traffic it has no rule for. > **Warning:** TLS interception means the workload must **trust the proxy's CA**. Its private key stays on the proxy host and only ever signs leaves for hosts you list. Install the CA only into the trust store of the workload you're proxying — not system-wide on a shared machine. ## Take the rules from the dashboard instead Everything above lives in a file, which is the right default: authorization with no network dependency, and a file seekrit cannot change. What it costs is that every new upstream is a redeploy — awkward for a fleet, and absurd for a developer whose coding agent just hit a tool it has no credential for. Server policy mode moves the churny half — the rules and the credentials — into [agent access policy](/docs/guides/agent-proxy/policy) in the dashboard, and shrinks the file to a **trust anchor**: ```toml listen = "127.0.0.1:8080" [[route]] # routing stays local: prefix → upstream prefix = "/openai" upstream = "https://api.openai.com" [policy] source = "server" # "file" (the default) keeps today's behaviour agent = "nova" # the agent identity this deployment is refresh_interval = "10s" # how soon a published change lands here signers = ["kNc8…thumbprint"] # ← the trust anchor. Copy it from the dashboard. ``` `seekrit proxy init --agent nova` writes exactly that file: it reads the policy through the same route the proxy polls, derives one `[[route]]` per host in it, and pins the thumbprints it found — each annotated with **where it came from**, because that distinction is the whole question for this field (see the warning below). The bundle the API serves is **signed in the publishing admin's browser** with their own P-256 key, and this proxy refuses any bundle not signed by a key whose thumbprint is in `signers`. So seekrit can withhold your policy — the proxy then fails closed — but it cannot widen it, which is the property that lets policy live in a dashboard at all. The dashboard's trust-anchor panel prints the exact snippet, thumbprints included. > **Warning:** `signers` must come from the local file, not from us. A proxy with `source = "server"` and no pinned signer refuses to start, on purpose: the alternative is a proxy that trusts whatever the API says about where your credentials may go. This is also the one field `seekrit proxy init --agent` cannot fully generate. A thumbprint it read back from the API inherits that API's trust until a human confirms it, so the generated file labels each one — `your own signing key (derived locally)` versus `read from the policy the API served. VERIFY THIS.` Check the second kind against the dashboard's trust-anchor panel before you rely on the file. Two more things change in server mode, both because a rule is useless without the credential it names: - **Rules cannot also be authored locally.** `allow`, `methods`, and `paths` on a `[[route]]` or `[[forward.host]]` are rejected rather than silently ignored, and in forward mode the set of intercepted hosts comes from the published policy — which is the whole point, since adding an upstream must not mean editing TOML. - **Secrets are re-resolved on the same interval.** The proxy historically resolved once and never again, so a credential added in the dashboard would never reach a healthy running proxy. In server mode it re-resolves on the policy interval; under file policy you can opt in with `[secrets] refresh_interval = "30s"`. No new grant is involved — a new secret in an environment this proxy already has a key grant for decrypts with the key it already holds. ### Failure semantics | Situation | Behaviour | | --- | --- | | Policy fetch fails at startup | Refuse to start — or last-known-good, if `[cache]` is on | | Policy fetch fails on refresh | Keep the policy already in force until it expires, then fail closed | | Signature invalid, or signer not pinned | Refuse the bundle. Never fall back to an unsigned or older one | | Bundle expired | Every injection refused, with expiry named as the reason | | Bundle exceeds a local `[[policy.ceiling]]` | Refuse the bundle wholesale — a narrowed policy nobody authored should not run | | Agent identity disabled in the dashboard | The next refresh is refused; the current bundle's expiry bounds the window | ### An optional ceiling, for fleets A fleet deployment can also state the hosts and secret names that are *ever* permissible here, which server policy may only narrow: ```toml [[policy.ceiling]] host = "api.openai.com" allow = ["OPENAI_API_KEY"] ``` It closes the "stolen admin signing key" case, and an existing file config converts into one unchanged. It is **off by default and wrong for interactive development**, where the adversary is the local agent — something with shell access can edit any local file, so a ceiling adds no security there and puts every new upstream back into a TOML edit. ## Several agents behind one proxy One proxy often fronts several agents that should not have the same reach — the case a file cannot express, because a file has no way to tell two callers apart. A `[control]` listener closes that without handing an agent any authority: ```toml [control] listen = "127.0.0.1:9090" ttl = "1h" # default ticket lifetime max_ttl = "12h" [policy] source = "server" agent = "nova" # the default identity, for unticketed requests agents = ["nova", "scribe"] # every identity this proxy may serve signers = ["kNc8…thumbprint"] ``` The **orchestrator** — trusted, and the thing that knows which agent it is starting — mints a ticket and hands it to the agent: ```bash export SEEKRIT_PROXY_CONTROL_TOKEN=$(openssl rand -hex 32) # before starting the proxy curl -s localhost:9090/session \ -H "x-seekrit-control-token: $SEEKRIT_PROXY_CONTROL_TOKEN" \ -H 'content-type: application/json' \ -d '{"agent":"scribe","scopes":["GITHUB_TOKEN"],"ttl":"15m"}' # → {"ticket":"skp_…","agent":"scribe","expires_in_seconds":900,"header":"x-seekrit-ticket"} ``` The agent presents that ticket in `x-seekrit-ticket` on its requests; the proxy strips the header before forwarding upstream. Scopes can only ever **narrow** — the effective set is the ticket's ∩ the published policy's — and an unknown or expired ticket is refused rather than quietly treated as unticketed. A single-agent sidecar never has to adopt any of this: with no `[control]` block there is no listener, and requests are evaluated against `[policy] agent`. > **Warning:** The control token is required, and must not be readable by the agent. Without it any local process — including the agent the proxy exists to constrain — could mint itself a ticket for any identity. ## Two honest limits of the developer-machine story Both are worth stating plainly, because this mode's security claim is weaker than the fleet one and should not be oversold. **The agent must not be able to read `SEEKRIT_TOKEN`.** If a coding agent with shell access can read the proxy's token, it can call `/v1/resolve` itself and skip the proxy entirely — and then policy is decoration. Run the proxy as a separate OS user, or in a container, so the token lives somewhere the agent cannot reach. On a single-user laptop this is the genuinely hard part. **`HTTPS_PROXY` is not an enforcement mechanism.** An agent that can unset an environment variable is not confined by it. Real confinement needs a container network namespace or a firewall rule that makes the proxy the only route out. ## Run in a container The proxy is published to Docker Hub as `seekritdev/proxy` — multi-arch, a single static musl binary on `scratch` (no OS, no shell), so the runtime image is nothing but the proxy holding your secrets in memory. `latest` and `` tags are cut on release; `edge` tracks `main`. Mount a config, pass the token in the environment, and publish the port: ```bash docker run --rm -e SEEKRIT_TOKEN=skt_… \ -v "$PWD/seekrit-proxy.toml:/seekrit-proxy.toml" \ -p 8080:8080 seekritdev/proxy --listen 0.0.0.0:8080 ``` The image reads its config from `/seekrit-proxy.toml` (override with `--config`) and the token from `SEEKRIT_TOKEN`. Which address to bind depends on how the container is reached: - **Sidecar** sharing the workload's network namespace — keep the default loopback bind (`127.0.0.1`), so nothing outside the pod can reach the proxy. - **Standalone** container — pass `--listen 0.0.0.0:` (as above) so the workload's container can connect, and publish only to that container's network. > **Note:** In forward mode, persist the CA across restarts by mounting a volume for the `ca_cert` / `ca_key` paths. Otherwise the proxy generates a fresh CA on each start and the certificate the workload trusts stops matching. Point the config's `ca_cert` / `ca_key` at a mounted directory, then mount both the config and that directory: ```toml # seekrit-proxy.toml [forward] listen = "0.0.0.0:8081" ca_cert = "/ca/seekrit-proxy-ca.pem" ca_key = "/ca/seekrit-proxy-ca-key.pem" [[forward.host]] match = "api.example.com" allow = ["EXAMPLE_API_KEY"] ``` ```bash mkdir -p ca # first run writes the CA here; later runs reuse it docker run --rm -e SEEKRIT_TOKEN=skt_… \ -v "$PWD/seekrit-proxy.toml:/seekrit-proxy.toml" \ -v "$PWD/ca:/ca" \ -p 8081:8081 seekritdev/proxy ``` The generated `ca/seekrit-proxy-ca.pem` is the cert the workload trusts (via `NODE_EXTRA_CA_CERTS` and friends, above); because it lives on the mounted volume it survives container restarts. ### A `docker compose` sidecar `seekrit proxy compose` prints one for a config you describe with the same flags `init` takes: ```bash seekrit proxy compose --preset openai > compose.proxy.yaml ``` The container case differs from the local one in exactly the ways that break a copied-from-the-docs compose file, and the generated snippet handles all three: the proxy must bind `0.0.0.0` to be reachable from a sibling container, the workload dials it by **service name** rather than loopback, and in forward mode the CA must live on a shared volume or the workload ends up trusting a certificate the proxy no longer has. It publishes no ports by default — reachable on the compose network only, so nothing outside the project can ask the proxy to inject a key. > **Warning:** A sidecar is what makes the trust boundary real, and it is where the [developer-machine caveat](#two-honest-limits-of-the-developer-machine-story) above stops applying: the service token lives in the proxy's environment, which the workload's container cannot read. For full confinement, put the workload on an `internal: true` network with the proxy as its only peer — otherwise it can simply ignore the base URL you gave it. Pin a release tag (e.g. `seekritdev/proxy:0.2.0`) for reproducible deployments, or `:edge` for the latest `main`. To build the image yourself, see `apps/proxy/Dockerfile` — the shared crypto crate is supplied as a named build context, so build from the repo root. Both modes can run at once, on different ports, from a single config. See [Service tokens](/docs/guides/service-tokens) for how to mint the token the proxy needs. --- # Agent access policy [`seekrit-proxy`](/docs/guides/agent-proxy) answers "may this secret go to this host?" from a file on its own disk. That file is a good default and stays supported — but the rules are the part that churns, and editing a file on every machine that runs an agent is the wrong shape for it. This page is the other option: **write the rules here, sign them in your browser, and let each proxy pick them up.** ``` admin ──▶ dashboard: edit rules ──▶ sign in your browser (your own key) │ opaque signed bundle ▼ seekrit API (stores, serves, cannot forge) │ agent ──{{seekrit:NAME}}──▶ seekrit-proxy ◀── GET /v1/agents/:agent/policy │ verify signature ∩ pinned signers ▼ allowlisted upstream ``` ## Why signing, and not just an API call Serving policy from an API naively would break the claim the proxy's allowlist makes. If seekrit could add `attacker.example.com` to your rules, a compromise here would end with your proxy faithfully decrypting a real credential and sending it there. No plaintext would ever pass through our servers — the letter of the [zero-knowledge model](/docs/concepts/security) would survive — but we would have gained authority over *where plaintext goes*, which is the same class of harm. So policy is signed client-side with the key you already have (the one your passphrase unlocks — no second keypair to manage), and every proxy verifies that signature against thumbprints pinned in its **own local file**. The consequences are worth being precise about: - seekrit can **withhold** policy. A proxy that cannot fetch keeps what it has until that bundle expires, then refuses everything. Fail-closed, always. - seekrit **cannot widen** policy, and neither can anyone who compromises the API. A bundle not signed by a pinned key is refused outright; there is no fallback to an unsigned one. - **An agent cannot widen its own policy**, because publishing needs a human's signing key. That is a guarantee, not an implementation detail — see below. ## Create an agent identity Dashboard → **Agents** → *New agent*. An identity is a policy *subject* and nothing else: it holds no key, and no key can ever be wrapped to it, so creating one grants nothing. What it gives you is a name — `nova`, `scribe` — that a proxy config and a policy can both refer to. Bind it to an environment when you can. That lets the editor offer the secret **names** that environment resolves (the dashboard already knows them without decrypting anything), and stops a service token bound elsewhere from fetching this agent's policy. ## Write the rules Rules are checked top to bottom, **first match wins**, so a narrow rule belongs above a broad one. Each rule names: | Field | Meaning | Empty means | | --- | --- | --- | | Host | Bare hostname — no scheme, port, or wildcard | — (required) | | Methods | HTTP methods this rule covers | **any** method | | Paths | Glob patterns (`*` within a segment, `**` across) | **any** path | | Injectable secrets | Names substitutable toward this host | **no** secret | That asymmetry is deliberate: operation constraints are opt-in, while injection has always been default-deny. A rule with no secrets is useful on its own — it is how you let an agent read an API while only one operation may carry the key. Every rule collapses to one line — `methods host paths → secrets`, the same shape the publish diff shows — so a policy of thirty rules stays something you can scroll. Click a rule to open it for editing and click its header again to shrink it; **Expand all** and **Collapse all** do the whole list. A short policy opens expanded; past a few rules it opens collapsed instead. Two things stay visible on a collapsed rule, because that is the state a long policy sits in: a host nobody filled in, and a warning that the rule names a secret no layer provides. ### Which secrets you can pick Every name the bound environment **resolves**, not just the ones stored in it. A proxy resolves its environment the same way any other client does: the [groups](/docs/guides/environments) composed into it, then the environment's own secrets, then a branch overlay if it is bound to a branch — merged into one flat set of names before anything is substituted. So a rule may name a group's secret, and the picker lists it under the group it comes from. The picker is grouped by layer, highest precedence first, and a name defined in two layers is listed once, under the layer that wins — which is the value a proxy would actually substitute. A name in a rule that no layer provides is flagged: the rule stays valid and the request is still permitted, but there is nothing to put in the placeholder. > **Note:** Nothing you type takes effect until it is signed and published. The editor holds a draft; the live policy is whatever version your proxies last fetched. ## Try it before an agent does The **Dry run** panel answers the question that actually matters: *would `POST https://api.openai.com/v1/chat/completions` carrying `OPENAI_API_KEY` be permitted for this agent?* It reports allow or deny, **which rule decided**, and which constraint refused. Use it. Allowlist mistakes are otherwise silent until an agent breaks in production, and the panel is a real prediction rather than an approximation: it runs the same evaluator the proxy runs, and the two implementations are pinned to shared test vectors so they cannot drift apart. ## Publish Publishing shows a diff against the live version, asks for the lifetime, and then asks you to unlock your keyring — because that unlock *is* the authorization step. The bundle is signed in your tab and the API stores an opaque blob. **Expiry is required** (7 days by default, 1 hour to 90 days). It bounds how long a revoked policy can keep working in a proxy partitioned from us, and it forces republication to act as a liveness signal. A proxy warns as expiry approaches, and the dashboard flags it too. > **Warning:** Publishing requires a human's signing key, so an `admin` **service token cannot publish policy**. This closes self-widening: a prompt-injected agent that can create apps and mint tokens still cannot broaden its own reach. The cost is that fully headless policy-as-code needs a held signing key, which is one reason file-only policy stays supported. ## Point a proxy at it The **Trust anchor** panel prints exactly what to paste into `seekrit-proxy.toml`: ```toml [policy] source = "server" agent = "nova" signers = ["kNc8…thumbprint"] ``` Copy it into the file and commit it. Pin a second admin's thumbprint too — with only one pinned signer, a lost passphrase means nobody can publish. Retiring a signer is an edit to that file, not a click here; the list has to live where we cannot reach it, or the argument above collapses. ## Change something while an agent is running This is the flow the short refresh interval exists for: 1. The agent hits a tool it has no credential for and gets a `403` naming the constraint that refused it. 2. You add the secret to the environment and a rule for it here, then publish. 3. Within `refresh_interval` (10s by default) the running proxy has both the new rule **and** the new credential — they arrive together, because server policy mode re-resolves secrets on the same interval. No restart. ## Turn an agent off Disable the identity. The next policy fetch is refused, so a running proxy keeps the bundle it already has until that bundle expires — which is why expiry is the real bound on revocation, and why a shorter lifetime is the knob to reach for when that window matters. Session tickets narrow it further: they are held only in the proxy's memory, so a restart drops them all. ## Versions and rollback Every publish appends a version; nothing is ever rewritten. Rolling back republishes an old bundle **as a new version** — no one re-signs anything, so the signature stays valid and the history stays honest. The consequence: the restored version keeps its *original* expiry, so rolling back to something stale leaves proxies failing closed. Edit and publish instead when that is the case. The audit trail records `agent.policy_published` and `agent.policy_rolled_back` with the signer and version — that row is the durable answer to "who widened this agent's reach, and when". Per-request decisions are not audited by us: they happen in your proxy and go to [your own OTLP collector](/docs/guides/telemetry), like every other substitution. --- # In-process injection The [egress proxy](/docs/guides/agent-proxy) gives you a strong property: your code holds `{{seekrit:OPENAI_API_KEY}}` and never the key. It also asks you to run a process — `seekrit proxy run` these days, so not much of an ask. The SDKs ship the same substitution engine as a `fetch` wrapper and an `httpx` transport, so you can have the placeholder without the process. It is a weaker boundary — see [what this does and does not guarantee](#what-this-does-and-does-not-guarantee) — and it is one import. Either way you need a seekrit token, the same one `seekrit run` uses: it is what resolves the value the placeholder stands for. If you don't have one yet, that is [three commands](/docs/guides/frameworks#get-running-in-five-minutes). ## TypeScript ```bash npm install @seekrit/sdk ``` ```ts import { createOpenAI } from "@ai-sdk/openai"; import { seekritFetch } from "@seekrit/sdk/fetch"; const openai = createOpenAI({ apiKey: "{{seekrit:OPENAI_API_KEY}}", fetch: seekritFetch({ allow: { "api.openai.com": ["OPENAI_API_KEY"] } }), }); ``` That covers more than the AI SDK. [Mastra](/docs/guides/frameworks/mastra) accepts an AI SDK provider instance anywhere it accepts a `'provider/model'` string, and LangChain.js passes `configuration` straight through to the OpenAI client: ```ts import { ChatOpenAI } from "@langchain/openai"; const model = new ChatOpenAI({ apiKey: "{{seekrit:OPENAI_API_KEY}}", configuration: { fetch: seekritFetch({ allow: { "api.openai.com": ["OPENAI_API_KEY"] } }) }, }); ``` ### Mastra Mastra types `model` as a model *or* a function of `{ requestContext }`, which makes a different key per request a first-class thing. `@seekrit/sdk/mastra` returns that function form, and shares one `fetch` per scope so the resolve stays cached: ```ts import { createOpenAI } from "@ai-sdk/openai"; import { seekritModel } from "@seekrit/sdk/mastra"; model: seekritModel( ({ apiKey, fetch }) => createOpenAI({ apiKey, fetch })("gpt-5.6-sol"), { secret: "OPENAI_API_KEY", allow: { "api.openai.com": ["OPENAI_API_KEY"] }, scope: (rc) => ({ with: { tenants: String(rc.get("tenant")) } }), }, ) ``` It also carries `seekritRequestContext` (server middleware that lifts a tenant header into the request context) and `seekritToolFetch` (a `fetch` narrowed to one tool's secrets). See the [Mastra page](/docs/guides/frameworks/mastra) for the whole shape. ## Python ```bash pip install 'seekrit[httpx]' ``` ```python import httpx from openai import OpenAI from seekrit.transport import SeekritTransport transport = SeekritTransport(allow={"api.openai.com": ["OPENAI_API_KEY"]}) client = OpenAI( api_key="{{seekrit:OPENAI_API_KEY}}", http_client=httpx.Client(transport=transport), ) ``` One transport covers every Python toolkit, because they all reach the network through the same `http_client=`: ```python allow = {"api.openai.com": ["OPENAI_API_KEY"]} transport = SeekritTransport(allow=allow) async_transport = AsyncSeekritTransport(allow=allow) # LangChain / LangGraph ChatOpenAI(api_key="{{seekrit:OPENAI_API_KEY}}", http_client=httpx.Client(transport=transport), http_async_client=httpx.AsyncClient(transport=async_transport)) # Pydantic AI OpenAIProvider(api_key="{{seekrit:OPENAI_API_KEY}}", http_client=httpx.AsyncClient(transport=async_transport)) # OpenAI Agents SDK set_default_openai_client(AsyncOpenAI(api_key="{{seekrit:OPENAI_API_KEY}}", http_client=httpx.AsyncClient(transport=async_transport))) # LlamaIndex OpenAI(api_key="{{seekrit:OPENAI_API_KEY}}", http_client=httpx.Client(transport=transport)) ``` Use `AsyncSeekritTransport` for the async client; the arguments are identical. CrewAI goes through LiteLLM, whose only seam is a module global: ```python import litellm litellm.client_session = httpx.Client(transport=transport) litellm.aclient_session = httpx.AsyncClient(transport=async_transport) ``` That works, but it is process-wide — per-request scoping is not expressible there. ## The allowlist is the point The placeholder is not the security boundary; the allowlist is. Without one, a substitution engine just moves the key from your source to your imports. ```python from seekrit.transport import AllowRule, SeekritTransport transport = SeekritTransport( rules=[ AllowRule( host="api.openai.com", methods=("POST",), paths=("/v1/chat/completions", "/v1/embeddings"), allow=("OPENAI_API_KEY",), ), AllowRule( host="api.stripe.com", methods=("POST",), paths=("/v1/refunds",), allow=("STRIPE_SECRET_KEY",), ), ], ) ``` Default-deny throughout, and the same semantics the proxy enforces: first matching rule wins, an empty `methods` or `paths` means *any*, an empty `allow` means *no secret*. `*` matches within one path segment and `**` across several. A refusal names the constraint that refused — `secret_not_allowed` is a different problem from `path_not_allowed`, and a default-deny rule set fails in exactly the confusing direction. `AllowRule` is the wire shape of a rule inside a signed [`ap1.` policy bundle](/docs/guides/agent-proxy/policy), so rules from a bundle you have verified can be passed in unchanged. Two things always fail closed, and neither sends the request: - a placeholder the allowlist refuses toward this host, method, or path - a placeholder that names a secret your token did not resolve Forwarding the literal placeholder would leak an internal name to the upstream; substituting anyway would hand a credential to a host that is not allowed to have it. The refusal carries the secret's **name** and never its value. ### How a refusal reaches you A refusal answers with the same **403** the proxy answers with, and never sends the request: ``` 403 placeholder {{seekrit:STRIPE_SECRET_KEY}} is not allowed toward this upstream x-seekrit-refusal: denied x-seekrit-secret: STRIPE_SECRET_KEY ``` That is deliberate, and it is the one place where the obvious implementation is wrong. A provider SDK wraps anything its HTTP layer *throws* into its own opaque error **and retries it** — with LangChain's defaults, a denied placeholder became `APIConnectionError: Connection error.` after six retries, with the real reason buried on `err.cause`. As a 403 the same mistake surfaces once, immediately, as `PermissionDeniedError: 403 placeholder {{seekrit:STRIPE_SECRET_KEY}} is not allowed toward this upstream`. It also means swapping this shim for the proxy does not change your error handling: both answer 403. The `x-seekrit-refusal` header tells our refusal apart from a real upstream 403. If you call the shim yourself rather than handing it to an SDK, ask for the typed error instead — `refusal: "throw"` in TypeScript, `refusal="raise"` in Python — and use `onRefuse`/`on_refuse` to observe either way. A failure to **resolve** — the seekrit API being unreachable — always raises, in both modes. That one is genuinely transient and worth a retry; a denial never is. ## Scoping a key to one tool An agent's provider key buys tokens. Its Stripe key does something irreversible. The interesting question is not "does this process hold the key" but "may *this tool call* use it" — and on LangChain that is expressible. ```bash pip install 'seekrit[langchain]' ``` ```python from langchain.agents import create_agent from seekrit.langchain import SeekritCredentials from seekrit.transport import SeekritTransport transport = SeekritTransport( allow={ "api.openai.com": ["OPENAI_API_KEY"], "api.stripe.com": ["STRIPE_SECRET_KEY"], }, require_scope=True, ) agent = create_agent( model=model, # built with http_client=httpx.Client(transport=transport) tools=[refund, search], context_schema=Context, middleware=[ SeekritCredentials( scope=lambda ctx: {"tenants": ctx.tenant}, model=["OPENAI_API_KEY"], tools={"refund": ["STRIPE_SECRET_KEY"]}, ), ], ) ``` The `refund` tool may substitute the Stripe key and nothing else. `search` may substitute nothing at all — when `tools` is given it is exhaustive, so a tool that is not named there gets an empty allowlist. A prompt-injected `search` call cannot reach a payment credential, and the model call itself is narrowed to the provider key. `scope` also picks *which* secrets to resolve: returning `{"tenants": ctx.tenant}` resolves that tenant's slice of a composed group, so one agent process serves many tenants without ever holding two tenants' keys at once. Note what this does **not** do: it never rebuilds the chat model. A model constructs its HTTP client once, so a per-tenant model instance would mean a per-tenant object cache and a per-tenant connection pool. The middleware sets an ambient scope instead, and the transport resolves against it when the request goes out. Pair the middleware with `require_scope=True`, as above. The scope travels in a context variable, which propagates into the same task and into threads started with `asyncio.to_thread`, but not through every possible executor hop. With `require_scope` set, a lost scope refuses the request; without it, a lost scope would fall back to the transport's unnarrowed rules — which is the wrong direction to fail. ### Pydantic AI `WrapperToolset.call_tool` wraps every tool invocation with `ctx.deps` in scope, so the same per-tool narrowing works there: ```bash pip install 'seekrit[pydantic-ai]' ``` ```python from pydantic_ai.toolsets import FunctionToolset from seekrit.pydantic_ai import SeekritToolset toolset = SeekritToolset( FunctionToolset([refund, search]), scope=lambda deps: {"tenants": deps.tenant}, tools={"refund": ["STRIPE_SECRET_KEY"]}, ) ``` A toolset only wraps *tools*, and Pydantic AI builds its provider when the agent is constructed — so wrap the run in `use_scope` to cover the model call too. The two compose: the run establishes the tenant, the toolset narrows per tool. See the [Pydantic AI page](/docs/guides/frameworks/pydantic-ai). ## What this does and does not guarantee This runs in your process. Code in that process can read the resolved value or replace the transport, so it is **not** the trust boundary the proxy is. Think of it as a ladder: | Rung | Boundary | Cost | | --- | --- | --- | | Environment variables | Anything in the process can read them, and so can anything that can read the process's environment | Nothing | | In-process injection | The value exists only inside one HTTP call | One import | | [The proxy](/docs/guides/agent-proxy) | A separate process; your code never has the value at all | `seekrit proxy run`, or `npx @seekrit/proxy` | | [Temporary access](/docs/concepts/temporary-access) | The credential did not exist before the request and stops working after | A lease | What the middle rung genuinely buys you: - **Nothing in the environment.** No `.env`, no `os.environ`, no `process.env` — so an environment-scraping bug in a dependency finds nothing. That is not hypothetical: `CVE-2025-68664` in `langchain-core` (CVSS 9.3) defaulted `secrets_from_env=True` on deserialization, which let a crafted payload read any named environment variable back out. - **Nothing in model context.** The value never becomes a tool argument, a tool result, a prompt, or a log line. In an agent that is the leak path that actually fires — every message passes through a trace exporter. - **A real allowlist.** Per host, per method, per path, default-deny. What it does not buy you: protection from the code holding the placeholder. When that code is a [sandbox](/docs/guides/sandboxes), model-generated, or an agent whose next action you cannot predict, use the proxy — and note that the rung above is now one command (`seekrit proxy run`, or `npx @seekrit/proxy` with no Node install of ours), not a build step. The gap between these two rungs is smaller than it was, so prefer the stronger one when you can. Only requests that *carry a placeholder* are gated. A request without one passes straight through untouched — this is a credential shim, not an egress firewall, and silently blocking unrelated traffic would be a worse lie than not blocking it. For default-deny on *all* egress, that is the forward proxy. ## Options Both shims take the same set. | Option | Default | What it does | | --- | --- | --- | | `allow` | — | `{host: [names]}` shorthand: those names toward that host, any method or path | | `rules` | — | Full rules, host by host. Combines with `allow` | | `token` / `apiUrl` | `$SEEKRIT_TOKEN` | The service token to resolve with | | `client` | — | A pre-built client, used when no scope overrides are in play | | `scope` | ambient | Called per request to narrow the resolve and the allowlist | | `ttlSeconds` / `ttl_seconds` | `60` | How long a resolved set is reused per scope. `0` resolves every time | | `body` | `true` | Also scan the request body | | `require_scope` | `false` | Refuse a placeholder-carrying request when no scope is in effect (Python) | | `refusal` | `"respond"` | `"respond"` answers 403; `"throw"` / `"raise"` raises the typed error | | `onInject` / `on_inject` | — | Called after a substitution with host, method, path and names. Never values | | `onRefuse` / `on_refuse` | — | Called on every refusal, whichever way it surfaces | ## Gotchas - **A streamed request body is never scanned.** Buffering it here would break streaming uploads, so put placeholders in headers — which is where credentials go anyway. Header and URL substitution always happen. - **A substituted value is never rescanned.** If a secret's value happens to contain `{{seekrit:OTHER}}`, that text is emitted literally. A stored value cannot be used to reach a second secret. - **`ttlSeconds` is a real trade-off.** The default reuses a resolved set for a minute per scope, so a rotation takes up to a minute to be picked up. Set `0` for correctness at the cost of one round trip per model call. - **In TypeScript, pass the URL as a string.** A placeholder in the URL of a `Request` *object* is refused rather than sent, because the URL of a constructed `Request` cannot be rewritten. Headers on a `Request` are substituted normally. - **`@ai-sdk/openai` calls `/v1/responses`, not `/v1/chat/completions`.** Current versions default to the Responses API, so a rule pinned to `paths: ["/v1/chat/completions"]` denies every request from that stack — fail-closed, but confusing. `["/v1/**"]` covers both. LangChain's `ChatOpenAI` and LiteLLM still use chat completions, so the two families need different pins if you constrain paths at all. - **A refusal looks like an upstream 403 unless you check the header.** That is the trade for not being retried six times. `x-seekrit-refusal` is there to disambiguate, and `on_refuse` fires locally. - **The seekrit token is still a credential.** It is the one thing the shim itself must hold, and it decrypts everything the token grants. Keep it out of the environment too: `seekrit login` writes `~/.config/seekrit/config.json` on a developer machine, and in a deploy it is the single variable your platform holds. --- # Agent sandboxes A sandbox is a container or microVM you start from your own code to run something you do not fully trust — model output, a coding agent, a user's snippet. That makes it a different secrets problem from a deploy target, and a better one: **your process is on the outside**, holding the token, deciding what goes in. Every provider here supports the same two shapes. They are not variants of each other — they answer different questions. | | Inject at boot | Keep the credential outside | | --- | --- | --- | | **What the sandbox gets** | The real values, as environment variables | Placeholders, and an endpoint | | **Code you trust inside** | Your own, or an agent you supervise | Anything, including hostile output | | **If the sandbox is compromised** | The key is gone — rotate it | Nothing to steal; the key was never there | | **Effort** | Three lines | A proxy or an outbound handler | | **Reach** | Everything the value is good for | Only the hosts and operations you allowed | Reach for **inject at boot** when the sandbox exists for isolation from *your* machine — a build, a test run, a notebook — and the code inside is code you would have run anyway. Reach for **keep the credential outside** when the whole point of the sandbox is that you do not trust what runs in it. An agent that can read `os.environ` can exfiltrate a key it finds there, and "it only pipes it to the model" stops being true the moment the model writes the code. > **Note:** Cloudflare's own Sandbox documentation is blunt about this: *"Do not put live API keys or other long-lived credentials into the sandbox."* That is the second column, and it is the same pattern seekrit ships as [`seekrit-proxy`](/docs/guides/agent-proxy) — a credential the workload names but never holds. ## The shape that is the same everywhere Whichever provider you use, injecting at boot is the same three steps, and **none of them run inside the sandbox**: ```python import seekrit, os # 1. Resolve and decrypt on the host — your process, your token. secrets = seekrit.Client(token=os.environ["SEEKRIT_TOKEN"]).resolve() # 2. Hand the sandbox exactly what it needs, not the whole environment. envs = {k: secrets[k] for k in ("OPENAI_API_KEY", "TAVILY_API_KEY")} # 3. Start it. sandbox = create_sandbox(envs=envs) # provider-specific; see the pages below ``` That ordering is the point. The service token never enters the sandbox, so code inside cannot re-resolve the environment, ask for a different one, or reach seekrit at all — it gets the values you chose and nothing else. Decryption happens in your process, which is where seekrit's [zero-knowledge model](/docs/concepts/encryption) wants it. > **Warning:** **Never pass `SEEKRIT_TOKEN` into a sandbox** running code you do not trust. A service token resolves a whole environment, so handing it over turns "the agent has two API keys" into "the agent has every secret in this environment, and can fetch them again after you rotate the two." If the sandbox genuinely needs to resolve for itself, give it its own token bound to its own environment — or better, a [temporary-access lease](/docs/concepts/temporary-access) that expires. ### Pick names, not the whole environment `resolve()` returns everything the token can see. Injecting all of it is the easy mistake: a sandbox that needed one model key ends up holding the database password too, because both live in `production`. Two ways to narrow it, and the second is better: - **In your code**, as above — a dict comprehension over the names you meant. - **In seekrit**, with an environment scoped to this job. A `sandbox` environment that [composes](/docs/guides/environments) only the group holding model keys can't leak a database password, because it never had one. The narrowing then survives someone editing the injection code. ## Per provider | Provider | Inject at boot | Keep the credential outside | | --- | --- | --- | | [**E2B**](/docs/guides/sandboxes/e2b) | `Sandbox.create({ envs })`, or per-command | `seekrit-proxy` as a sidecar, or on the host | | [**Modal**](/docs/guides/sandboxes/modal) | `Secret.from_dict()` at deploy or sandbox create | `seekrit-proxy` in the image | | [**Daytona**](/docs/guides/sandboxes/daytona) | `envVars` at create, `updateEnv` after | `seekrit-proxy` on the host | | [**Vercel Sandbox**](/docs/guides/sandboxes/vercel-sandbox) | `env` at create, or per `runCommand` | `networkPolicy: 'deny-all'` plus a proxy | | [**Cloudflare Sandbox**](/docs/guides/sandboxes/cloudflare-sandbox) | `setEnvVars()` or `exec({ env })` | **Outbound handlers** — no proxy needed | Cloudflare is the one that does the second column natively: an outbound handler runs in the Worker, outside the sandbox, and attaches the credential on the way past. Everywhere else the same job is `seekrit-proxy`. ## What about a hosted agent runtime? If the platform runs the agent *for* you — you never start a process — there is no host to resolve on and none of this applies. That is a [third-party sync](/docs/guides/third-party-sync) problem instead: seekrit decrypts on its own servers and pushes the values to the platform, which is a real trade-off and the one case seekrit's zero-knowledge rule is explicitly carved out for. [LangGraph Platform](/docs/guides/third-party-sync/langgraph-platform) is the agent-hosting destination. The line is simply whether you control a process at startup. A sandbox you create from your own code: you do — use this page. A managed Agent Server: you don't — sync to it. ## See also - [AI frameworks](/docs/guides/frameworks) — the same shapes in LangGraph's, Mastra's, or Pydantic AI's idiom, for the agent you are running *inside* the sandbox, starting from a three-command setup - [`seekrit run`](/docs/guides/run) — the same injection for a process on your own machine - [Agent proxy](/docs/guides/agent-proxy) — the credential the workload never holds - [Agent access policy](/docs/guides/agent-proxy/policy) — bounding *which* hosts and operations an agent may reach - [Temporary access](/docs/concepts/temporary-access) — a credential that expires on its own - [SDKs](/docs/guides/sdks) — `resolve()` in Python, Go, JavaScript and Ruby --- # Secrets in E2B sandboxes E2B takes environment variables at creation and per command, so both shapes on the [sandboxes overview](/docs/guides/sandboxes) work without any E2B-side configuration. ## Inject at creation ```python import os import seekrit from e2b import Sandbox secrets = seekrit.Client(token=os.environ["SEEKRIT_TOKEN"]).resolve() sandbox = Sandbox.create( envs={ "OPENAI_API_KEY": secrets["OPENAI_API_KEY"], "TAVILY_API_KEY": secrets["TAVILY_API_KEY"], }, ) ``` ```ts import { Sandbox } from 'e2b'; import { Seekrit } from '@seekrit/sdk'; const secrets = await new Seekrit({ token: process.env.SEEKRIT_TOKEN }).resolve(); const sandbox = await Sandbox.create({ envs: { OPENAI_API_KEY: secrets.OPENAI_API_KEY, TAVILY_API_KEY: secrets.TAVILY_API_KEY, }, }); ``` Named keys, not `envs=secrets`. The token can see the whole environment and the sandbox has no business holding all of it — see [Pick names, not the whole environment](/docs/guides/sandboxes#pick-names-not-the-whole-environment). ## Inject per command Creation-time variables apply to everything the sandbox runs. If only one step needs a credential, scope it to that step: ```python sandbox = Sandbox.create() sandbox.commands.run( "python fetch_and_summarize.py", envs={"OPENAI_API_KEY": secrets["OPENAI_API_KEY"]}, ) # This one gets no key at all. sandbox.commands.run("python render_report.py") ``` > **Warning:** Per-command variables are narrower in *time*, not in secrecy. E2B says so plainly: they are "scoped to the command but are not private in the OS." Any other process in the sandbox can read `/proc//environ` while the command runs. Use this to keep a key out of unrelated steps, not to hide it from code running alongside. ## Keep the credential outside the sandbox When the code inside is model output or a coding agent, do not inject at all. Run [`seekrit-proxy`](/docs/guides/agent-proxy) on the host and give the sandbox a placeholder plus a base URL: ```toml # seekrit-proxy.toml — on the host, not in the sandbox listen = "0.0.0.0:8080" [[route]] prefix = "/openai" upstream = "https://api.openai.com" allow = ["OPENAI_API_KEY"] methods = ["POST"] paths = ["/v1/chat/completions", "/v1/embeddings"] ``` ```python sandbox = Sandbox.create( envs={ # A placeholder, not a key. Worthless if it leaks. "OPENAI_API_KEY": "{{seekrit:OPENAI_API_KEY}}", "OPENAI_BASE_URL": "http://:8080/openai", }, ) ``` The agent's SDK sends `Authorization: Bearer {{seekrit:OPENAI_API_KEY}}`; the proxy swaps in the real value and forwards it. What the sandbox holds is a string that means nothing anywhere else, and the `paths` list means the key cannot be spent on anything but chat completions and embeddings even from inside. > **Note:** Bind the proxy where the sandbox can reach it, and nowhere else. `0.0.0.0` above is for the sandbox-to-host hop; put it behind a firewall or on a private network, because anything that can reach the proxy can spend the credential on the allowed operations. The default `127.0.0.1` is right when the proxy is a sidecar in the same network namespace. ## See also - [Agent sandboxes](/docs/guides/sandboxes) — the two shapes and when each is right - [Agent proxy](/docs/guides/agent-proxy) — the full proxy configuration - [SDKs](/docs/guides/sdks) — `resolve()` in every language --- # Secrets in Modal Modal's unit of environment is a `modal.Secret`, and the interesting one here is `Secret.from_dict()` — it is **built where your code runs**, not stored in Modal's dashboard. That makes Modal a particularly clean fit: seekrit stays the single source of truth, and Modal holds the values only for as long as the function or sandbox that received them. ## Inject into a Function at deploy time ```python import os import modal import seekrit secrets = seekrit.Client(token=os.environ["SEEKRIT_TOKEN"]).resolve() app = modal.App("storefront-agent") @app.function( secrets=[ modal.Secret.from_dict( { "OPENAI_API_KEY": secrets["OPENAI_API_KEY"], "TAVILY_API_KEY": secrets["TAVILY_API_KEY"], } ) ], ) def run_agent(prompt: str) -> str: import os # The keys are ordinary environment variables in here. ... ``` `Secret.from_dict()` runs on your machine (or in CI) when you `modal deploy`, so the values travel with that deployment. Named keys, not the whole `secrets` dict — see [Pick names, not the whole environment](/docs/guides/sandboxes#pick-names-not-the-whole-environment). > **Note:** **This binds values at deploy time.** A rotated secret does not reach a deployed Modal function until you deploy again. That is the honest trade for keeping Modal out of the loop; if you need rotation to land without a deploy, read the [proxy section](#keep-the-credential-outside-the-sandbox) below, where the function holds no key at all. ## Inject into a Sandbox Sandboxes take the same `secrets=` list, and this is the more interesting case because a sandbox is usually running something you trust less than your own function body: ```python sb_app = modal.App.lookup("agent-sandboxes", create_if_missing=True) sandbox = modal.Sandbox.create( app=sb_app, secrets=[modal.Secret.from_dict({"OPENAI_API_KEY": secrets["OPENAI_API_KEY"]})], ) process = sandbox.exec("python", "-c", "import os; print(bool(os.environ['OPENAI_API_KEY']))") print(process.stdout.read()) ``` ## Why there is no Modal sync connector Every other agent-hosting page under [third-party sync](/docs/guides/third-party-sync) exists because the platform has a secrets API seekrit's servers can call. **Modal does not.** Its control plane at `api.modal.com` speaks gRPC and nothing else — secrets are managed through the dashboard, the `modal secret` CLI, or the Python/JS/Go SDKs, all of which talk that same gRPC surface. There is no REST endpoint to push to. That turns out to be the right answer anyway. Sync means seekrit decrypting your environment on its own servers, which seekrit's [zero-knowledge rule](/docs/concepts/encryption) permits only where no client exists at the moment values are needed. With Modal a client always exists: your `modal deploy`, or your code calling `Sandbox.create`. `Secret.from_dict()` keeps decryption on your side, which is strictly better than a connector would have been. ## Keep the credential outside the sandbox For untrusted code, do not inject at all. Run [`seekrit-proxy`](/docs/guides/agent-proxy) **where the sandbox cannot reach into it** — the machine that created the sandbox — and give the sandbox a placeholder and a URL: ```bash # On the host, beside the code that calls Sandbox.create. docker run --rm -e SEEKRIT_TOKEN=skt_… \ -v "$PWD/seekrit-proxy.toml:/seekrit-proxy.toml" \ -p 8080:8080 seekritdev/proxy --listen 0.0.0.0:8080 ``` ```toml # seekrit-proxy.toml [[route]] prefix = "/openai" upstream = "https://api.openai.com" allow = ["OPENAI_API_KEY"] methods = ["POST"] paths = ["/v1/chat/completions", "/v1/embeddings"] ``` ```python sandbox = modal.Sandbox.create( app=sb_app, secrets=[ modal.Secret.from_dict( { # A placeholder, not a key. Worthless if it leaks. "OPENAI_API_KEY": "{{seekrit:OPENAI_API_KEY}}", "OPENAI_BASE_URL": "https://proxy.internal.example/openai", } ) ], ) ``` The agent's SDK sends `Authorization: Bearer {{seekrit:OPENAI_API_KEY}}`; the proxy swaps in the real value and forwards it. The sandbox holds no seekrit token, so there is nothing in it to steal — and the `paths` list means the key cannot be spent on anything but chat completions and embeddings even by code that finds the endpoint. > **Warning:** **Do not run the proxy inside the same sandbox as untrusted code.** The proxy needs `SEEKRIT_TOKEN`, and a Modal sandbox is one process space — an agent running as the same user can read it out of the proxy's environment, which hands over the whole environment rather than the two keys you meant to share. The boundary only holds when the token is somewhere the agent cannot read. ## See also - [Agent sandboxes](/docs/guides/sandboxes) — the two shapes and when each is right - [Agent proxy](/docs/guides/agent-proxy) — the full proxy configuration - [Notebooks](/docs/guides/notebooks) — `seekrit.load()`, if you are driving Modal from a notebook --- # Secrets in Daytona sandboxes Daytona takes environment variables at creation and — unusually — lets you change them on a running sandbox, which makes it the one provider here where a rotated secret can reach a live sandbox without recreating it. ## Inject at creation ```ts import { Daytona, type CreateSandboxFromSnapshotParams } from '@daytonaio/sdk'; import { Seekrit } from '@seekrit/sdk'; const secrets = await new Seekrit({ token: process.env.SEEKRIT_TOKEN }).resolve(); const daytona = new Daytona(); const sandbox = await daytona.create({ envVars: { OPENAI_API_KEY: secrets.OPENAI_API_KEY, TAVILY_API_KEY: secrets.TAVILY_API_KEY, }, } satisfies CreateSandboxFromSnapshotParams); ``` ```python import os import seekrit from daytona import Daytona, CreateSandboxFromSnapshotParams secrets = seekrit.Client(token=os.environ["SEEKRIT_TOKEN"]).resolve() daytona = Daytona() sandbox = daytona.create( CreateSandboxFromSnapshotParams( env_vars={ "OPENAI_API_KEY": secrets["OPENAI_API_KEY"], "TAVILY_API_KEY": secrets["TAVILY_API_KEY"], }, ) ) ``` Note the casing difference between the SDKs: `envVars` in TypeScript, `env_vars` in Python. Named keys, not the whole dict — see [Pick names, not the whole environment](/docs/guides/sandboxes#pick-names-not-the-whole-environment). ## Update a running sandbox `updateEnv` replaces what later processes see, which is how a rotation lands without recreating the sandbox: ```ts // After rotating OPENAI_API_KEY in seekrit. const fresh = await new Seekrit({ token: process.env.SEEKRIT_TOKEN }).resolve(); await sandbox.updateEnv( { OPENAI_API_KEY: fresh.OPENAI_API_KEY }, { unset: ['TAVILY_API_KEY'] }, ); ``` > **Warning:** **Only newly started processes see the change.** Daytona is explicit: "Newly spawned processes, sessions and PTYs inherit the change; already-running processes keep their environment." A long-lived agent process started before the update keeps the old key until it restarts — so an `updateEnv` after a rotation is not, on its own, a revocation. Revoke at the provider too. ## Keep the credential outside the sandbox Daytona sandboxes are commonly handed to coding agents, which is exactly the case for not injecting anything. Run [`seekrit-proxy`](/docs/guides/agent-proxy) on the host and give the sandbox a placeholder and a URL: ```toml # seekrit-proxy.toml — on the host listen = "0.0.0.0:8080" [[route]] prefix = "/anthropic" upstream = "https://api.anthropic.com" allow = ["ANTHROPIC_API_KEY"] methods = ["POST"] paths = ["/v1/messages"] ``` ```ts const sandbox = await daytona.create({ envVars: { // A placeholder, not a key. ANTHROPIC_API_KEY: '{{seekrit:ANTHROPIC_API_KEY}}', ANTHROPIC_BASE_URL: 'http://:8080/anthropic', }, }); ``` The value in the sandbox means nothing anywhere except through the proxy, and the `paths` list bounds what the real key can be spent on. This also removes the rotation problem above entirely: the proxy re-resolves on its own (`[secrets] refresh_interval`), so a rotated key reaches even a long-running agent process without touching the sandbox. ## See also - [Agent sandboxes](/docs/guides/sandboxes) — the two shapes and when each is right - [Agent proxy](/docs/guides/agent-proxy) — the full proxy configuration - [Rotation](/docs/guides/rotation) — what a rotation does and does not revoke --- # Secrets in Vercel Sandbox Vercel Sandbox runs Firecracker microVMs you create from your own code, so seekrit resolves in that code and hands the microVM only what it needs. It also has the one feature that makes the no-credential shape genuinely enforceable: `networkPolicy: 'deny-all'`. ## Inject at creation ```ts import { Sandbox } from '@vercel/sandbox'; import { Seekrit } from '@seekrit/sdk'; const secrets = await new Seekrit({ token: process.env.SEEKRIT_TOKEN }).resolve(); const sandbox = await Sandbox.create({ env: { OPENAI_API_KEY: secrets.OPENAI_API_KEY, TAVILY_API_KEY: secrets.TAVILY_API_KEY, }, }); ``` `env` sets the defaults for every command the sandbox runs. Named keys, not `env: secrets` — see [Pick names, not the whole environment](/docs/guides/sandboxes#pick-names-not-the-whole-environment). ## Inject per command Per-command `env` overrides the creation-time defaults, which is how you keep a key out of the steps that do not need it: ```ts await sandbox.runCommand({ cmd: 'node', args: ['summarize.js'], env: { OPENAI_API_KEY: secrets.OPENAI_API_KEY }, }); // No key in this one. await sandbox.runCommand({ cmd: 'node', args: ['render.js'] }); ``` > **Note:** `Sandbox.fork()` copies the source sandbox's environment variables into the new one, so a forked sandbox inherits any key you injected. Pass `env` on the fork to override them, and remember that a persistent sandbox auto-snapshots its configuration on stop, so the values come back on resume. ## Keep the credential outside the microVM This is the shape to use for agent output, and Vercel Sandbox makes it enforceable rather than advisory. Create the sandbox with **no egress at all**, then give it one route out — the proxy: ```ts const sandbox = await Sandbox.create({ networkPolicy: 'deny-all', env: { // A placeholder, not a key. Worthless if it leaks. OPENAI_API_KEY: '{{seekrit:OPENAI_API_KEY}}', OPENAI_BASE_URL: 'https://proxy.internal.example/openai', }, }); ``` ```toml # seekrit-proxy.toml — wherever you run the proxy, never in the sandbox [[route]] prefix = "/openai" upstream = "https://api.openai.com" allow = ["OPENAI_API_KEY"] methods = ["POST"] paths = ["/v1/chat/completions", "/v1/embeddings"] ``` Why this combination is stronger than either half: - **`deny-all` makes the proxy the only way out.** Elsewhere an `OPENAI_BASE_URL` pointing at the proxy is a suggestion the agent can ignore by calling `api.openai.com` directly — with a placeholder that fails, but a *stolen* key would work. With no egress, there is nothing to fall back to. - **The allowlist makes the route narrow.** Even through the proxy, the key reaches one upstream, one method, two paths. An agent that decides to spend your model budget on a different endpoint is refused, and the refusal is logged. - **The microVM holds no seekrit token**, so a compromise yields a placeholder string and a URL. > **Warning:** `deny-all` blocks the sandbox's package installs too. Bake dependencies into a [custom image](https://vercel.com/docs/sandbox/concepts/images) rather than installing at runtime — and if you widen the network policy to reach a registry, the argument above weakens by exactly the reach you added back. ## See also - [Agent sandboxes](/docs/guides/sandboxes) — the two shapes and when each is right - [Agent proxy](/docs/guides/agent-proxy) — the full proxy configuration - [Agent access policy](/docs/guides/agent-proxy/policy) — publishing the allowlist from the dashboard instead of a file - [Sync to Vercel](/docs/guides/third-party-sync/vercel) — for a Vercel *deployment*, which is the other problem --- # Secrets in Cloudflare Sandbox Cloudflare Sandbox is the one provider on this list where the credential-never-enters-the-sandbox shape needs no proxy at all. **Outbound handlers** run in the Workers runtime, outside the container, and can attach a credential to a request on its way past — which is exactly what [`seekrit-proxy`](/docs/guides/agent-proxy) does elsewhere, built into the platform. Cloudflare's own documentation recommends it over injection, in as many words: > Use environment variables for **non-secret** configuration (paths, feature > flags, `NODE_ENV`, and similar). Do not put live API keys or other long-lived > credentials into the sandbox. So this page leads with that shape, and covers injection second. ## Keep the credential in the Worker Resolve in the Worker, hold the values there, and attach them in an outbound handler. The container never sees a key: ```ts import { getSandbox, Sandbox } from '@cloudflare/sandbox'; import { Seekrit } from '@seekrit/sdk'; export class AgentSandbox extends Sandbox {} AgentSandbox.outboundByHost = { 'api.openai.com': async (request: Request, env: Env) => { const secrets = await new Seekrit({ token: env.SEEKRIT_TOKEN }).resolve(); const authed = new Request(request); authed.headers.set('authorization', `Bearer ${secrets.OPENAI_API_KEY}`); return fetch(authed); }, }; export default { async fetch(request: Request, env: Env): Promise { const sandbox = getSandbox(env.AgentSandbox, 'user-123'); // No credential passed in — the agent calls api.openai.com with no key and // the handler supplies one on the way out. const proc = await sandbox.exec('python agent.py'); return Response.json({ output: await proc.output({ encoding: 'utf8' }) }); }, } satisfies ExportedHandler; ``` What this buys, and it is the whole argument for the pattern: - **Nothing to steal.** The container holds no key and no seekrit token, so prompt injection, a leaked log, or arbitrary code execution inside the sandbox yields nothing. - **Rotation lands immediately.** The next request picks up whatever the Worker resolves; there is no sandbox to restart and no value to re-inject. - **The host is the boundary.** A handler is registered per host, so a credential can only ever be attached to a request going to that host. An agent that POSTs your key's worth of traffic at `evil.example` gets no key. > **Tip:** Cache the resolve rather than doing it per request. A Worker isolate can hold the resolved map in module scope, or you can keep it on the Durable Object — `/v1/resolve` is metered, and a busy agent makes a lot of outbound calls. Do **not** cache it into the container. Per-instance credentials work the same way, using `ctx.containerId` to pick which secret an instance gets — useful when each tenant's agent should carry its own key: ```ts AgentSandbox.outboundByHost = { 'api.example.com': async (request, env, ctx) => { const secrets = await new Seekrit({ token: env.SEEKRIT_TOKEN }).resolve(); const authed = new Request(request); authed.headers.set('x-api-key', secrets[`TENANT_${ctx.containerId}_KEY`]); return fetch(authed); }, }; ``` ## Inject into the container When the code inside is yours — a build step, a test run — injection is fine and simpler. Resolve in the Worker and set the variables before anything runs: ```ts const sandbox = getSandbox(env.Sandbox, 'build-42'); const secrets = await new Seekrit({ token: env.SEEKRIT_TOKEN }).resolve(); await sandbox.setEnvVars({ DATABASE_URL: secrets.DATABASE_URL, NODE_ENV: 'production', }); await sandbox.exec('npm run migrate'); ``` Or per command, which keeps a value out of the steps that do not need it: ```ts await sandbox.exec('node seed.js', { env: { DATABASE_URL: secrets.DATABASE_URL }, }); await sandbox.exec('node report.js'); // no credential ``` > **Note:** Call `setEnvVars()` **before** other sandbox operations — the SDK merges stored names in at each `exec()` launch, so a command that started earlier does not get them. The stored names live in the sandbox Durable Object's memory, not on the container filesystem, so after the DO is evicted you must set them again (or pass `env` per launch). Named keys, not the whole resolved map — see [Pick names, not the whole environment](/docs/guides/sandboxes#pick-names-not-the-whole-environment). ## Which one, concretely | The sandbox runs | Use | | --- | --- | | Your build, migration, or test suite | `setEnvVars()` / `exec({ env })` | | A coding agent, or model-generated code | An outbound handler | | Untrusted user submissions | An outbound handler, and `unset` anything you injected earlier | A useful tell: if you would be unhappy to find the value printed in the sandbox's stdout, it belongs in the Worker. ## See also - [Agent sandboxes](/docs/guides/sandboxes) — the two shapes and when each is right - [Agent proxy](/docs/guides/agent-proxy) — the same pattern for every other runtime - [Sync to Cloudflare](/docs/guides/third-party-sync/cloudflare) — for Workers, Pages, and Secrets Store, which are the other problem --- # Language SDKs When a secret needs to live inside your application process — not injected by a launcher — use a language SDK. Each one authenticates with a service token, calls `GET /v1/resolve`, and **decrypts locally**: the API only ever returns ciphertext plus a data key wrapped to your token, exactly like every other seekrit client. | Language | Package | Repo | | --- | --- | --- | | Python 3.9+ | `seekrit` (PyPI) | [seekritdev/python-sdk](https://github.com/seekritdev/python-sdk) | | Go 1.24+ | `github.com/seekritdev/go-sdk` | [seekritdev/go-sdk](https://github.com/seekritdev/go-sdk) | | Node / Bun / Deno / browsers / Workers | `@seekrit/sdk` (npm) | [seekritdev/js-sdk](https://github.com/seekritdev/js-sdk) | | Ruby 3.0+ | `seekrit-sdk` (RubyGems) | [seekritdev/ruby-sdk](https://github.com/seekritdev/ruby-sdk) | > **Note:** The SDKs are **read-only**: resolve and decrypt with a service token. Creating, rotating, and granting access to secrets stays in the [web dashboard](/docs/guides/web-app) and [CLI](/docs/guides/cli), which use a human principal's passphrase-protected key. A service token binds to exactly one app environment (plus its composed group slices) — that scope is the SDK's blast radius. See [Service tokens](/docs/guides/service-tokens). ## Python ```bash pip install seekrit ``` ```python import seekrit client = seekrit.Client() # token from $SEEKRIT_TOKEN secrets = client.resolve() # {"DATABASE_URL": "postgres://…", …} db_url = client.get("DATABASE_URL") seekrit.Client().into_env() # or load everything into os.environ ``` Or the one-call form, which resolves, loads `os.environ`, and returns a value-free summary of what it loaded — see [Jupyter notebooks](/docs/guides/notebooks), which is what it was built for: ```python import seekrit seekrit.load() ``` ## Go ```bash go get github.com/seekritdev/go-sdk@latest ``` ```go import seekrit "github.com/seekritdev/go-sdk" client, err := seekrit.New() // token from $SEEKRIT_TOKEN secrets, err := client.Resolve(context.Background()) fmt.Println(secrets["DATABASE_URL"]) ``` No external dependencies — just the standard library (`crypto/ecdh`, `crypto/hkdf`). ## JavaScript / TypeScript ```bash npm install @seekrit/sdk ``` ```ts import { Seekrit } from "@seekrit/sdk"; const client = new Seekrit(); // token from $SEEKRIT_TOKEN const { DATABASE_URL } = await client.resolve(); ``` Pure WebCrypto + global `fetch`, so it runs unchanged on Node 18+, Bun, Deno, browsers, and Cloudflare Workers. In a Worker there's no ambient env, so pass the token from your binding: ```ts export default { async fetch(request, env) { const secrets = await new Seekrit({ token: env.SEEKRIT_TOKEN }).resolve(); // ... }, }; ``` ## Ruby ```bash gem install seekrit-sdk ``` The gem is `seekrit-sdk`; the module is `Seekrit`. ```ruby require "seekrit/sdk" # or "seekrit" — both work client = Seekrit::Client.new # token from ENV["SEEKRIT_TOKEN"] secrets = client.resolve # { "DATABASE_URL" => "postgres://…", … } ``` Rails apps can load secrets into `ENV` on boot by opting in from an initializer: ```ruby config.seekrit.autoload = true ``` ## Never holding the value at all The Python and JS SDKs can also inject a credential without your code ever seeing it, by holding a `{{seekrit:NAME}}` placeholder that a `fetch` wrapper or an `httpx` transport substitutes on the way out: ```python import httpx from openai import OpenAI from seekrit.transport import SeekritTransport client = OpenAI( api_key="{{seekrit:OPENAI_API_KEY}}", http_client=httpx.Client( transport=SeekritTransport(allow={"api.openai.com": ["OPENAI_API_KEY"]}), ), ) ``` One transport covers every Python agent toolkit and `seekritFetch` covers the TypeScript ones, because they all take a client or a `fetch`. On LangChain it extends to scoping a key to one **tool call**. See [in-process injection](/docs/guides/agent-proxy/in-process) — including what it does and does not guarantee against the [proxy](/docs/guides/agent-proxy). ## Common behavior - **Fail-closed.** A bad token, an unreachable API, or a layer that won't decrypt raises — the SDKs never return partial results. - **Precedence.** `resolve` returns the merged environment: composed groups first, then the app environment on top (later wins on a name collision). - **References.** `${OTHER_SECRET}` [references](/docs/guides/references) are expanded over the merged result. Opt out per client: `interpolate: false` (JS/TS), `interpolate=False` (Python), `WithInterpolate(false)` (Go), `interpolate: false` (Ruby). - **Overrides.** Pull a different environment slice of a composed group with the `with` / `overrides` option — the SDK equivalent of `?with=group:env`. You can only pull a slice your token holds a key for. - **Errors.** A non-2xx response surfaces as a typed API error carrying the HTTP status and the API's error code (`unauthorized`, `forbidden`, `not_found`, …). Every SDK reimplements the same decrypt path (ECDH P-256 → HKDF-SHA256 → AES-256-GCM, AAD-bound to `environmentId/NAME`) and the same reference-expansion rules, and is tested against a shared fixture generated from the reference implementation, so they all recover identical plaintext. See the [encryption model](/docs/concepts/encryption) and the [resolve endpoint](/docs/reference/api). --- # Replace dotenv (Node.js) [`dotenv`](https://www.npmjs.com/package/dotenv) reads a `.env` file from disk and copies its `KEY=VALUE` pairs into `process.env`. It's the default way Node projects load configuration — and it means your real secrets live in a plaintext file that is one `git add` away from being committed, shared over Slack, or baked into an image. seekrit replaces that file. Your secrets are encrypted on your machine and stored as ciphertext; at runtime the CLI decrypts them locally and injects them into `process.env` — exactly where your code already reads them. **Your application code doesn't change**: `process.env.DATABASE_URL` keeps working. You just stop shipping the plaintext file and delete the `dotenv` dependency. ```diff - import "dotenv/config"; // reads .env off disk + // nothing — seekrit injects process.env before your code runs ``` ```bash - node server.js # relied on a committed .env + seekrit run -- node server.js # decrypts & injects, then runs ``` > **Note:** This guide assumes you've done the [Quickstart](/docs/quickstart): installed the CLI, signed in, and created an application and environment. If not, do that first — it takes a few minutes. ## 1. Import your existing `.env` Bulk-load the file you already have. Each `KEY=VALUE` is encrypted **locally** and stored under the target environment; the API only ever receives ciphertext. Preview first with `--dry-run` — it lists which names are new versus updates and writes nothing: ```bash seekrit secrets import .env --app storefront --env production --dry-run seekrit secrets import .env --app storefront --env production ``` The importer understands the same `.env` syntax dotenv does — `export` prefixes, `#` comments, and single/double quotes, including quoted values that span several lines. An invalid variable name aborts the whole import before anything is written, so it's all-or-nothing. Prefer the browser? **Add key** on an application or environment page has a **paste .env** tab that runs the same parser. It previews every key it found — truncated value, and whether it's new or overwrites what's there — and writes to as many environments as you select at once, encrypting separately for each. Names that aren't valid variable names are listed as skipped rather than aborting the paste, since the preview shows you which ones before you commit. ![The Add keys to environments dialog on its paste .env tab: a pasted file above a preview listing each key as new or overwrites, with production, staging and development selected](https://seekrit.dev/screenshots/original/dashboard-paste-dotenv.webp) *Pasting a .env in the dashboard. The preview marks each key new or overwrites before anything is written; the values are encrypted separately for every environment you select.* > **Tip:** Migrating more than one environment? Import each file into its own environment: `seekrit secrets import .env.development --app storefront --env development`, and so on. Config several apps share belongs in a **group** — see [Environments & groups](/docs/guides/environments). ### JSON credentials A Google service-account key, a Firebase config, a Kubernetes pull secret — a JSON blob is one string as far as seekrit is concerned, and it is stored and delivered byte-for-byte. The friction is only ever in the file format around it. **The simplest path skips `.env` entirely** — point at the file you downloaded: ```bash seekrit secrets set GOOGLE_SERVICE_ACCOUNT --file ./service-account.json \ --app storefront --env production ``` In the dashboard, the same thing: **Add key** → **one key**, paste the JSON into the value box. It's marked as JSON once it parses, with a **format** button to re-indent it; if it doesn't parse, you find out there rather than at 3am. If you do keep it in a `.env` file, wrap it in **single quotes** — the value is full of double quotes and `\n` escapes that need to survive as written, and single quotes are literal. The value may span as many lines as it likes: ```bash GOOGLE_SERVICE_ACCOUNT='{ "type": "service_account", "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQ…\n-----END PRIVATE KEY-----\n" }' ``` > **Warning:** Without the quotes, the value stops at the end of its line and the JSON's own lines are read as further assignments — a handful of nonsense names instead of an error. The dashboard's **paste .env** tab flags this before it writes anything; the same applies to `.env` files read by `seekrit run --env-file`. ## 2. Run your app through seekrit `seekrit run` resolves the environment, decrypts it locally, injects it into the child process, and then runs your command. Anything after `--` is your command, verbatim: ```bash seekrit run --app storefront --env production -- node server.js seekrit run --app storefront --env production -- npm run dev ``` Confirm a value made it in without printing secrets to your shell history: ```bash seekrit run --app storefront --env production -- node -e "console.log(!!process.env.DATABASE_URL)" # true ``` Because seekrit populates `process.env` before your code runs, every `process.env.X` read works unchanged — there is nothing to load. ## 3. Delete dotenv from your code Remove the loader line and the dependency. Your `process.env.*` reads stay exactly as they are. ```diff - import "dotenv/config"; - // or: require("dotenv").config(); const dbUrl = process.env.DATABASE_URL; ``` If you preloaded it on the command line (`node -r dotenv/config`) or via a package script, drop that too, and wrap the script with `seekrit run`: ```diff // package.json "scripts": { - "dev": "node -r dotenv/config server.js", + "dev": "node server.js", "start": "node server.js" } ``` ```bash npm uninstall dotenv dotenv-cli ``` Run the wrapped scripts through the CLI. With a service token in the environment (next section) it's just `seekrit run -- npm run dev`; interactively, name the target: ```bash seekrit run --app storefront --env development -- npm run dev ``` > **Tip:** Don't want to type `seekrit run` every time in local dev? Add a thin script — `"dev:secrets": "seekrit run -- npm run dev"` — or export a service token in your shell profile so a bare `seekrit run -- …` resolves everything. ## 4. Local overrides still use `.env` You don't lose the convenience of a local file. `seekrit run` auto-loads `.env` from the working directory and **layers it on top of** the managed secrets, and the live shell wins over everything: ``` group secrets < app-env secrets < .env file < process env (highest wins) ``` So you can override a single value locally without touching the shared set — point a service at a local database, say — while every other variable comes from seekrit: ```bash echo 'DATABASE_URL=postgres://localhost/dev' > .env.local seekrit run --env-file .env.local -- npm run dev # .env.local wins for that one key ``` Not sure where a value resolved from? `--explain` prints each variable's source layer to stderr (names only, never values): ```bash seekrit run --app storefront --env development --explain -- true ``` > **Warning:** A leftover committed `.env` will silently **shadow** your managed secrets, since the file layer wins. After importing, delete the committed file and gitignore any local one — see [Clean up](#5-clean-up-the-plaintext-file). ## 5. Clean up the plaintext file Once secrets are imported and the app runs through `seekrit run`, remove the plaintext `.env` from the repo and stop tracking it: ```bash git rm --cached .env echo '.env' >> .gitignore echo '.env.local' >> .gitignore ``` > **Warning:** A secret that was ever committed is compromised even after you delete it — it stays in git history. Treat imported values as needing rotation, and reissue any key that lived in a committed `.env`. ## Framework notes Most Node frameworks read `process.env`, so wrapping their dev/build/start command with `seekrit run` is all it takes. - **Next.js** loads its own `.env*` files, but variables already present in `process.env` take precedence — so `seekrit run -- next dev` and `seekrit run -- next build` just work. Remember Next only exposes `NEXT_PUBLIC_`-prefixed vars to the browser; that's unchanged. - **Vite** exposes only `VITE_`-prefixed vars to client code and reads them from `process.env` at config time: `seekrit run -- vite` and `seekrit run -- vite build`. - **TypeScript runners** (`tsx`, `ts-node`) and **nodemon** need no dotenv preload — `seekrit run -- tsx watch src/server.ts`, `seekrit run -- nodemon server.js`. - **Node's built-in `--env-file`** (Node 20.6+) is the same idea as dotenv and can likewise go away; let seekrit inject instead of `node --env-file=.env`. ## Production, CI, and containers In local dev you resolve as yourself. Everywhere else, authenticate with a **service token** — mint one bound to an environment in the console (or with an admin token). It carries its own org, app, and environment, self-decrypts with no passphrase, and can be revoked independently, so `seekrit run` needs no other flags: ```bash export SEEKRIT_TOKEN=skt_… seekrit run -- node server.js ``` For runtime images, skip Node entirely with the static [`seekrit-run`](/docs/guides/run) launcher — same layering and `.env` parsing, in a ~2 MB dependency-free binary: ```bash SEEKRIT_TOKEN=skt_… seekrit-run -- node server.js ``` See [Service tokens](/docs/guides/service-tokens) for minting and scoping, and [CI/CD & containers](/docs/guides/ci-cd) for pipeline and image wiring. ## Next steps - Share config across services with [Environments & groups](/docs/guides/environments) - Mint and scope machine credentials with [Service tokens](/docs/guides/service-tokens) - Go Node-free in containers with the [`seekrit-run` launcher](/docs/guides/run) - Browse every flag in the [CLI reference](/docs/reference/cli) --- # Jupyter notebooks A notebook is the easiest place in a codebase to leak a credential. The two usual ways happen without anyone deciding to do anything careless: - **A token pasted into a cell.** A kernel launched from JupyterLab, VS Code, or JupyterHub doesn't inherit the shell where you exported anything, so the obvious fix is to type the credential into the first cell — where it is saved into the `.ipynb` and committed. - **A secret printed by a cell.** Notebook files store *outputs* as well as source. `print(api_key)`, a stray `secrets` on the last line, or a DataFrame built from a connection string all write plaintext into the file that gets pushed and reviewed. The Python SDK's `load()` is built for exactly this. One call at the top of the notebook, and the secrets land in `os.environ` where your libraries already look for them: ```bash pip install seekrit ``` ```python import seekrit seekrit.load() ``` That's the whole integration. Everything downstream reads the environment as usual — `psycopg`, `boto3`, `openai`, `sqlalchemy`, anything that honors env vars: ```python import os, psycopg conn = psycopg.connect(os.environ["DATABASE_URL"]) ``` > **Note:** `load()` needs a [service token](/docs/guides/service-tokens) — a machine credential bound to exactly one app environment. That scope is the notebook's blast radius, so create a token for the environment the analysis should see (a read-only `staging`, usually) rather than reusing a production one. ## Where the token comes from `load()` looks in three places, in order: 1. the `token=` argument, 2. `$SEEKRIT_TOKEN` in the kernel's environment, 3. an interactive prompt. The prompt is the point. When no token is configured, `load()` asks for one through a password field — ipykernel routes `getpass` to the notebook frontend — so the credential goes into the kernel's memory and **never into the notebook file**: ```python seekrit.load() # seekrit service token (skt_...): [·······] # ``` To skip the prompt, give the kernel the token in its environment. Launching Jupyter under [`seekrit run`](/docs/guides/run) is the tidiest way — the kernel inherits it, and `load()` finds it without asking: ```bash SEEKRIT_TOKEN=skt_... seekrit run -- jupyter lab ``` Control the prompt explicitly when you need to: ```python seekrit.load(prompt=False) # never ask; raise if no token is configured seekrit.load(prompt=True) # require the prompt ``` For a notebook executed headlessly — `papermill`, `nbconvert --execute`, a scheduled job — there is no frontend to prompt, so set `SEEKRIT_TOKEN` in the environment that runs it. `load()` says so rather than hanging. ## Cell outputs stay clean `load()` deliberately does not return your secrets. Its result carries the **names** it loaded and the scope they came from, and nothing else — so the value that gets saved into the `.ipynb` when the cell displays it is a summary: ```python seekrit.load() # ``` There is no `result["API_KEY"]`; the object has no way to hand back a value. Read them from the environment instead, and keep them out of the last line of a cell: ```python loaded = seekrit.load() len(loaded) # 7 "DATABASE_URL" in loaded # True sorted(loaded) # ['API_KEY', 'DATABASE_URL', …] ``` > **Warning:** This guards the *summary*, not your own cells. `print(os.environ["API_KEY"])` still writes a secret into the notebook file, and so does an exception traceback that renders a connection string. Clear outputs before committing — `jupyter nbconvert --clear-output --inplace notebook.ipynb`, or [`nbstripout`](https://github.com/kynan/nbstripout) as a git filter — and treat a notebook that has ever displayed a secret as needing a [rotation](/docs/guides/rotation). ## Re-running the cell `load()` refreshes: run the cell again and every name is re-resolved and overwritten, which is what you want after rotating a secret or switching branches. That is the opposite of `Client().into_env()`, which leaves existing variables alone — calling `load()` is a statement that seekrit owns these names. To keep what the kernel already has: ```python seekrit.load(override=False) # existing os.environ wins; skipped names are listed ``` ## Options Everything the [SDK client](/docs/guides/sdks) takes, `load()` takes too: ```python seekrit.load( token=None, # default: $SEEKRIT_TOKEN, then prompt api_url=None, # default: $SEEKRIT_API_URL overrides={"shared": "dev"}, # pull another env slice of a composed group env=None, # default: os.environ override=True, # resolved secrets win prompt="auto", # "auto" | True | False timeout=30.0, interpolate=True, # expand ${OTHER_SECRET} references ) ``` `load()` is fail-closed like the rest of the SDK: a bad token, an unreachable API, or a layer that won't decrypt raises instead of loading a partial environment. ## Colab, Kaggle, and other hosted kernels The same call works — the prompt is the mechanism that makes it safe. Hosted notebooks have no shell you control, so `$SEEKRIT_TOKEN` won't be set and `seekrit.load()` will ask for the token each session: ```python !pip install --quiet seekrit import seekrit; seekrit.load() ``` Prefer the platform's own secret store for the token itself where one exists (Colab's **Secrets** panel, Kaggle's **Add-ons → Secrets**) and pass it through, so a shared notebook prompts its reader rather than carrying your credential: ```python from google.colab import userdata seekrit.load(token=userdata.get("SEEKRIT_TOKEN")) ``` ## What the notebook can and can't do The SDK is **read-only**: it resolves and decrypts. Creating, editing, and rotating secrets stays in the [dashboard](/docs/guides/web-app) and [CLI](/docs/guides/cli), which use a human principal's passphrase-protected key. A notebook holding a service token can read one environment's secrets and nothing else. Decryption happens in the kernel. The API returns ciphertext plus a data key wrapped to your token's public key, and the SDK unwraps and decrypts locally — the same path as every other seekrit client. See the [encryption model](/docs/concepts/encryption). --- # The `seekrit-run` launcher `seekrit-run` is a compiled, single-file version of `seekrit run` built for machines. It authenticates with a service token, fetches the bound environment's encrypted secrets, **decrypts them locally**, layers them under any `.env` files and the live process environment, and then `exec`s your command. That's all it does — no config files, no caching, no daemon. ```bash SEEKRIT_TOKEN=skt_… seekrit-run -- ./start-server ``` > **Note:** Use the Node `seekrit` CLI for human workflows (login, key setup, managing secrets). `seekrit-run` is **service-token only** and read-only at runtime: the smallest thing that can turn a token into an environment and hand off to your process. ## Degrades gracefully Fetching seekrit secrets is **best-effort**. If the token is missing or malformed, or the API can't be reached, `seekrit-run` logs a warning to stderr and still runs your command with just the `.env` overlay and the live environment: ``` seekrit-run: continuing without seekrit-managed secrets: could not reach the seekrit API: … ``` This means the same launcher works whether or not seekrit is reachable — locally without a token, in CI, or in an offline sandbox — so you can wire it into an image's entrypoint unconditionally. Only genuinely local problems stop it: a usage error, an explicit `--env-file` that can't be read, or a command that can't be started. The `seekrit run` subcommand of the Node CLI behaves the same way. Note what that degraded start actually gives you: the command runs, but **without** its managed secrets. If your app needs them, it will fail somewhere further in — usually deeper and less obviously than a failure at startup. Add `--cache` when you would rather it keep running on the last secrets it saw. ## Surviving an outage with `--cache` Off by default. With `--cache`, each successful resolve is written to disk and a later run falls back to it when the API cannot be reached: ```bash seekrit-run --cache -- ./start-server ``` ``` seekrit-run: could not reach the seekrit API: … — using cached secrets fetched 6m ago ``` Only the **encrypted** response is stored — the same ciphertext and wrapped data keys the API serves. Decrypting it still needs this token, so the file is no more sensitive than the token beside it (it is written `0600` in a `0700` directory). Live resolve is always tried first, and a *refused* resolve (`401`/`403`) deletes the entry rather than falling back to it, so revoking a token still takes effect on the next run. Entries expire after `--cache-max-age` (default `24h`). In a container, point it at a mounted volume so it survives a restart: ```bash seekrit-run --cache --cache-dir /var/cache/seekrit -- ./start-server ``` Full behavior, including the two trade-offs it makes, is in the [CLI reference](/docs/reference/cli#last-known-good-cache). ## Why use it instead of the CLI? - **Tiny & static.** One ~1–2 MB file with no runtime dependencies — no Node, no OpenSSL, no system CA bundle. TLS roots are compiled in, so it runs unchanged in `distroless`, `alpine`, and `scratch` images. - **Same behavior as `seekrit run`.** Identical layer precedence, `.env` parsing, and `${OTHER_SECRET}` [reference](/docs/guides/references) expansion; its decryption is verified bit-for-bit against the browser/CLI crypto. - **Clean process model.** On Unix it replaces itself with your command (`execvp`), so signals and exit codes pass through exactly. On Windows it spawns, waits, and forwards the exit code. ## Install The install script detects your OS and architecture, downloads the matching binary from `run.seekrit.dev`, verifies its SHA-256 checksum, and drops it on your `PATH`: ```bash curl -fsSL https://run.seekrit.dev/install.sh | sh ``` It installs to `/usr/local/bin` if that's writable, otherwise `~/.local/bin`. Override the destination, pin a version, or force a target with environment variables: ```bash # Pin a version and install somewhere specific. curl -fsSL https://run.seekrit.dev/install.sh \ | SEEKRIT_RUN_VERSION=0.2.0 SEEKRIT_RUN_INSTALL_DIR="$HOME/bin" sh ``` | Variable | Default | Description | | --- | --- | --- | | `SEEKRIT_RUN_VERSION` | `latest` | Version to install, e.g. `0.2.0`. | | `SEEKRIT_RUN_INSTALL_DIR` | `/usr/local/bin` or `~/.local/bin` | Where to put the binary. | | `SEEKRIT_RUN_TARGET` | auto-detected | Force a Rust target triple. | ### Manual download Prefer to fetch it yourself? Every release is published under a versioned and a `latest/` path, each with a `.sha256` alongside. On Linux the fully static **musl** build runs anywhere (glibc, `alpine`, `distroless`, `scratch`): ```bash # Pick your target: {x86_64,aarch64}-{unknown-linux-musl,unknown-linux-gnu,apple-darwin} curl -fsSL -O https://run.seekrit.dev/latest/seekrit-run-aarch64-apple-darwin.tar.gz curl -fsSL -O https://run.seekrit.dev/latest/seekrit-run-aarch64-apple-darwin.sha256 shasum -a 256 -c seekrit-run-aarch64-apple-darwin.sha256 tar xzf seekrit-run-aarch64-apple-darwin.tar.gz install -m 0755 seekrit-run /usr/local/bin/ ``` Windows ships as a `.zip` (`seekrit-run-x86_64-pc-windows-msvc.zip`). ## Usage ```bash # Token from the environment (or a .env file); resolves org/app/env from it. SEEKRIT_TOKEN=skt_… seekrit-run -- pnpm start # Token via flag; everything after -- is the command, verbatim. seekrit-run --token skt_… -- node --enable-source-maps server.js # Swap one composed group's slice for this run. seekrit-run --with auth-providers=staging -- ./app # See where each variable resolved from (stderr; names only, never values). seekrit-run --explain -- true ``` ## Precedence Highest wins — identical to `seekrit run`: ``` process env > .env files > app-environment secrets > group secrets ``` The live process environment always wins, so a value exported in the shell (or by your orchestrator) overrides anything seekrit resolves. ## Options | Flag | Default | Description | | --- | --- | --- | | `-t, --token ` | `SEEKRIT_TOKEN` (env or `.env`) | Service token. | | `--api-url ` | `SEEKRIT_API_URL` or `https://api.seekrit.dev` | API base URL. | | `-e, --env-file ` | `.env` | A `.env` file to overlay (repeatable). | | `--no-env-file` | | Do not load the default `.env`. | | `--with ` | | Override one composed group's slice (repeatable). | | `--explain` | | Print each variable's source to stderr. | | `--no-interpolate` | | Leave `${OTHER_SECRET}` [references](/docs/guides/references) as literal text. | | `--cache` | off | Fall back to the [last-known-good](#surviving-an-outage-with---cache) encrypted response when the API is unreachable. Also `SEEKRIT_CACHE=1`. | | `--no-cache` | | Override `SEEKRIT_CACHE=1` for this run. | | `--cache-dir ` | `SEEKRIT_CACHE_DIR`, else `$XDG_CACHE_HOME/seekrit` | Where cached responses live. | | `--cache-max-age ` | `24h` | How stale a cached response may be and still be used. | `HTTPS_PROXY` / `ALL_PROXY` are honored for egress-proxied networks. ## In a container Keep Node out of your runtime image entirely. The simplest way is to copy the binary out of the published image — `seekritdev/run` is multi-arch and is just the static binary on `scratch` (~2 MB) — into a minimal runtime, and make it the entrypoint: ```dockerfile FROM seekritdev/run:latest AS seekrit FROM gcr.io/distroless/static COPY --from=seekrit /seekrit-run /usr/local/bin/seekrit-run ENTRYPOINT ["seekrit-run", "--"] CMD ["./start-server"] ``` Pin a release tag (e.g. `seekritdev/run:0.3.0`) for reproducibility, or `:edge` for the latest `main`. Prefer not to depend on Docker Hub? Fetch the static binary in a build stage instead — pin the version and verify the checksum for reproducible images: ```dockerfile # --- fetch seekrit-run: pinned + checksum-verified -------------------------- FROM alpine:3 AS seekrit ARG SEEKRIT_RUN_VERSION=0.2.0 ARG TARGET=x86_64-unknown-linux-musl RUN apk add --no-cache curl && cd /tmp \ && curl -fsSL -O https://run.seekrit.dev/v${SEEKRIT_RUN_VERSION}/seekrit-run-${TARGET}.tar.gz \ && curl -fsSL -O https://run.seekrit.dev/v${SEEKRIT_RUN_VERSION}/seekrit-run-${TARGET}.sha256 \ && sha256sum -c seekrit-run-${TARGET}.sha256 \ && tar xzf seekrit-run-${TARGET}.tar.gz -C /usr/local/bin # --- your runtime image: fully static, so distroless/static is enough ------- FROM gcr.io/distroless/static COPY --from=seekrit /usr/local/bin/seekrit-run /usr/local/bin/seekrit-run ENTRYPOINT ["seekrit-run", "--"] CMD ["./start-server"] ``` ```bash docker run --rm \ -e SEEKRIT_TOKEN="$SEEKRIT_TOKEN" \ your-image ./start-server ``` > **Warning:** Inject at runtime, never at build time. Secrets fetched during `docker build` can be baked into an image layer. `seekrit-run` only ever puts values into the child process's environment — nothing touches disk. ## Exit codes | Code | Meaning | | --- | --- | | `2` | Usage error (unknown flag, no command). | | `1` | An explicitly named `--env-file` could not be read. | | `127` | The command could not be started. | | _child_ | Otherwise, the command's own exit code. | A missing/malformed token, a revoked token or missing key grant, and network failures are **not** fatal — `seekrit-run` warns and runs the command without the managed secrets (see [Degrades gracefully](#degrades-gracefully)). See the [CLI reference](/docs/reference/cli#seekrit-run-launcher) for the condensed version, and [Service tokens](/docs/guides/service-tokens) for how to mint the token this binary needs. --- # CI/CD & containers The pattern for every automated environment is the same: provide a **service token** and either `run` your command with secrets injected or `export` them. No passphrase is involved. Store the token in your platform's secret store as `SEEKRIT_TOKEN`. The token is bound to one application environment, so `seekrit run`/`export` need no `--app`/`--env` flags — it resolves its own org, app, environment, and composed groups. ## GitHub Actions The [`seekritdev/github-action`](https://github.com/seekritdev/github-action) action resolves your secrets, decrypts them **on the runner**, and injects them into the job — every later step sees them as environment variables. Store the token as an [encrypted Actions secret](https://docs.github.com/actions/security-guides/encrypted-secrets) named `SEEKRIT_TOKEN`. ```yaml jobs: deploy: runs-on: ubuntu-latest steps: - uses: seekritdev/github-action@v1 with: token: ${{ secrets.SEEKRIT_TOKEN }} - run: ./deploy.sh # $DATABASE_URL, $API_KEY, … are set and masked ``` Every value is masked in the logs (`::add-mask::`) and decryption happens entirely on the runner — the API only ever returns ciphertext. Handy inputs: `with` (compose a group at a specific environment, like the CLI's `--with`), `prefix`, `include`/`exclude`, and `set-outputs` to expose values as step outputs instead of environment variables. See the [action's README](https://github.com/seekritdev/github-action#inputs) for the full list. > **Note:** Prefer injecting into the job environment (the default) over step outputs — masked environment variables are a smaller surface than run-scoped outputs. Want to wrap a single command instead of exporting to the whole job? Install the CLI and use `seekrit run` — the same pattern as every other CI system: ```yaml jobs: deploy: runs-on: ubuntu-latest env: SEEKRIT_TOKEN: ${{ secrets.SEEKRIT_TOKEN }} steps: - uses: actions/checkout@v4 - run: npm install -g @seekrit/cli - run: seekrit run -- ./deploy.sh # secrets injected into the process ``` ### A config per pull request Preview deploys usually need *almost* the shared config — with one or two values of their own (a database branch URL, a preview hostname). A [branch config](/docs/guides/branches) gives each PR exactly that: it inherits everything from `dev` and stores only the difference, then deletes itself. `SEEKRIT_BRANCH` is read by `seekrit run`, `seekrit export`, and `seekrit-run`, so setting it once at the job level is enough. The token stays bound to `dev` — naming a branch of its own environment needs no new token and no new grants. ```yaml jobs: preview: runs-on: ubuntu-latest env: SEEKRIT_TOKEN: ${{ secrets.SEEKRIT_TOKEN }} # bound to api/dev SEEKRIT_BRANCH: pr-${{ github.event.number }} steps: - uses: actions/checkout@v4 - run: npm install -g @seekrit/cli - run: seekrit branch create "$SEEKRIT_BRANCH" --app api --from dev --ttl 3d - run: seekrit secrets set DATABASE_URL "$NEON_BRANCH_URL" --app api --env dev --branch "$SEEKRIT_BRANCH" - run: seekrit run -- ./deploy-preview.sh teardown: if: github.event.action == 'closed' runs-on: ubuntu-latest env: SEEKRIT_TOKEN: ${{ secrets.SEEKRIT_TOKEN }} steps: - run: npm install -g @seekrit/cli - run: seekrit branch delete "pr-${{ github.event.number }}" --app api ``` `branch create` is idempotent-friendly in practice — re-running the workflow on a new commit hits an existing-slug conflict, which you can ignore. The `--ttl` is the backstop for when teardown never runs: a cancelled workflow, a force-push, a dead runner. Nothing is left holding credentials. ## Docker Prefer injecting secrets **at runtime**, not baking them into an image. In containers the best fit is [`seekrit-run`](/docs/guides/run) — a tiny static binary with no Node/OpenSSL/CA dependency, so it works in `distroless`, `alpine`, and `scratch` images. Copy it out of the published `seekritdev/run` image and make it the entrypoint: ```dockerfile FROM seekritdev/run:latest AS seekrit FROM gcr.io/distroless/static COPY --from=seekrit /seekrit-run /usr/local/bin/seekrit-run ENTRYPOINT ["seekrit-run", "--"] CMD ["./start-server"] ``` The [`seekrit-run` guide](/docs/guides/run#in-a-container) also has a Docker-Hub-free variant that downloads the binary from `run.seekrit.dev` and verifies its checksum. ```bash docker run --rm \ -e SEEKRIT_TOKEN="$SEEKRIT_TOKEN" \ your-image ./start-server ``` If Node is already in your image you can use the CLI instead — same behavior. The CLI ships as [`seekritdev/cli`](https://hub.docker.com/r/seekritdev/cli), or install it and wrap your command: ```dockerfile # entrypoint.sh #!/bin/sh exec seekrit run -- "$@" ``` If you must materialize a file (for tools that read `.env`), write it inside the container at startup and keep it out of any image layer: ```bash seekrit export --format dotenv > /run/secrets.env ``` > **Warning:** Never `seekrit export` into a build stage that gets committed to an image layer. Fetch secrets at container start, into a tmpfs or process environment. ## Kubernetes Store the token in a Kubernetes Secret and reference it as an environment variable; run your app through the CLI: ```yaml env: - name: SEEKRIT_TOKEN valueFrom: secretKeyRef: name: seekrit-token key: token command: ["seekrit", "run", "--"] args: ["./start-server"] ``` > **Tip:** Prefer a declarative sync into native Kubernetes `Secret` objects — so pods consume secrets without any seekrit tooling in the image — with the [External Secrets Operator](/docs/guides/kubernetes). One `helm install`, then you write only `ExternalSecret` resources. ## AI agent sandboxes Ephemeral environments — like the throwaway sandboxes an AI coding agent spins up — are a natural fit: create a short-lived environment, grant a scoped token, and let the agent's process read secrets through the CLI without ever seeing long-lived credentials. Revoke the token when the sandbox is torn down. > **Note:** A dedicated agent-proxy that swaps tokenized placeholders for real credentials on outbound requests (so an agent never sees secret values at all) is on the roadmap. The grant/wrap model already supports it — a proxy is just another principal. --- # Kubernetes (External Secrets Operator) There are two ways to get seekrit secrets into a Kubernetes workload: 1. **Inject at runtime** — run your process through [`seekrit run`](/docs/guides/run), which resolves and decrypts into the process environment. Nothing is written to the cluster. This is the [CI/CD guide's Kubernetes pattern](/docs/guides/ci-cd#kubernetes). 2. **Sync into a Kubernetes `Secret`** — declaratively, with the [External Secrets Operator](https://external-secrets.io) (ESO), so your pods consume a normal `Secret` via `envFrom`/`valueFrom` and nothing in them knows about seekrit. **That's this guide.** ## How it works seekrit is zero-knowledge: `GET /v1/resolve` returns only ciphertext plus a data key wrapped to your token's public key, and decryption happens client-side. ESO, which expects to pull *plaintext* from a provider, therefore can't read from the seekrit API directly. The **`seekrit-eso` Helm chart** bridges that gap. It deploys a small in-cluster sidecar (`seekrit-sdk-server`) that holds a service token, resolves and decrypts **locally**, caches the result, and serves it over a tiny authed HTTP API — then generates a webhook `SecretStore` that points ESO at the sidecar. You run stock, unmodified ESO and write only `ExternalSecret` resources. ``` ExternalSecret ─▶ ESO (stock) ──webhook──▶ seekrit-sdk-server ──/v1/resolve──▶ seekrit API (you write) (unmodified) (the chart) (ciphertext only) ``` > **Note:** The seekrit API still never sees plaintext — decryption stays inside your cluster, exactly as it does for the CLI and `seekrit-run`. The sidecar is simply "another principal" holding a grant. ## Prerequisites **1. External Secrets Operator**, installed once per cluster: ```bash helm repo add external-secrets https://charts.external-secrets.io helm install external-secrets external-secrets/external-secrets \ -n external-secrets --create-namespace ``` **2. A seekrit service token** bound to the app environment you want to sync. Mint one with the CLI (see [Service tokens](/docs/guides/service-tokens)): ```bash seekrit token create --name eso --app storefront --env production # prints: skt_XXXXXXXX_… (save it now) ``` The token is bound to that one environment and auto-granted its keys (plus any composed group slices), so it can read exactly that environment and nothing else. ## Install the chart ```bash helm install seekrit-eso oci://registry-1.docker.io/seekritdev/seekrit-eso \ --version 0.2.0 \ -n seekrit-system --create-namespace \ --set seekrit.token=skt_XXXXXXXX_… ``` That deploys the sidecar and creates a `SecretStore` named `seekrit`. The chart also generates the sidecar's API key automatically and preserves it across `helm upgrade`. > **Tip:** Managing secrets with GitOps? Don't put the token in `--set`. Create a `Secret` yourself (sealed-secrets, SOPS, etc.) and point the chart at it with `--set seekrit.existingSecret=my-token-secret`. ## Write an ExternalSecret Now the payoff — the only thing your team writes per app: ```yaml apiVersion: external-secrets.io/v1 kind: ExternalSecret metadata: name: storefront namespace: seekrit-system spec: refreshInterval: 1m secretStoreRef: name: seekrit kind: SecretStore target: name: storefront-secrets # the Kubernetes Secret ESO creates/manages data: - secretKey: DATABASE_URL # key in the resulting Secret remoteRef: key: DATABASE_URL # secret name in your seekrit environment - secretKey: STRIPE_API_KEY remoteRef: key: STRIPE_API_KEY ``` > **Note:** The generated `SecretStore` is namespaced (`secretStore.kind` defaults to `SecretStore`), so any `ExternalSecret` referencing it must live in the **same namespace you installed the chart into** — `seekrit-system` above. To reference it from other namespaces (e.g. your app's own namespace) instead, install with `--set secretStore.kind=ClusterSecretStore`. ESO creates a `storefront-secrets` Secret in that namespace and keeps it in sync. Consume it like any other Secret (from a pod in the same namespace): ```yaml envFrom: - secretRef: name: storefront-secrets ``` Check status: ```bash kubectl -n seekrit-system get externalsecret storefront kubectl -n seekrit-system logs deploy/seekrit-eso ``` ### Secret references `${OTHER_SECRET}` [references](/docs/guides/references) are expanded by the sidecar, over the merged environment, before ESO ever sees a value — so the `Secret` in the cluster holds the assembled string. ### Pulling a whole environment ESO's webhook provider fetches one key at a time, so list the keys you want in `data[]` (the sidecar caches the decrypted environment, so each fetch is cheap). To pull *everything* without enumerating keys, template against the sidecar's whole-environment map with `dataFrom` + `target.template` — the sidecar exposes `GET /v1/secrets` returning `{"data": {NAME: value, …}}`. > **Note:** A native seekrit ESO provider (first-class `provider: {seekrit:}` with built-in `dataFrom` support) is planned. It reuses this same sidecar, so when it lands the chart swaps its generated `SecretStore` with no change to your `ExternalSecret`s. ## Rotation & refresh timing Two intervals stack. The **sidecar** re-resolves from seekrit every `refreshInterval` (chart value, default `60s`), and **ESO** re-reads the sidecar every `spec.refreshInterval` on the `ExternalSecret`. So a rotated secret reaches your pods within roughly the sum of the two. Lower either for faster propagation. ## Surviving a seekrit outage The sidecar's **first** resolve is fail-closed: if the seekrit API is unreachable when it starts, it refuses to bind. That is the right default for a component that hands out plaintext — but it means a pod rescheduled during an outage takes every `ExternalSecret` it backs down with it. (Once running, it is already resilient: a failed *refresh* keeps serving the last-good snapshot in memory.) Turn on the last-known-good cache to close that gap: ```yaml cache: enabled: true maxAge: 24h # emptyDir survives a container restart but not a reschedule. # Use a PVC to survive both: # volume: # persistentVolumeClaim: # claimName: seekrit-lkg-cache ``` The sidecar then writes each successful resolve — the **encrypted** response only — to the volume, and a restart that cannot reach the API starts from that copy instead of failing. It keeps retrying on a short backoff and switches to live secrets as soon as the API answers. Decrypting the cached copy still requires the service token, so the volume is no more sensitive than the token `Secret` already mounted into the pod. The trade-off is the one described in the [CLI reference](/docs/reference/cli#last-known-good-cache): while the API is unreachable, a revoked token keeps working until `maxAge` elapses. A *reachable* API that refuses the token deletes the entry immediately. ## Security posture > **Warning:** ESO writes decrypted values into Kubernetes `Secret` objects (base64 in etcd). That is inherent to how ESO works — seekrit's control plane stays zero-knowledge, but the cluster becomes a trusted decryption endpoint, the same as running `seekrit-run` on a CI box. Harden accordingly: - **Enable [etcd encryption-at-rest](https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/)** so the synced Secrets aren't stored in plaintext. - **Restrict who can reach the sidecar.** It decrypts everything the token can read, gated by an API key. Turn on the chart's `NetworkPolicy` to allow ingress only from your ESO pods: ```bash --set networkPolicy.enabled=true \ --set 'networkPolicy.from[0].namespaceSelector.matchLabels.kubernetes\.io/metadata\.name=external-secrets' ``` - **One token, one environment.** The token's scope *is* the sidecar's blast radius. To sync another app or environment, install a second release with its own token and `secretStore.name` — don't broaden one token. ## Reference - Chart values and options: the [`seekrit-eso` chart](https://github.com/seekritdev/helm-charts/tree/main/charts/seekrit-eso). - The sidecar's source: [`seekrit-sdk-server`](https://github.com/seekritdev/seekrit-sdk-server). - Minting and scoping tokens: [Service tokens](/docs/guides/service-tokens). - Runtime injection instead of syncing: [`seekrit run`](/docs/guides/run) and [CI/CD & containers](/docs/guides/ci-cd#kubernetes). --- # Third-party sync Most ways of getting secrets into a running app keep decryption on your side: [`seekrit run`](/docs/guides/run) decrypts in your process, the [egress proxy](/docs/guides/agent-proxy) decrypts in your proxy, the [SDKs](/docs/guides/sdks) decrypt in your code, and [ESO](/docs/guides/kubernetes) decrypts in your cluster. Some platforms don't let you run anything before your app starts. A Vercel build reads environment variables that Vercel already holds; a Cloudflare Worker reads bindings the platform injected before your code ran. There is no earlier point to inject them. **Third-party sync** is for exactly that case: seekrit stays the source of truth, and pushes the environment out whenever it changes. ## Choose the right tool first | If you… | Use | Who decrypts | | --- | --- | --- | | control the process (CI job, container, server) | [`seekrit run`](/docs/guides/run) | your machine | | have untrusted or agent workloads | [egress proxy](/docs/guides/agent-proxy) | your proxy | | run Kubernetes | [ESO chart](/docs/guides/kubernetes) | your cluster | | are writing the app | [a language SDK](/docs/guides/sdks) | your process | | **don't control the runtime** (Vercel build env, Cloudflare bindings, a managed platform's env vars) | **sync** | **seekrit's sync engine** | Sync is the last row, and it is the only feature in seekrit where our servers decrypt anything. Reach for it when the rows above don't apply. > **Warning:** **Enabling sync lets seekrit decrypt that environment.** To push a value to any of the platforms below, something has to hold it in the clear, and sync runs when nobody is logged in — so the sync engine must be able to decrypt on its own. This applies **only** to environments you explicitly enable it for, and only to the destination you named. Everything else stays zero-knowledge: seekrit cannot decrypt an environment that has no sync grant, and enabling one requires a key holder, so it can't be switched on server-side. ## How it works Enabling sync creates two things: 1. A **connection** — the destination account: an API credential for Vercel, Cloudflare, AWS, or any other platform below. It is encrypted in your browser to a public key held by the sync engine, so the control plane stores ciphertext it cannot open. 2. A **binding** — one environment → one destination. Creating it also creates a **key grant**: the environment's data key, wrapped in your browser to that same public key. This grant is what authorizes decryption, and it is [an ordinary key grant](/docs/concepts/access-control) — visible alongside your users and service tokens, and revocable the same way. ``` secret write ─▶ seekrit API ─▶ sync engine ──▶ destination (decrypts here, only with a grant) ``` The private half of that keypair lives inside the sync engine and is never written to the database. Deleting the connection destroys it, which turns every grant made to it into ciphertext nobody can open — including us. Every destination follows the same three steps, and each page below walks them for its own platform: 1. **Create a credential** at the destination, scoped as narrowly as that platform allows. 2. **Add the connection** with `seekrit sync connect` (or **Sync → Add connection** in the dashboard). The credential is read from stdin — never a flag, so it can't land in your shell history — and wrapped in your browser or terminal before it is sent. 3. **Bind an environment** with `seekrit sync enable`. This is the step that shows the decryption disclosure and creates the key grant, so it has to be run by someone who can already read the environment. `--acknowledge-decryption` is the terminal's version of the dashboard's disclosure: at a terminal you can leave it off and answer the prompt, but a non-interactive run must pass it, and either way the acknowledgment lands in the audit row. seekrit then pushes once immediately, and again on every change. ## Destinations | Destination | What it writes | Takes effect | | --- | --- | --- | | [**Vercel**](/docs/guides/third-party-sync/vercel) | Project environment variables (`encrypted`), per deployment target | next build | | [**Cloudflare Workers**](/docs/guides/third-party-sync/cloudflare) | A Worker's `secret_text` bindings — the slot `wrangler secret put` writes | immediately | | [**Cloudflare Pages**](/docs/guides/third-party-sync/cloudflare) | A project's environment variables (`secret_text`), per deployment config | next deployment | | [**Cloudflare Secrets Store**](/docs/guides/third-party-sync/cloudflare) | Account-level secrets that Workers bind by name | next Worker deploy | | [**Railway**](/docs/guides/third-party-sync/railway) | A service's variables, or an environment's shared variables | redeploy (triggered by default) | | [**AWS Secrets Manager**](/docs/guides/third-party-sync/aws) | One secret per name, or all of them as one JSON secret | next read by your app | | [**AWS Parameter Store**](/docs/guides/third-party-sync/aws) | SSM parameters under one path, `SecureString` by default | next read by your app | | [**Render**](/docs/guides/third-party-sync/render) | One service's variables, or a shared environment group's | next deploy | | [**Fly.io**](/docs/guides/third-party-sync/fly) | An app's secrets, delivered to Machines as environment variables | next Machine boot (`fly secrets deploy`) | | [**Northflank**](/docs/guides/third-party-sync/northflank) | A secret group's variables, inherited by the project's services and jobs | next deploy or restart | | [**DigitalOcean**](/docs/guides/third-party-sync/digitalocean) | An App Platform app's or component's environment variables, encrypted | a new deployment, started by the push | | [**Heroku**](/docs/guides/third-party-sync/heroku) | An app's config vars, delivered to every dyno as environment variables | immediately — a new release, and the dynos restart | | [**Netlify**](/docs/guides/third-party-sync/netlify) | A site's environment variables, for the deploy contexts you name | next build and deploy | | [**Bunnyshell**](/docs/guides/third-party-sync/bunnyshell) | An environment's variables, or a project's — inherited by environments made later | next deployment of the environment | | [**GitHub Actions**](/docs/guides/third-party-sync/github-actions) | Repository, deployment environment, or organization Actions secrets | next workflow run | | [**Google Secret Manager**](/docs/guides/third-party-sync/google-secret-manager) | One secret per name, or all of them as one JSON secret | next read by your app | | [**LangGraph Platform**](/docs/guides/third-party-sync/langgraph-platform) | An Agent Server deployment's secrets, read as environment variables | a new revision, started by the push | Each page covers the credential to create, the least-privilege scope for it, how a binding addresses that platform, and what a push does and does not touch there. Everything on *this* page — naming, filtering, references, deletions, failure handling — works the same on every one of them. > **Note:** **Syncing to GitHub Actions?** Read [that page's opening warning](/docs/guides/third-party-sync/github-actions) first. GitHub is the one destination here that seekrit *can* inject into at runtime, so the published [`seekritdev/github-action`](https://github.com/seekritdev/github-action) is the better answer for most workflows — sync only reaches the cases an Action cannot. ## Naming and filtering By default, secret names are pushed verbatim. A binding can adjust that: - **Prefix / suffix / case** — e.g. prefix `NEXT_PUBLIC_` or force upper case. - **Rename** — map individual names exactly. An explicit rename is used verbatim; prefix and case are not applied on top of it. - **Include / exclude** — glob allow/deny lists (`DB_*`, `*_PASSWORD`). Exclusion always wins over inclusion. If two secrets would end up with the same destination name, the run fails and tells you which two. seekrit will not silently let one value shadow another. From the CLI, `sync enable` takes `--prefix`, `--include`, and `--exclude` (comma-separated globs). Suffixes, case folding, and per-name renames are set in the dashboard. ```bash seekrit sync enable --connection acme-production \ --app storefront --env production --project prj_abc \ --prefix NEXT_PUBLIC_ --include 'API_*,FEATURE_*' --exclude '*_PASSWORD' \ --acknowledge-decryption ``` > **Note:** **A name transform is the usual way to produce a name a destination rejects.** Every platform has its own rules — Heroku reserves `HEROKU_`, GitHub reserves `GITHUB_`, Bunnyshell wants at least three characters, Secret Manager takes no slashes. seekrit's own names are legal almost everywhere, so it is a `--prefix` or a case fold that usually pushes one over the line. Each destination page lists its rules, and a name that breaks one is reported as a failure against that name alone — the rest of the environment still pushes. ## Secret references [`${OTHER_SECRET}` references](/docs/guides/references) are expanded before the push, the same way `seekrit run` and the SDKs expand them. The destination receives the final value, not the reference — pushing the raw stored text would put a literal `${OTHER_SECRET}` into your Vercel project. A reference to a name that doesn't exist is left as written (so CI templating like `${GITHUB_SHA}` passes through untouched). A reference **cycle** fails the run, because no value would be correct to push. ## Composed environments Sync pushes the **effective** environment — what your app actually resolves, including every [composed group](/docs/guides/environments). That means the connection needs a grant on each composed group environment too, not only the application environment. The dashboard walks you through granting all of them; if one is missing, the run fails with `no key grant for group "…"` rather than pushing a partial environment. Sync targets application environments. To sync a shared group, bind the application environments that compose it. ## Branch environments A [branch environment](/docs/guides/branches) can be synced like any other, and the pairing is a natural one: point a branch at a Vercel `preview` target with its git branch set, and each PR gets its own overlay downstream. Two things follow from how branches inherit: - A write to the **parent** re-syncs its branches too, since a branch supplies only the values that differ. - When a branch **expires**, its binding goes with it and seekrit stops pushing. Values already at the destination are left alone — expiry removes the branch, not the copy the destination is holding. Delete those there if you need them gone. ## Deletions A binding's `onDelete` policy decides what happens when a secret disappears from seekrit: - **`delete`** (default) — remove it at the destination too, so the destination is a true mirror. - **`retain`** — leave it. Use this when something else also writes to that project and seekrit is not the only source. A removal only ever touches what the binding itself owns: on Vercel, variables whose targets overlap the binding's; on Pages, the deployment configs it writes; on Render, the one service or group the binding names; on AWS and Google Secret Manager, the names under the binding's prefix or path. Two bindings can point at one project with different targets without stepping on each other. A name that is already gone at the destination counts as deleted rather than failing forever. > **Note:** On **Secrets Manager**, deleting is AWS's *scheduled* deletion with its 30-day recovery window — a mistaken removal is recoverable in the console. The consequence is that the name stays reserved for those 30 days: if the secret comes back before the window closes, seekrit restores it rather than failing to recreate it. **Google Secret Manager has no such window.** A removal deletes the secret and every version of it immediately and permanently, so use `retain` there if you would rather clean up by hand. ## When a sync fails Runs are recorded with per-name outcomes. A partial run is normal — no destination API is transactional, so some names can land while others fail — and only the failures are retried. Where a destination writes in batches (the Cloudflare Workers and Secrets Store bulk endpoints, Heroku's single `PATCH`) a rejected batch is reported against every name in it, because that is all the API tells us; re-pushing a value that did in fact land is harmless. Retries back off exponentially. After five consecutive failures the binding stops and org admins get an email, because the destination is now serving stale values and nothing downstream will say so on its own. Fix the cause, then re-enable the binding or use **Sync now**. From the CLI: ```bash seekrit sync bindings # mode, last run, last error seekrit sync runs --binding syb_abc # per-run history seekrit sync run syb_abc # push now; exits non-zero unless fully successful ``` `sync run` treats a `partial` result as a failure for exit-code purposes, so it can gate a deploy step. > **Note:** **A very large environment can take more than one run.** Every connector works to a per-run request budget, sized against whichever ceiling binds first on that platform — the Worker subrequest cap, or the destination's own rate limit (Railway's is as low as 100 requests an hour on the free plan). A run that hits its budget reports that it has more to do and the engine re-runs immediately, so a big first sync is slower rather than silently partial. A few failures are the same everywhere. The rest are per-destination, and each page has a troubleshooting table for its own: - `no key grant for …` — the grant was revoked, or the environment now composes a group the connection can't read. Re-enable sync for that environment. - `the sync key grant may be stale` — the environment was re-keyed without re-granting, so the connection holds a wrap around the wrong data key. Re-enable sync for that environment. - `name collision: "…" and "…" both map to "…"` — a name transform collapsed two names into one. Fix the rename, the prefix, or the filters; seekrit will not pick a winner. > **Note:** **A failure reason never carries a secret value.** Error text is built from the destination's own message fields, never from a request body — and where a platform is known to echo a submitted value back in an error (Heroku, Netlify, Bunnyshell), the connector scrubs any value it just sent out of the message before it is stored on the run row. ## Turning it off Deleting a binding revokes that environment's grant to the connection (unless another binding still needs it). Deleting the connection revokes all of its grants and destroys its keypair. Either way the values already at the destination stay there — seekrit stops updating them, it does not reach in and clean up. Remove them at the destination if you want them gone. ```bash seekrit sync pause syb_abc # stop pushing, keep the binding and its grant seekrit sync disable syb_abc # delete the binding and revoke its grant seekrit sync disconnect acme-production # delete the account, its bindings, and its keypair ``` --- # Sync to Vercel A Vercel build reads environment variables that Vercel already holds — there is no earlier point at which seekrit could inject them. So seekrit stays the source of truth and pushes each secret into the project as an `encrypted` environment variable, for the deployment targets the binding names. | At a glance | | | --- | --- | | **What seekrit writes** | Project environment variables, `type: encrypted` | | **Addressed by** | Vercel project id (`prj_…`) or name, plus one or more targets | | **Connection carries** | The API token, and a team id for team-owned projects | | **Token permission** | A token with access to the project — Vercel has no per-resource scope | | **Takes effect** | Next build. Vercel injects at build and run time from its own copy | | **Value visibility** | Encrypted at rest by Vercel, and readable by anyone who can read the project's env vars | New to sync? Read [Third-party sync](/docs/guides/third-party-sync) first — the decryption grant, name mapping, deletions, and failure handling are the same on every destination. ## 1. Create a Vercel token In Vercel, go to **Settings → Tokens** and create a token with access to the project you want to sync into. If the project belongs to a Vercel Team, note the team id (`team_…`) — a personal-scope token can't write to a team project, and Vercel reports that as a bare 403. Give the token an expiry you will actually notice, and name it after the connection. Vercel tokens carry their creator's access, so the least-privilege setup is a machine user added to only the projects it syncs. ## 2. Add the connection In the dashboard, open **Sync → Add connection**, choose Vercel, paste the token, and set the team id if you have one. The token is encrypted in your browser before it is sent. Or from the CLI. The token is read from stdin — never a flag, so it can't end up in your shell history — and is wrapped to the connection's public key before it is sent: ```bash printf '%s' "$VERCEL_TOKEN" | seekrit sync connect \ --name acme-production --team-id team_abc ``` Vercel is the default provider, so `--provider vercel` can be left off. The team id is sent as `?teamId=` on every request the connection makes; omit it for a personal account. Check it can reach the project before you bind anything to it: ```bash seekrit sync verify acme-production --project prj_abc ``` Verify fetches the project (`GET /v9/projects/{id}`), which proves three things at once: the token is valid, it reaches this project, and the team id is right or not needed. ## 3. Bind an environment Pick the application environment to sync and the Vercel project and targets (`production`, `preview`, `development`). This is the step that shows the decryption disclosure and creates the key grant, so it has to be done by someone who can already read the environment. ```bash seekrit sync enable \ --connection acme-production \ --app storefront --env production \ --project prj_abc \ --target production \ --acknowledge-decryption ``` `--target` takes a comma-separated list and defaults to `production`. A binding writes each variable to every target it names, which is how one seekrit environment feeds both production and preview from a single binding. seekrit pushes once immediately, then on every change. ### Preview deployments and one branch `--git-branch` restricts `preview` writes to a single git branch. Vercel only honors it when `preview` is among the targets, so seekrit sends it only then — a production-only binding that carried a branch would be rejected outright. ```bash seekrit sync enable --connection acme-production \ --app storefront --env staging \ --project prj_abc --target preview --git-branch staging \ --acknowledge-decryption ``` This pairs naturally with [branch environments](/docs/guides/branches): point a seekrit branch environment at a Vercel `preview` target with its git branch set, and each PR gets its own overlay downstream. When the branch expires its binding goes with it, and seekrit stops pushing — the values already on Vercel stay where they are. ## How a push behaves - **One request per name**, `POST /v10/projects/{id}/env?upsert=true`. Vercel's upsert is create-or-replace, so a run never needs to read the project's current values first. - **Values are stored as `encrypted`.** Vercel can read them — that is inherent to pushing anywhere, and is exactly what the sync key grant authorizes. - **A removal costs a listing.** Deleting needs Vercel's internal env-var id, so a run with removals lists the project's variables once, then deletes each. Only records whose targets **overlap this binding's** are touched, so two bindings can point at one project with different targets and neither will delete the other's variables. A variable already gone counts as deleted rather than failing forever. - **A run is capped at 500 operations.** Beyond that it reports that it has more to do and the engine re-runs immediately, so a very large first sync is slower rather than silently partial. - **A 429 stops the whole run.** seekrit honors Vercel's `Retry-After` rather than its own backoff curve; what already landed is reported, and the rest retries. ## Troubleshooting | Symptom | Cause | Fix | | --- | --- | --- | | `403` on every name | The project is team-owned and the connection has no team id | Add `--team-id team_…`, or re-create the connection with it | | `403` after it was working | The token expired, or its owner lost access to the project | Mint a new token and re-create the connection | | `404` on the project | Wrong project id, or the project was deleted or renamed | Check `prj_…` in the project's Settings; `seekrit sync verify` catches this before a binding exists | | `400` mentioning `gitBranch` | A branch was set on a binding whose targets don't include `preview` | Add `preview` to `--target`, or drop `--git-branch` | | `429`, run reported partial | Vercel rate-limited the account | Nothing to do — seekrit backs off and retries the rest | | Values pushed, site unchanged | Vercel injects env vars at build time | Redeploy. seekrit does not trigger Vercel builds | ## See also - [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions - [Branch configs](/docs/guides/branches) — per-PR environments that pair with Vercel previews - [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag --- # Sync to Cloudflare Cloudflare gives a Worker its secrets before your code runs — there is no earlier point at which seekrit could inject them. Three destinations live behind one platform here, and they behave differently enough to be worth choosing deliberately. | | Workers | Pages | Secrets Store | | --- | --- | --- | --- | | **What seekrit writes** | A Worker's `secret_text` bindings — the slot `wrangler secret put` writes | A project's environment variables (`secret_text`), per deployment config | Account-level secrets that Workers bind by name | | **Addressed by** | Worker script name | Pages project name + deployment configs | Store id + the scopes new secrets are created with | | **Token permission** | Workers Scripts: Edit | Cloudflare Pages: Edit | Secrets Store: Edit | | **Takes effect** | Immediately, on the Worker's current version | Next deployment (Pages binds at build time) | Next Worker deploy — a Worker needs a `secrets_store_secrets` binding to read one | New to sync? Read [Third-party sync](/docs/guides/third-party-sync) first — the decryption grant, name mapping, deletions, and failure handling are the same on every destination. ## 1. Create a scoped API token All three destinations share one connection shape: an API token plus the **account ID** it belongs to (32 lowercase hexadecimal characters, in the sidebar of any Cloudflare dashboard page). What differs is the token permission each needs. In Cloudflare, go to **My Profile → API Tokens → Create Token**, use *Create Custom Token*, and grant the one account-level permission from the table above — not the global API key, which carries everything. > **Note:** **A connection is per product, because the token permissions are.** Syncing to both Workers and Pages means two connections, each with its own scoped token. That is a feature rather than friction: a leaked Pages token cannot rewrite a Worker's secrets. ## 2. Add the connection ```bash printf '%s' "$CLOUDFLARE_API_TOKEN" | seekrit sync connect \ --name acme-cloudflare --provider cloudflare-workers \ --account-id 0123456789abcdef0123456789abcdef ``` `--account-id` is required for all three Cloudflare providers, and seekrit checks its shape before storing it — the alternative is a bare 400 from Cloudflare hours later, inside a run nobody is watching. It cannot catch a *zone* id pasted by mistake, which has the same shape; only the API can tell those apart, so verify a destination before you bind to it. ## 3. Bind an environment ### Workers Name the script. A Wrangler environment is its own Worker, so deploying `my-api` with `--env staging` creates `my-api-staging` — name that instead. Secrets land on the Worker's current version immediately, with no redeploy: ```bash seekrit sync verify acme-cloudflare --provider cloudflare-workers --script my-api seekrit sync enable --connection acme-cloudflare \ --provider cloudflare-workers --script my-api \ --app storefront --env production --acknowledge-decryption ``` Verify lists the script's secret **names** — never values — which is the cheapest call that proves all three things a run needs: the token is valid, it is scoped to this account, and the Worker is deployed there. ### Pages Name the project and the deployment configs (`production`, `preview`), reusing `--target`: ```bash seekrit sync enable --connection acme-pages \ --provider cloudflare-pages --project my-site --target production,preview \ --app storefront --env production --acknowledge-decryption ``` > **Note:** Pages binds environment variables at **build time**. A pushed value reaches the running site on its next deployment — seekrit cannot force one, because a Pages deploy is a build, not a config reload. Workers are the opposite: values apply to the running Worker at once. ### Secrets Store Name the store (`wrangler secrets-store store list`, or the dashboard) and the scopes new secrets are created with: ```bash seekrit sync enable --connection acme-store \ --provider cloudflare-secrets-store \ --store-id 0123456789abcdef0123456789abcdef --scopes workers \ --app storefront --env production --acknowledge-decryption ``` `--scopes` takes a comma-separated list from `workers`, `ai_gateway`, `dex`, `access`, `containers`, and `websearch`, and defaults to `workers`. Cloudflare requires at least one at creation and cannot infer them, which is why a binding states them. Store secrets are account-level: a Worker still needs a `secrets_store_secrets` binding in its Wrangler config to read one, and adding that binding is a deploy. ## How a push behaves **Workers** batches. `PATCH …/secrets-bulk` carries up to 100 upserts and deletes per request, so a 500-secret environment is five requests rather than five hundred. A `null` value deletes a name and an omitted name is left alone, so a push never disturbs a binding it doesn't own. The cost is granularity: if Cloudflare rejects a batch, every name in it is reported as failed, because that is all the API tells us. Re-pushing a value that did in fact land is harmless. **Pages** is a single `PATCH` of the project's `deployment_configs`. There is no partial landing to report — it either applies or it does not. seekrit reads the project first so removals are only sent where the key actually exists; Pages redacts `secret_text` values on read, so that listing returns names only. **Secrets Store** addresses every write by secret **id**, and seekrit only knows names, so a run pages through the store's metadata (100 per page) to map them. Creates are batched; updates and deletes are one request each. An update sends the **value alone** — `scopes` and `comment` belong to the destination once a secret exists, so widening a scope in the Cloudflare dashboard survives the next sync. Secrets seekrit creates carry a comment marking them as managed. All three share a per-run budget of 400 requests, sized against the Worker subrequest cap. A run that hits it reports more work and the engine re-runs immediately. > **Note:** **Cloudflare does not always disagree loudly.** A handful of its endpoints answer `200` with `success: false` and a populated `errors` array. seekrit treats that as a failure rather than a landed push — otherwise a run would mark secrets as delivered that never left the building. ## Troubleshooting | Symptom | Cause | Fix | | --- | --- | --- | | `403` on every name | The token is missing that destination's one permission, or was issued for a different account | Check the permission in the table above, and that the account id matches the token's account | | `404` on the Worker, project, or store | Renamed or deleted | Destinations are addressed by name, so a rename breaks the binding — update it. For a Wrangler environment, name the derived Worker (`my-api-staging`) | | `400` right after adding a connection | A **zone** id was pasted instead of an account id | Both are 32 hex characters, so only the API tells them apart. Take the account id from the dashboard sidebar | | Secrets pushed, Pages site unchanged | Pages binds at build time | Redeploy the project. seekrit does not start Pages builds | | Store secret exists, Worker can't read it | The Worker has no `secrets_store_secrets` binding for it | Add the binding in `wrangler.jsonc` and deploy | | A whole batch of names failed at once | Workers and Secrets Store write in batches | The API reports per batch, not per name. Fix the cause and re-run; the retry is idempotent | ## See also - [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions - [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag --- # Sync to Railway Railway applies variables at deploy time and hands them to the process it starts, so there is no earlier point at which seekrit could inject them. A binding maps one seekrit environment onto one (project, environment, service) address inside Railway. | At a glance | | | --- | --- | | **What seekrit writes** | A service's variables, or an environment's shared variables | | **Addressed by** | Railway project, environment, and service — all three UUIDs | | **Connection carries** | The API token, and which *kind* of token it is | | **Token permission** | An account/workspace token, or a project token scoped to one project | | **Takes effect** | On redeploy, which seekrit triggers by default | | **Value visibility** | Readable by anyone with access to the Railway project | New to sync? Read [Third-party sync](/docs/guides/third-party-sync) first — the decryption grant, name mapping, deletions, and failure handling are the same on every destination. ## 1. Create a token, and know which kind it is Railway has two kinds, and they travel in **different headers** — a project token sent as an account one fails exactly like an invalid token would, so seekrit asks you which you pasted rather than guessing: - **account** — from **Account Settings → Tokens** (personal or workspace). Reaches every project you can see. Sent as `Authorization: Bearer`. - **project** — from **Project → Settings → Tokens**. Railway scopes it to one project and environment, which makes it the least-privilege choice when a connection serves a single destination. Sent as `Project-Access-Token`. ```bash printf '%s' "$RAILWAY_TOKEN" | seekrit sync connect \ --name acme-railway --provider railway --token-kind project ``` `--token-kind` defaults to `account`. There is no workspace or team id to state: Railway ids are globally unique and a binding names its project outright, so the token plus the destination is the whole address. ## 2. Bind an environment A Railway variable is addressed by project, environment, and service — all three are UUIDs, so each has its own flag. The **environment** here is Railway's (`production`, `pr-42`), not the seekrit environment the binding reads from; a binding is the mapping between the two. ```bash seekrit sync verify acme-railway --provider railway \ --railway-project 1111… --railway-environment 2222… --service 3333… seekrit sync enable --connection acme-railway --provider railway \ --railway-project 1111… --railway-environment 2222… --service 3333… \ --app storefront --env production --acknowledge-decryption ``` Find the ids in the resource's URL, or on the project's Settings page. seekrit checks each is UUID-shaped before storing it, because the alternative is a bare `Problem processing request` hours later inside a run nobody is watching. Verify asks for the environment and the service in **one** GraphQL document rather than two round trips — Railway's hourly quota is small enough that a spare request is worth avoiding. Omit `--service` to write the environment's **shared** variables instead — the project-level set that services opt into with `${{shared.NAME}}`. That is a genuinely different destination, not a wildcard: shared variables reach only the services that reference them. > **Note:** Railway applies variables at **deploy time**, so changing one triggers a redeploy of the service. That is what makes a synced value reach the running process, and it is the default. Pass `--skip-deploys` (or tick *Don't redeploy on change*) when deploys are gated behind a release process — the values then sit staged until your next one. ## How a push behaves - **One request for the whole environment.** `variableCollectionUpsert` takes a map of up to 100 names, so a normal run costs a single call no matter how many secrets it carries. - **`replace: false`, always.** Railway's upsert can replace a collection wholesale; seekrit never uses that. Railway's own `PORT`, reference variables, and anything another binding owns are left alone, and a removal deletes just that name. - **Removals are one request each.** Railway has no bulk delete. A variable Railway has never heard of counts as deleted rather than as a failure that would retry forever. - **seekrit never reads Railway's existing values.** Variables are addressed by name, not by an internal id, so unlike Vercel there is nothing to list first — which keeps unrelated third-party plaintext out of the sync engine entirely. - **A run is capped at 200 requests**, far below the other connectors' budgets, because Railway's own rate limits bite first: as low as 100 requests per *hour* on the free plan, and 10 per second on Hobby. Since a normal push is one request, that ceiling only ever bounds runs with many deletions. > **Warning:** **Railway answers a failed call with HTTP 200.** A bad token, a project the token cannot see, and a deleted service all come back as `200 {"errors":[{"message":"Not Authorized"}]}`. seekrit treats a populated `errors` array as a failure regardless of status — trusting the status alone would mark every secret as pushed on a run where nothing left the building. It also means the *reason* has to be read out of the message text. `Not Authorized` is the one Railway returns for all three causes, so the fix is usually a process of elimination: check the token kind first, then the ids. ## Troubleshooting | Symptom | Cause | Fix | | --- | --- | --- | | `Not Authorized` on everything, immediately after setup | The token kind is wrong — Railway sends the two in different headers | Re-create the connection with the right `--token-kind`. A project token presented as a bearer fails identically to an invalid one | | `Not Authorized` on a connection that worked | The token was revoked, or its owner lost access to the project | Mint a new token and re-create the connection | | `Not Authorized` naming one destination only | A project token pointed at a project or environment outside its scope | Use an account token, or a project token minted in the right project | | A UUID is rejected when enabling | An id was pasted from the wrong place — a slug, or a name | All three ids are UUIDs. Take them from the resource's URL | | Values pushed, process still on the old ones | `--skip-deploys` is on, or the deploy has not finished | Deploy the service; without the flag seekrit triggers one for you | | Run reported partial, `429` in the error | Railway rate-limited the token | Nothing to do — seekrit honors `Retry-After` and retries the rest | ## See also - [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions - [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag --- # Sync to AWS The AWS destinations are usually chosen for a different reason than the others. You often *can* decrypt on your own side in AWS — [`seekrit run`](/docs/guides/run) in an ECS container, [an SDK](/docs/guides/sdks) in a Lambda — and where you can, you should. Sync to Secrets Manager or Parameter Store when something else already reads from them: an ECS task definition's `secrets:` block, a Terraform data source, a CloudFormation `{{resolve:ssm-secure:…}}`, or a team convention you are not going to change. | | Secrets Manager | Parameter Store | | --- | --- | --- | | **What seekrit writes** | One secret per name, or all of them as one JSON secret | SSM parameters directly under one path, `SecureString` by default | | **Addressed by** | An optional name prefix, or the one bundle secret's name | A `/`-delimited path | | **IAM actions** | `PutSecretValue`, `CreateSecret`, `DeleteSecret`, `RestoreSecret`, `ListSecrets` | `PutParameter`, `DeleteParameters`, `GetParametersByPath` | | **Takes effect** | Next read by your app | Next read by your app | | **Deletion** | AWS's scheduled deletion, 30-day recovery window | Immediate | New to sync? Read [Third-party sync](/docs/guides/third-party-sync) first — the decryption grant, name mapping, deletions, and failure handling are the same on every destination. ## The connection: a region and one IAM key Both AWS destinations share one connection shape: a **region**, an IAM **access key ID**, and the **secret access key** that pairs with it. Only the secret access key is treated as a credential. An access key ID is an identifier — it appears in CloudTrail, in the IAM console, and in the `Authorization` header of every signed request — so seekrit stores it in the clear, where the dashboard can show you which key a connection is using. That is the first thing you want to know when a connection starts failing after a key rotation. The secret half is encrypted in your browser, like every other destination credential. > **Note:** Use a long-lived key belonging to an IAM user created for this. Temporary `ASIA…` credentials from STS expire within hours, and a sync connection has to keep working unattended. ## 1. Create an IAM user with one policy Give it only what its destination needs. For Secrets Manager, scoped to the prefix the binding writes under: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "secretsmanager:PutSecretValue", "secretsmanager:CreateSecret", "secretsmanager:DeleteSecret", "secretsmanager:RestoreSecret" ], "Resource": "arn:aws:secretsmanager:us-east-1:111122223333:secret:prod/storefront/*" }, { "Effect": "Allow", "Action": "secretsmanager:ListSecrets", "Resource": "*" } ] } ``` `ListSecrets` cannot be resource-scoped and is only used by **Test connection**; drop it if you would rather find out at the first push. A customer-managed `--kms-key-id` additionally needs `kms:GenerateDataKey` and `kms:Decrypt` on that key. For Parameter Store, scoped to the path: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["ssm:PutParameter", "ssm:DeleteParameters", "ssm:GetParametersByPath"], "Resource": "arn:aws:ssm:us-east-1:111122223333:parameter/prod/storefront/*" }, { "Effect": "Allow", "Action": "kms:Encrypt", "Resource": "arn:aws:kms:…:key/…" } ] } ``` `SecureString` parameters are encrypted with KMS, so the key needs `kms:Encrypt` — the account's `aws/ssm` default key, or whichever one the binding names. ## 2. Add the connection ```bash printf '%s' "$AWS_SECRET_ACCESS_KEY" | seekrit sync connect \ --name acme-aws --provider aws-secrets-manager \ --region us-east-1 --access-key-id AKIAIOSFODNN7EXAMPLE ``` One connection covers one region. Syncing the same environment into two regions means two connections — which also keeps their key grants separate. There is no account id to supply: every endpoint is reached through the regional host and authorizes off the signature, so the account is whichever one the key belongs to. ## 3. Bind an environment ### Secrets Manager Choose how the secrets are laid out. The default writes one AWS secret per seekrit secret, under an optional prefix: ```bash seekrit sync verify acme-aws --provider aws-secrets-manager --path prod/storefront/ seekrit sync enable --connection acme-aws \ --provider aws-secrets-manager --path prod/storefront/ \ --app storefront --env production --acknowledge-decryption ``` The alternative packs every value into **one** secret as a JSON object — the shape an ECS task definition or Lambda reads with `secret-arn:json-key::`: ```bash seekrit sync enable --connection acme-aws \ --provider aws-secrets-manager --layout json-bundle \ --secret-name prod/storefront/env \ --app storefront --env production --acknowledge-decryption ``` Prefer the bundle when your runtime reads secrets as a group — Secrets Manager bills per secret per month, so fifty names cost fifty times as much stored separately. Prefer one-per-name when consumers read them individually or you want per-secret IAM. > **Note:** seekrit rewrites the bundle **whole** on every run, so it mirrors exactly what the binding resolves: a removed secret disappears from it, and so would a key you added by hand. `onDelete: retain` cannot apply to a single JSON value — if you need removals left in place, use the one-secret-per-name layout. ### Parameter Store Name the path every parameter lands directly under, so an application can read the whole environment with one `GetParametersByPath`: ```bash seekrit sync enable --connection acme-ssm \ --provider aws-parameter-store --path /prod/storefront/ \ --app storefront --env production --acknowledge-decryption ``` The path needs a leading **and** trailing slash, so the binding's names concatenate unambiguously — `/prod/storefront/` + `DB_URL`. AWS reserves `aws` and `ssm` as a first segment. Values are written as `SecureString` unless you pass `--param-type String`, and on the free `Standard` tier unless you pass `--tier` — `Standard` caps a value at 4KB, `Advanced` at 8KB and bills per parameter per month, and `Intelligent-Tiering` lets AWS upgrade only the parameters that need it. ## How a push behaves - **Neither connector lists before writing.** Secrets Manager has no bulk write, so a listing would add requests without removing any; `PutParameter` with `Overwrite` is already an upsert. Each secret is written optimistically and only falls back to `CreateSecret` on `ResourceNotFoundException` — one request per secret in the steady state, two the first time. - **Parameter Store batches deletions** ten at a time (`DeleteParameters`), the one place this API is kinder than Secrets Manager's. - **Secrets seekrit creates carry a description** marking them as managed, so the console says who owns them. A secret that already existed keeps its own. - **A run is capped at 400 requests**, and reports more work rather than silently syncing a subset. - **A credential or permission failure stops the run once.** `AccessDenied`, `UnrecognizedClientException`, `InvalidSignatureException`, `ExpiredToken` and their kin are facts about the connection, not about a name — repeating one beside fifty secrets would bury it. > **Note:** **Deletion on Secrets Manager is scheduled, not immediate.** seekrit takes AWS's 30-day default recovery window, so a mistaken removal is recoverable in the console. The consequence is that the name stays **reserved** for those 30 days and `CreateSecret` on it fails — so a secret that is deleted and then comes back (renamed away and back, an `exclude` glob edited twice) is *restored* rather than recreated. ## Troubleshooting | Symptom | Cause | Fix | | --- | --- | --- | | `AccessDeniedException`, whole run stopped | The IAM policy is missing an action, or its `Resource` doesn't cover the prefix or path the binding writes under | Compare the policy against the actions above. AWS reports this per request, so the run stops and says so once | | `UnrecognizedClientException` or `InvalidSignatureException` | The key pair is wrong or was deactivated | Rotating an AWS key changes the **id** as well as the secret, so a rotation means a new connection, not just a new credential | | `ExpiredTokenException` | Temporary STS credentials were used | Use a long-lived IAM user key — sync runs unattended | | `ThrottlingException`, run reported partial | AWS rate-limited the account | Nothing to do — seekrit backs off and retries the rest | | A new secret fails with `InvalidRequestException` about a scheduled deletion | The name is inside its 30-day recovery window | seekrit restores it automatically on the next run; force one with `seekrit sync run` | | `ParameterLimitExceeded` or a value rejected as too long | `Standard` tier caps a value at 4KB | Pass `--tier Advanced` (billed) or `Intelligent-Tiering` | | Bundle secret missing keys you added by hand | The bundle is rewritten whole every run | Use `--layout secret-per-name`, or keep hand-managed values in a different secret | ## See also - [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions - [AWS KMS drop-in](/docs/guides/kms-aws) — the other direction: KMS-compatible envelope crypto against seekrit - [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag --- # Sync to Render Render injects environment variables into the process it starts, and a service picks up a change on its next deploy. A binding writes one of two places: a single service's own variables, or a shared environment group that every linked service reads. | At a glance | | | --- | --- | | **What seekrit writes** | One service's environment variables, or an environment group's | | **Addressed by** | Service id (`srv-…`, or `crn-…` for a cron job) or environment group id (`evg-…`) | | **Connection carries** | The API key alone — there is no account or team id | | **Token permission** | An account API key. Render has no narrower scope | | **Takes effect** | Next deploy of the service. A group change starts a deploy of every linked service with autodeploy on | | **Value visibility** | Readable by anyone with access to the Render workspace | Both destinations are the same API with a different noun in the path, so they share one connection. Point as many bindings at it as you have services and groups. New to sync? Read [Third-party sync](/docs/guides/third-party-sync) first — the decryption grant, name mapping, deletions, and failure handling are the same on every destination. ## 1. Create an API key In Render, go to **Account Settings → API Keys** and create a key. > **Warning:** A Render API key carries the permissions of the user who created it, and Render has no narrower scope to grant — the key reaches every workspace that user can. Mint it from an account whose access you are willing to hand the connection, and name the connection after the workspace it is for. ```bash printf '%s' "$RENDER_API_KEY" | seekrit sync connect \ --name acme-render --provider render ``` There is no account or team id to supply: every Render endpoint seekrit calls names its resource by id, so the binding carries the whole address. ## 2. Bind an environment **A service** — pass the service ID from its dashboard URL (`srv-…`, or `crn-…` for a cron job): ```bash seekrit sync verify acme-render --provider render --service srv-abc123 seekrit sync enable --connection acme-render \ --provider render --service srv-abc123 \ --app storefront --env production --acknowledge-decryption ``` **An environment group** — pass the group ID (`evg-…`) instead: ```bash seekrit sync enable --connection acme-render \ --provider render --env-group evg-xyz789 \ --app storefront --env production --acknowledge-decryption ``` Pass one or the other; which flag you use is what picks the destination. seekrit refuses an `evg-` id in `--service` and a `srv-`/`crn-` id in `--env-group`, because pasting one into the other's field is otherwise a 404 hours later inside a run nobody is watching. > **Note:** **Render does not redeploy on an API-driven change.** A service picks up new values on its next deploy — start one from the Render dashboard if you need a value live immediately. An environment group goes the other way: changing one starts a deploy for every linked service that has autodeploy enabled. A group binding's blast radius is the link list, not one service. And a variable set directly on a service still **wins** over the same name coming from a group. ## How a push behaves - **One request per name**, `PUT …/env-vars/{key}`. seekrit deliberately does **not** use Render's bulk endpoint: `PUT /v1/services/{id}/env-vars` replaces a service's *entire* variable set, which would delete every variable the binding does not own — the ones a person set in the dashboard, and the ones the binding's own filters exclude — and would make `onDelete: retain` a lie. Addressing each key individually is the only version that touches exactly what the binding claims. - **That makes the request count the per-run ceiling.** A run is capped at 400 operations, so an environment larger than that syncs across several runs, back to back. - **Nothing reads a successful response body.** Render echoes values back — the env-group upsert returns the whole group in plaintext, and both list endpoints return values. seekrit parses no 2xx body at all, so those never materialize inside the sync engine; only an error body is read, and only for its message. - **seekrit never asks Render to generate a value.** The write is `{ value }`, never `{ generateValue: true }` — seekrit is the source of truth. ## Troubleshooting | Symptom | Cause | Fix | | --- | --- | --- | | `401` or `403` | The API key was revoked, or the user who created it lost access to that workspace | Render keys are per user, so someone leaving can break a connection. Mint a new key from an account with access | | `404` on the service or group | It was deleted | Render addresses these by id, so a **rename is safe** — only deletion breaks a binding | | An id is rejected when enabling | An `evg-` id was passed to `--service`, or a `srv-`/`crn-` id to `--env-group` | Use the flag that matches the id. Which flag you pass is what picks the destination | | Values pushed, service unchanged | Render does not redeploy on an API change | Start a deploy from the dashboard. seekrit does not trigger one | | A group change deployed more than you expected | Every linked service with autodeploy redeploys | That is the group's blast radius — bind a service directly if you want it narrower | | A value at the destination doesn't match the group | A service-level variable shadows the group's | Render's precedence, not seekrit's. Remove the service-level one | | Run reported partial on a big environment | The 400-operation budget | Nothing to do — the engine re-runs immediately until it drains | ## See also - [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions - [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag --- # Sync to Fly.io Fly injects an app's secrets into a Machine when it boots, so there is no earlier point at which seekrit could supply them. A binding owns one Fly app's secret set — the same slot `fly secrets set` writes. | At a glance | | | --- | --- | | **What seekrit writes** | An app's secrets, delivered to Machines as environment variables | | **Addressed by** | The Fly app name — one secret set per app, shared by every Machine in every region | | **Connection carries** | The token alone. No account, team, region, or token kind | | **Token permission** | A deploy token scoped to one app (`fly tokens create deploy`), or an org token | | **Takes effect** | Next Machine boot. Existing Machines keep their values until `fly secrets deploy` | | **Value visibility** | Write-only at Fly — `fly secrets list` shows names and digests, not values | New to sync? Read [Third-party sync](/docs/guides/third-party-sync) first — the decryption grant, name mapping, deletions, and failure handling are the same on every destination. ## 1. Create a token ```bash fly tokens create deploy -a storefront-production ``` That mints a token scoped to a single app, which is enough to write its secrets and the least-privilege choice when a connection serves one destination. `fly tokens org` covers every app in an organization — reach for it only when one connection genuinely serves many apps. Paste it whole, `FlyV1` prefix included. Fly's two token shapes travel under different auth schemes, and seekrit picks the right one by reading the token — so there is nothing else to configure here. No account, team, or region, and no token kind to state the way Railway needs one: ```bash printf '%s' "$FLY_API_TOKEN" | seekrit sync connect \ --name acme-fly --provider fly ``` ## 2. Bind an environment A Fly app has **one** secret set, shared by every Machine in every region, so the app name is the whole destination — there are no targets to pick. Fly's own convention is that staging and production are separate apps, and that is the split a binding maps onto: ```bash seekrit sync verify acme-fly --provider fly --fly-app storefront-production seekrit sync enable --connection acme-fly --provider fly \ --fly-app storefront-production \ --app storefront --env production --acknowledge-decryption ``` `--fly-app` is Fly's app; `--app` is the seekrit application the environment belongs to. They are different things, which is why the flag is not just `--app`. Verify fetches the app (`GET /v1/apps/{app}`) rather than reading its secrets — the same call `flyctl` makes on every deploy, so every token flavour that can push is known to be allowed it. A verify stricter than the push would reject setups that work. A token with read but not write access surfaces on the first push, in the run ledger, per name. > **Note:** **Pushed secrets are staged until Machines restart.** Fly injects secrets when a Machine boots, so a push updates the app's secret set while already running Machines keep the values they started with. Machines created after the push have them; existing ones pick them up on `fly secrets deploy -a storefront-production`, or on any deploy. seekrit will not roll your Machines for you. Rolling a Machine set is a *deployment* — a lease per Machine, each one's config resubmitted, health checks between batches — and a secrets push is the wrong place to be doing it. The failure mode is an app taken down to deliver an environment variable. A bare Machine restart is not a substitute either: it carries no secrets version, which is why `flyctl` itself does not use one here. ## How a push behaves - **One request for the whole environment.** `POST /v1/apps/{app}/secrets` takes a single `{ values: { NAME: value } }` map of up to 100 names, applied as a **merge**: a name with a value is set, a name with `null` is unset, and a name that is absent is left alone. - **Merge semantics are what make this safe** to point at an app that already has secrets. Fly's own `FLY_*` secrets, another binding's names, and anything you set by hand survive a push untouched, and a removal unsets just that name. - **Sets and removals travel separately**, so a rejected removal cannot report landed secrets as failures. - **A run is capped at 100 requests**, small on purpose: Fly's Machines API allows roughly **one request per second per action, bursting to three**. Since a normal environment is one or two calls, that ceiling only bounds something pathological. - **Fly never sees a value it can echo back.** Error text is built from Fly's own message fields, never from a request body. ## Troubleshooting | Symptom | Cause | Fix | | --- | --- | --- | | Rate-limited on **Test connection** | Fly allows about one request a second | Wait a moment and try again. seekrit tells this apart from a refusal, because the advice is the opposite | | `401` on every name | The token was revoked, or it is an app token for a different app | Mint a new token with `fly tokens create deploy -a ` | | `404` on the app | The app name is wrong, or the app was deleted | App names are lowercase letters, numbers, and dashes. `fly apps list` prints the exact one | | Verify passes, pushes fail | A read-scoped token — verify uses the read call `flyctl` makes on deploy | Use a deploy or org token | | Secrets pushed, app still on the old values | Fly injects at Machine boot | `fly secrets deploy -a `, or deploy. seekrit deliberately rolls nothing | | Run reported partial, `429` in the error | Fly rate-limited the token | Nothing to do — seekrit honors `Retry-After` and retries the rest | ## See also - [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions - [seekrit-run launcher](/docs/guides/run) — if you control the container's entrypoint, prefer this: no plaintext leaves your side - [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag --- # Sync to Northflank Northflank injects a secret group's values into the services and jobs that inherit it, at deploy time. A binding writes one secret group — the platform's unit of injection. | At a glance | | | --- | --- | | **What seekrit writes** | A secret group's `variables` map | | **Addressed by** | Project slug + secret group slug | | **Connection carries** | The token alone — a Northflank token names its own team | | **Token permission** | An API role granting **Secrets → Secret Groups**: read and update on the project | | **Takes effect** | Next deploy, or **restart dependents** on the group's page | | **Value visibility** | Readable by anyone with access to the project's secret groups | New to sync? Read [Third-party sync](/docs/guides/third-party-sync) first — the decryption grant, name mapping, deletions, and failure handling are the same on every destination. ## 1. Create an API token In Northflank, go to **Team settings → API tokens** and create one with a role granting **Secrets → Secret Groups** read and update on the project you are syncing into. Read is not optional here — see [why this connector reads first](#why-a-push-reads-the-group-first). A Northflank token is issued by exactly one team and carries that scope itself, so a Northflank connection has nothing else to configure: ```bash printf '%s' "$NORTHFLANK_API_TOKEN" | seekrit sync connect \ --name acme-northflank --provider northflank ``` ## 2. Bind an environment A binding writes one **secret group**, which the project's services and jobs inherit. Create the group in Northflank first (**Project → Secrets → Create secret group**), then name it and its project by the slug in their URLs, not by their display names: ```bash seekrit sync verify acme-northflank --provider northflank \ --project default-project --secret-group app-secrets seekrit sync enable --connection acme-northflank --provider northflank \ --project default-project --secret-group app-secrets \ --app storefront --env production --acknowledge-decryption ``` seekrit checks both against Northflank's own slug pattern before storing them, so the common slip — pasting the display name, spaces and all — fails at the form rather than as a bare 404 inside a run nobody is watching. Verify walks the project's secret-group **metadata** (names and ids, never values) to confirm the group exists and the token can see it. There is no environment field. Northflank has no per-group environment axis — separate environments are separate projects, or separate groups restricted to a stage — so a seekrit environment maps to a group, one to one. seekrit writes only the group's **variables**. Its priority, restrictions, secret type, secret files, and linked addons are yours: a push leaves them exactly as you configured them. > **Note:** Northflank injects a secret group's values at **deploy time**. A synced change reaches a running service on its next deploy, or when you use **restart dependents** on the group's page. seekrit does not restart anything on your behalf. ## Why a push reads the group first Northflank's variables map is **replace-semantics**: `PATCH` with `secrets.variables` sets the group's map to exactly what was sent, so a key absent from the payload is deleted. There is no per-variable endpoint and no merge flag — the whole map is the unit of writing. So a run reads the group's current variables and writes back `(existing − removed) ∪ seekrit`. Sending only seekrit's names would be one request instead of two, and would silently delete every variable in the group that seekrit does not manage — a hand-added `LOG_LEVEL`, another team's key. A secrets manager may fail a push; it may not quietly destroy the values beside it. > **Warning:** **That read is the one place a connector sees plaintext seekrit did not already hold**, so it is worth being precise about what it costs. The read uses `show=this`, which excludes secrets inherited from linked addons — database credentials and the like — so only the group's own variables are read. In the recommended setup, a group dedicated to this binding, every value read is one seekrit just decrypted anyway. The read only sees *foreign* values in exactly the case where not reading would destroy them, and they are used for one thing: writing them straight back, unchanged. Nothing is cached, logged, audited, or kept past the run. Give a binding a secret group of its own and this never arises — everything in it came from seekrit to begin with. ## Troubleshooting | Symptom | Cause | Fix | | --- | --- | --- | | `401` or `403`, whole run stopped | The token was revoked, or its role lacks Secret Groups read **or** update on this project | Both permissions are required — the read is not optional, see above | | `404` on the group, whole run stopped | The group doesn't exist, or the slug is wrong | Create it in Northflank first; seekrit will not. Take both slugs from the resource URL | | A slug is rejected when enabling | A display name was pasted instead of the URL slug | Slugs are lowercase, hyphen-separated, with no spaces | | A hand-added variable disappeared | Something wrote the group's map without merging | seekrit always merges. Check for another tool, or a `PATCH` from your own automation | | Values pushed, service unchanged | Northflank injects at deploy time | Deploy, or use **restart dependents** on the group | | Run reported partial, `429` in the error | Northflank rate-limited the token | Nothing to do — seekrit honors `Retry-After` and retries | ## See also - [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions - [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag --- # Sync to DigitalOcean App Platform holds environment variables in the app spec and hands them to your components when it deploys them, so there is no earlier point at which seekrit could inject them. A binding writes one app's variables — either the app-level set every component inherits, or one component's own. | At a glance | | | --- | --- | | **What seekrit writes** | An app's or component's `envs`, as App Platform **secrets** (`type: SECRET`) | | **Addressed by** | The app's UUID, optionally plus a component name | | **Connection carries** | The token alone — a DigitalOcean token belongs to one account | | **Token permission** | `app:read` + `app:update` (a full-access token has both) | | **Takes effect** | A new deployment, started by the push itself | | **Value visibility** | Encrypted at rest by DigitalOcean, shown as `EV[1:…]` in the app spec | New to sync? Read [Third-party sync](/docs/guides/third-party-sync) first — the decryption grant, name mapping, deletions, and failure handling are the same on every destination. ## 1. Create a personal access token In DigitalOcean, go to **API → Tokens** and create a personal access token with **`app:read`** and **`app:update`**. The token belongs to one account and carries that scope itself, so a DigitalOcean connection has nothing else to configure: ```bash printf '%s' "$DIGITALOCEAN_TOKEN" | seekrit sync connect \ --name acme-digitalocean --provider digitalocean ``` ## 2. Bind an environment A binding writes into one **App Platform** app, named by the UUID in its dashboard URL (`cloud.digitalocean.com/apps/`, or `doctl apps list`) — not by the app's name, which `GET /v2/apps/{id}` answers with a flat 404. Without `--component`, it writes the app-level variables that every component inherits: ```bash seekrit sync verify acme-digitalocean --provider digitalocean \ --do-app 4f6c71e2-1e90-4762-9fee-6cc4a0a9f2cf seekrit sync enable --connection acme-digitalocean --provider digitalocean \ --do-app 4f6c71e2-1e90-4762-9fee-6cc4a0a9f2cf \ --app storefront --env production --acknowledge-decryption ``` Pass `--component` to write one component's own variables instead. A component-level variable **overrides** an app-level one of the same name, which is App Platform's rule, not seekrit's: ```bash seekrit sync enable --connection acme-digitalocean --provider digitalocean \ --do-app 4f6c71e2-1e90-4762-9fee-6cc4a0a9f2cf --component api \ --app storefront --env api-production --acknowledge-decryption ``` A component is named as it appears in the app spec — `services`, `workers`, `jobs`, `static_sites`, and `functions` all qualify. Databases do not: a database component has no `envs` of its own, it *supplies* bindable values to the ones that do. ### Run time, build time, or both Values are written as App Platform **secrets** (`type: SECRET`), so DigitalOcean encrypts them at rest and shows them as `EV[1:…]` in the app spec rather than in the clear. By default they are scoped to **run time**, which keeps them out of build logs and buildpacks. That is deliberately narrower than DigitalOcean's own default of `RUN_AND_BUILD_TIME`: a build-time variable is visible to every build command, every buildpack, and anything they print. Pass `--env-scope BUILD_TIME` or `--env-scope RUN_AND_BUILD_TIME` for a value a build genuinely needs — a private registry token, a sourcemap upload key. > **Warning:** **A push deploys the app.** App Platform has no per-variable endpoint: environment variables live in the app spec, and the only way to change one is to submit a new spec, which starts a new deployment. That is true of the control panel and `doctl` too — it is not something seekrit adds. The deployment reuses each component's current commit or image digest, so it redeploys the code already running and never ships a newer build. But it is a real deployment: a build, a health check, and a rollout. Bind an environment here knowing that changing a secret in it will roll the app. ## How a push behaves **The whole app spec is the unit of writing.** `PUT /v2/apps/{id}` replaces the spec entirely — every component, route, database, domain, and alert in one document. So a run reads the current spec, changes exactly one `envs` array inside it, and sends the rest back byte for byte. That makes one rule absolute: **the spec is opaque.** seekrit parses it as an untyped document, mutates it at one path, and re-serializes — never mapping it onto a model of what an app spec contains. DigitalOcean's own public OpenAPI document is the reason: it omits `envs`, `alerts`, and `features`, all of which its Go client has and real apps use. Round-tripping through any hand-written shape would silently delete whatever that shape forgot, and the thing deleted would be a customer's production config. So your components, routes, databases, domains, and alerts survive a push, and so does any variable seekrit did not write — including another team's encrypted secrets, which pass through as opaque blobs. A removal drops just that name. > **Note:** **seekrit cannot tell a redundant push from a real one here.** Everything it writes goes in as `type: SECRET`, so App Platform hands back an opaque `EV[1:…]` blob on the next read. A connector holding plaintext and reading back ciphertext cannot compare the two, so a run with anything to push, pushes. This is a property of the platform, not of this connector — DigitalOcean's own Terraform provider has the same reported problem. If it matters, keep the binding's `include`/`exclude` narrow, so unrelated changes elsewhere in the environment don't trigger a deployment. ## Name rules App Platform accepts environment variable names matching `[_A-Za-z][_A-Za-z0-9]*` — which is seekrit's own rule too, so only a binding's **name transform** can produce a name that fails it. A name that breaks the rule is reported as a failure against that name alone; the rest of the environment still pushes. Watch for it if you use a `--prefix`. ## Troubleshooting | Symptom | Cause | Fix | | --- | --- | --- | | `401` or `403`, whole run stopped | The token lacks `app:update`, or was revoked | Both `app:read` and `app:update` are required | | `404` on the app, whole run stopped | The app's **name** was used instead of its UUID | Take the UUID from `cloud.digitalocean.com/apps/` or `doctl apps list` | | A UUID is rejected when enabling | Not a UUID — usually the app name | seekrit checks the shape up front, because the API answers a name with a flat 404 | | `404` mentioning the component | The component name isn't in the app spec, or is a database | Use the name as the spec writes it; databases have no `envs` | | The app redeployed when nothing changed | seekrit cannot compare encrypted values, so any run with something to push, pushes | Narrow the binding's `include`/`exclude` | | A build can't see a value | Values default to `RUN_TIME` | Pass `--env-scope BUILD_TIME` or `RUN_AND_BUILD_TIME` — and only for values a build genuinely needs | | A name failed but the rest landed | The mapped name isn't `[_A-Za-z][_A-Za-z0-9]*` | Fix the prefix or rename that produced it | ## See also - [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions - [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag --- # Sync to Heroku Heroku hands an app's config vars to every dyno as environment variables when it starts them, so there is no earlier point at which seekrit could inject them. A binding owns one Heroku app's config vars — the same slot `heroku config:set` writes. | At a glance | | | --- | --- | | **What seekrit writes** | An app's config vars, delivered to every dyno and process type | | **Addressed by** | The Heroku app name, or its UUID | | **Connection carries** | The token alone — Heroku app names are globally unique | | **Token permission** | A token whose user has the **operate** or **deploy** role on the app | | **Takes effect** | Immediately — a new release, and the dynos restart | | **Value visibility** | Readable by anyone with access to the app's config | New to sync? Read [Third-party sync](/docs/guides/third-party-sync) first — the decryption grant, name mapping, deletions, and failure handling are the same on every destination. ## 1. Create an API token ```bash heroku authorizations:create --short ``` Reach for that rather than `heroku auth:token`: the CLI's own token expires a year after you log in — or **eight hours** if your account uses SSO — and a sync connection built on it stops working overnight, long after you have forgotten where it came from. `authorizations:create` mints a token that does not expire. The token carries its user's access to every app and team they can reach, and Heroku app names are globally unique, so a Heroku connection has nothing else to configure: ```bash printf '%s' "$(heroku authorizations:create --short)" \ | seekrit sync connect --name acme-heroku --provider heroku ``` > **Note:** A Heroku token is only as narrow as the user who made it. There is no per-app API token the way Fly has one, so the least-privilege setup is a machine user added to just the apps it syncs, with the **deploy** or **operate** role. ## 2. Bind an environment A Heroku app has **one** set of config vars, shared by every dyno and every process type, so the app is the whole destination — there are no targets to pick. Heroku's own convention is that staging and production are separate apps, and that is the split a binding maps onto: ```bash seekrit sync verify acme-heroku --provider heroku --heroku-app storefront-production seekrit sync enable --connection acme-heroku --provider heroku \ --heroku-app storefront-production \ --app storefront --env production --acknowledge-decryption ``` `--heroku-app` is Heroku's app; `--app` is the seekrit application the environment belongs to. They are different things, which is why the flag is not just `--app`. You can pass the app's UUID instead of its name, and that is the sturdier choice — renaming an app in the Heroku dashboard breaks a binding that holds its name. seekrit checks a name against Heroku's own pattern before storing it, so the habitual slips (pasting `example.herokuapp.com`, or a name with capitals) fail at the form rather than as a 404 inside a run nobody is watching. Verify fetches the app rather than reading its config vars: that proves the token and that the app exists — every failure you can cause from the connection dialog — without pulling a single value into seekrit. > **Warning:** **Every push restarts the app.** Heroku applies config vars by cutting a new release and restarting the dynos — the same thing `heroku config:set` does. A sync run that writes anything will do that to `storefront-production`. seekrit keeps it to **one release per run**: a run's sets and removals travel in a single request, and a run with nothing to write sends no request at all. But if brief restarts are costly for this app, bind it in `manual` mode (`--mode manual`) and push with `seekrit sync run` when you choose, rather than on every write. ## How a push behaves - **Exactly one request per run.** `PATCH /apps/{app}/config-vars` carries the sets and the removals together in one flat map. No other connector does that — here a request is the expensive unit, because each one bounces the dynos. - **Merge semantics.** A name with a value is set, a name with `null` is removed, and a name that is absent is left alone. - **Add-on credentials survive.** That matters more here than anywhere else, because Heroku apps are full of config vars that are not yours: add-ons write `DATABASE_URL`, `REDIS_URL`, and their kin, **and rotate those values on their own schedule**. seekrit never sends a name it was not given, so add-on credentials, `HEROKU_*`, and anything you set by hand survive a push untouched. - **A rejected request is blamed on every name in it**, which is honest here: Heroku validates the whole map before applying it, so a rejected `PATCH` leaves the app exactly as it was and none of those names landed. - **Invalid names are failed without being sent.** Because the whole run is one request, a single unacceptable name would otherwise take the entire environment down with it. seekrit checks names against Heroku's rules first and records the offenders as per-name failures, leaving the rest to push normally. - **A value Heroku echoes back in an error is scrubbed** before the reason is stored on the run row. ## Name and value rules > **Note:** Heroku will not accept every name seekrit will. Config var names take only letters, numbers, and underscores, cannot begin with a digit or a double underscore, and cannot begin with `HEROKU_`, which Heroku reserves for itself. A name that breaks one of those rules is reported as a failure against that name alone — the rest of the environment still pushes — so watch for it if you use a `--prefix`. Heroku also caps an app's config vars at **64KB** across every key and value. seekrit checks its own payload against that before sending, but the app may already hold config vars seekrit does not manage, so passing that check is not a promise Heroku will accept it — only failing it is a certainty that Heroku would not. ## Troubleshooting | Symptom | Cause | Fix | | --- | --- | --- | | `401` on every name | The token expired or was revoked — likely `heroku auth:token`, which expires (8 hours under SSO) | Re-create the connection with `heroku authorizations:create --short` | | `403` | The token's user lacks the deploy or operate role on this app | Add the machine user to the app with the right role | | `404` on the app | Wrong name, a `.herokuapp.com` URL, or the app was renamed | Bind by UUID instead — a rename then can't break it | | One name failed, rest landed | The mapped name breaks Heroku's rules — a digit or `HEROKU_` at the front, a stray character | Fix the prefix or rename; seekrit refuses to send it rather than fail the run | | `422` about the app total | The app's config vars exceed 64KB across all keys and values | Trim, or move bulk config out of env vars | | Every name reported failed at once | The single `PATCH` was rejected | Nothing landed — Heroku validates before applying. Fix the cause and re-run | | Dynos restarting more than you'd like | Every write cuts a release | Bind with `--mode manual` and push with `seekrit sync run` | ## See also - [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions - [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag --- # Sync to Netlify Netlify reads a site's environment variables during a build and hands them to functions and edge functions at runtime — from its own copy, before anything of yours runs. A binding writes one site's variables, for the deploy contexts it names. | At a glance | | | --- | --- | | **What seekrit writes** | A site's environment variables, per deploy context | | **Addressed by** | The site's **API ID** (a UUID) + one or more contexts | | **Connection carries** | The token, and the **team** whose variables it writes | | **Token permission** | A personal access token whose user can change the team's environment variables | | **Takes effect** | Next build and deploy | | **Value visibility** | Write-only, when created as a Netlify secret (the default) | New to sync? Read [Third-party sync](/docs/guides/third-party-sync) first — the decryption grant, name mapping, deletions, and failure handling are the same on every destination. ## 1. Create a personal access token Netlify issues them under **User settings → Applications → Personal access tokens**. A token carries its user's access, so the least-privilege setup is a machine user in only the team it syncs. Unlike most connections here, a Netlify one has a second half to state: the **team**. Netlify keeps environment variables on the account, not the site — every endpoint is `/accounts/{account_id}/env`, with the site as a filter — and a personal access token belongs to a user who may sit in several teams, so it cannot say which one to write. Pass the slug from your dashboard URL (`app.netlify.com/teams/`), or the account ID; Netlify treats the two as interchangeable wherever `{account_id}` appears. ```bash printf '%s' "$NETLIFY_AUTH_TOKEN" \ | seekrit sync connect --name acme-netlify --provider netlify --account-id acme ``` ## 2. Bind an environment A Netlify value is keyed by **site and deploy context**, so a binding names both. `--target` takes the contexts — `production`, `deploy-preview`, `branch-deploy`, `branch`, or `dev` — and defaults to `production`: ```bash seekrit sync verify acme-netlify --provider netlify \ --netlify-site 3970e0fe-8564-4903-9a55-c5f8de49fb8b seekrit sync enable --connection acme-netlify --provider netlify \ --netlify-site 3970e0fe-8564-4903-9a55-c5f8de49fb8b \ --target production,deploy-preview \ --app storefront --env production --acknowledge-decryption ``` For one named branch, pass `--target branch` with `--git-branch staging`. A push writes **only** the contexts you list: the same variable's other contexts, and every variable this binding does not manage, are left exactly as they are — which is what makes it safe to point at a site that already has variables set by hand. Netlify's `all` context is deliberately not offered. A **secret** value must be set against explicit contexts, and Netlify's own endpoint is reported to fail outright on `context: "all"`. Naming the contexts you mean is what you want here anyway — a binding already exists to map one seekrit environment onto one deploy context. > **Warning:** **The site is named by its API ID, not its name.** Netlify shows it under Project configuration → General → Project information, and it is a UUID. seekrit refuses anything else on purpose. The environment variable endpoints take the site as a query parameter, where Netlify resolves no names — and a site it cannot resolve does not fail the request. The variables are created on the **team** instead, shared by every site in it. `seekrit sync verify` also checks that the site really belongs to the connection's team, which is the other half of that mistake. > **Note:** **Values are pushed as Netlify secrets.** Netlify's Secrets Controller makes a variable write-only: after it is set, the value cannot be read back through the UI, CLI, or API. seekrit creates variables that way by default, which is what you want for something a secrets manager owns. Three things follow from Netlify's rules. The flag can only be set when the variable is **created** — Netlify will not add it to one that already exists, and never removes it — so a name you already set by hand keeps whatever it has. A secret cannot carry the post-processing scope (a secret in snippet injection would be served to the browser), so seekrit sets `builds`, `functions`, and `runtime`. And values in the `dev` context stay readable by design, since local development needs them. If your plan does not include Secrets Controller, pass `--no-netlify-secret`. ## How a push behaves - **A create carries every context at once**; an update sets one context's value at a time. So a variable seekrit creates costs one request, and a change to an existing one costs a request per context it writes. - **A removal reads the variable's value ids first**, then drops the ones for the contexts this binding owns — or the whole variable when it owns them all. - **A run is capped at 400 operations**, sized against Netlify's rate limit of **500 requests per minute per user**, which binds well before the Worker subrequest cap and is shared with everything else that token does. - **A value Netlify echoes back in an error is scrubbed** before the reason is stored on the run row. Netlify binds environment variables at build time, so a pushed value reaches the running site on its **next deployment** — a sync run does not start one. ## Name and value rules > **Note:** Netlify will not accept every name seekrit will. Variable names take only letters, numbers, and underscores, must **start with a letter** — where seekrit also allows a leading underscore — and cannot begin with `NETLIFY_`, which Netlify reserves for itself. Keys stop at 255 characters and values at **5,000**. A name that breaks one of those is reported as a failure against that name alone; the rest of the environment still pushes. Watch for it if you use a `--prefix`. ## Troubleshooting | Symptom | Cause | Fix | | --- | --- | --- | | The site ID is rejected when enabling | A site name or `.netlify.app` address was pasted | Use the API ID (a UUID) from Project configuration → General | | Variables appeared on **every** site in the team | A site id Netlify could not resolve — the failure mode the strict check exists to prevent | Delete the team-level variables, then re-bind with the API ID | | Verify fails saying the site isn't in this team | The connection's `--account-id` is a different team | Re-create the connection with the team that owns the site | | `401` on every name | The token was revoked | Mint a new personal access token | | `403` | The token's user can't change that team's environment variables | Give the machine user the right team role | | A create fails mentioning secrets or scopes | The plan has no Secrets Controller | Pass `--no-netlify-secret` to create readable variables | | A variable set by hand is still readable | The secret flag only applies at creation, and Netlify won't add it later | Delete it at Netlify and let the next sync recreate it | | One name failed, rest landed | The mapped name starts with a digit or underscore, begins `NETLIFY_`, or the value is over 5,000 characters | Fix the prefix or rename | | Values pushed, site unchanged | Netlify binds at build time | Trigger a deploy. seekrit does not start builds | ## See also - [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions - [Branch configs](/docs/guides/branches) — per-PR environments that pair with deploy previews - [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag --- # Sync to Bunnyshell Bunnyshell interpolates variables while it builds an environment, and hands the result to the containers it starts. A binding writes one of two collections: an environment's variables, or a project's — the latter reaching environments that do not exist yet. | At a glance | | | --- | --- | | **What seekrit writes** | An environment's variables, or a project's | | **Addressed by** | A Bunnyshell environment ID or project ID | | **Connection carries** | The token alone — both IDs are globally unique | | **Token permission** | A token whose user can change variables in the organization | | **Takes effect** | Next deployment of the environment | | **Value visibility** | Hidden in the dashboard when marked secret (the default); encrypted at rest either way | New to sync? Read [Third-party sync](/docs/guides/third-party-sync) first — the decryption grant, name mapping, deletions, and failure handling are the same on every destination. ## 1. Create an access token Bunnyshell issues one at **environments.bunnyshell.com/access-token** — the same token `bns configure` saves. It carries its user's access to every organization they belong to, so the least-privilege setup is a machine user in only the organization it syncs. There is no second half to state. A Bunnyshell connection carries nothing beyond the provider: both variable collections name their parent by a globally unique ID, so the token plus the destination is the whole address. ```bash printf '%s' "$BUNNYSHELL_TOKEN" \ | seekrit sync connect --name acme-bunnyshell --provider bunnyshell ``` ## 2. Bind an environment A binding writes one of two scopes. `--bunnyshell-environment` names an environment, whose variables every component in it inherits: ```bash seekrit sync verify acme-bunnyshell --provider bunnyshell \ --bunnyshell-environment env-9f3a2b seekrit sync enable --connection acme-bunnyshell --provider bunnyshell \ --bunnyshell-environment env-9f3a2b \ --app storefront --env production --acknowledge-decryption ``` `--project` names a project instead, and is the one that reaches an environment which **does not exist yet** — every environment created in the project from then on inherits its variables, which is how you seed the ephemeral environments a webhook will spin up per branch or per pull request: ```bash seekrit sync enable --connection acme-bunnyshell --provider bunnyshell \ --project prj-4c8d1e \ --app storefront --env preview --acknowledge-decryption ``` Pass one or the other, never both. Get either ID from `bns environments list` / `bns projects list`, or from the dashboard URL. > **Warning:** **A project binding's blast radius is the project, not one environment.** Every environment created in it afterwards inherits these values, and there may be many. An existing environment is not touched — it keeps whatever it already has, and an environment-scoped value always beats the project's, so a project binding does not fight an environment binding pointed at the same name. > **Note:** **Values are marked secret.** Bunnyshell's `isSecret` hides a value in the dashboard and keeps it encrypted inside an exported environment definition. seekrit sets it by default. It is a weaker guarantee than Netlify's flag of the same name: Bunnyshell encrypts *every* variable with an organization key whether or not the flag is set, so this is about who can read it, not whether it is stored in the clear. The flag is only sent when seekrit **creates** a variable — an update never mentions it, so one you deliberately un-secreted stays that way. Pass `--no-bunnyshell-secret` to create dashboard-visible variables instead. ## How a push behaves - **There is no bulk write and no upsert here.** A run pages through the collection to find which names already exist, then creates or updates each one individually. That makes Bunnyshell the destination where the per-run budget (400 requests) binds hardest. - **A write is skipped when nothing would change.** Bunnyshell's listing discloses current values, and seekrit uses that for exactly one thing — skipping a redundant write. Those values are never stored, logged, or audited. - **Every variable's parent is re-checked before it is touched.** Bunnyshell IDs are opaque strings with no documented format, so seekrit cannot validate them by shape the way it validates a Netlify site ID. Instead the connector confirms each listed variable really belongs to this binding's environment or project before editing it — a filter that failed to bite cannot turn into an edit of a neighbouring environment's variables. A create names its parent in the request body as a required relation, so an unresolvable ID is a `422` naming the field, not a write that lands somewhere broader. - **A truncated listing fails the run loudly** rather than making a deletion look already done. - **A value Bunnyshell echoes back in an error is scrubbed** before the reason is stored on the run row. Bunnyshell interpolates variables while it builds, so a pushed value reaches the workload on the environment's **next deployment**. seekrit starts none: a secret rotation should not restart someone's environment as a side effect. ## Name rules > **Note:** Bunnyshell will not accept every name seekrit will. Variable names take letters, numbers, underscores, dashes, and dots, cannot begin with a digit, cannot begin with `BNS_`, which Bunnyshell reserves for the variables it injects itself, and must be at least **3** characters and at most **255**. A name that breaks one of those is reported as a failure against that name alone; the rest of the environment still pushes. Watch for the length floor and the leading digit if you use a `--prefix`. ## Troubleshooting | Symptom | Cause | Fix | | --- | --- | --- | | `401` or `403`, whole run stopped | The token was revoked, or its user can't change variables in that organization | Mint a new token from a user with the right role | | `422` naming `environment` or `project` | The ID doesn't resolve | Check it against `bns environments list` / `bns projects list`. A create names its parent as a required relation, so this fails safely rather than writing elsewhere | | Both scope flags rejected | `--bunnyshell-environment` and `--project` were both passed | A binding writes one collection; pass one | | A new environment didn't get the values | It was created before the project binding, or the binding is environment-scoped | Project variables are inherited at creation only. Bind the project, and existing environments keep what they have | | A value looks wrong in an environment | An environment-scoped value shadows the project's | That is Bunnyshell's precedence. Remove the environment-level one | | A variable is visible in the dashboard | It already existed when seekrit first wrote it — the secret flag is only sent at creation | Delete it in Bunnyshell and let the next sync recreate it | | One name failed, rest landed | The mapped name is under 3 characters, starts with a digit, or begins `BNS_` | Fix the prefix or rename | | Run reported partial on a big environment | The 400-request budget, which binds hard here | Nothing to do — the engine re-runs immediately until it drains | ## See also - [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions - [Branch configs](/docs/guides/branches) — per-PR environments that pair with ephemeral Bunnyshell environments - [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag --- # Sync to GitHub Actions > **Warning:** **Read this before setting one up: you probably want the Action instead.** GitHub Actions is a runtime seekrit *can* reach into, which makes it the one destination here where a strictly better option already exists. The published [`seekritdev/github-action`](https://github.com/seekritdev/github-action) resolves and decrypts **inside your workflow run**: ```yaml - uses: seekritdev/github-action@v1 with: token: ${{ secrets.SEEKRIT_TOKEN }} app: storefront env: production ``` Nothing is copied to GitHub, nothing is stored there, a rotated value is picked up on the next run with no re-sync, and seekrit's servers never decrypt anything. Syncing gives all three of those up. Sync is the right answer only where the Action cannot reach — see [when to sync anyway](#when-to-sync-anyway) below. | At a glance | | | --- | --- | | **What seekrit writes** | Actions secrets, at repository, deployment environment, or organization scope | | **Addressed by** | `owner/repo`, plus an environment name — or an organization login and a visibility | | **Connection carries** | The token, and a base URL for GitHub Enterprise Server only | | **Token permission** | Fine-grained **Secrets** repository permission (write), or the organization **Secrets** permission | | **Takes effect** | Next workflow run | | **Value visibility** | Write-only — GitHub never discloses a secret value again | ## When to sync anyway GitHub resolves some things before any step of yours could run, so no Action can supply them. Those are the cases this connector is for: - **A third-party action that takes a credential as a `with:` input** — the value is interpolated from the `secrets` context when the step is created. - **`secrets: inherit`** into a reusable workflow, which passes the calling workflow's `secrets` context wholesale. - **Job-level `container:` and `services:` credentials**, resolved before the job's first step. - **Dependabot and Codespaces**, which read their own secret stores and run no workflow you control. (Those are separate GitHub APIs; this connector writes Actions secrets only.) If your situation is not one of these, use the Action. ## 1. Create a token Prefer a **fine-grained** personal access token with the **Secrets** repository permission set to *Read and write*, scoped to only the repositories this connection syncs. For organization secrets, it needs the organization **Secrets** permission instead. A classic token needs `repo` — or `admin:org` for organization secrets — which grants vastly more than writing secrets. Use fine-grained where you can. ```bash printf '%s' "$GITHUB_TOKEN" \ | seekrit sync connect --name acme-github --provider github-actions ``` A github.com connection has no second half to state: a GitHub token addresses everything by `owner/repo` or `org`, and those are the binding's business. For a self-hosted **GitHub Enterprise Server** appliance, add `--base-url https://github.acme.com/api/v3`. Leave it off for github.com and for Enterprise Cloud. It must be `https` — that URL carries the token. ## 2. Bind an environment GitHub has three secret scopes, and a binding picks one. They are three different endpoints with three different blast radii — and each has its **own** encryption key, so a value sealed for one cannot be written to another. ### A repository's secrets Readable by every workflow in it, including one added by a pull request from a collaborator with write access. That is GitHub's model, not seekrit's — and it is the reason to prefer the environment scope for anything touching production. ```bash seekrit sync verify acme-github --provider github-actions \ --gh-repo acme/storefront seekrit sync enable --connection acme-github --provider github-actions \ --gh-repo acme/storefront \ --app storefront --env production --acknowledge-decryption ``` `--gh-repo` takes the pair as one flag, because that is how GitHub writes a repository everywhere; asking for it in two invites pasting the pair into one of them. ### A deployment environment's secrets The narrowest scope GitHub has, and the one to prefer: ```bash seekrit sync enable --connection acme-github --provider github-actions \ --gh-repo acme/storefront --gh-environment production \ --app storefront --env production --acknowledge-decryption ``` A job reads these only by declaring `environment: production`, which also subjects it to that environment's protection rules — required reviewers, wait timers, and the branch policy. That combination is as close as GitHub gets to "this secret is for production, and reaching it takes an approval". The environment must already exist. seekrit will not create one: an environment is a deployment gate, and silently creating an unprotected one because a name was misspelled would quietly remove the protection you were relying on. ### An organization's secrets The widest scope in the product, and the only destination on any provider that can hand a value to repositories nobody named. ```bash seekrit sync enable --connection acme-github --provider github-actions \ --gh-org acme --gh-visibility selected --gh-repo-ids 1296269,1296270 \ --app storefront --env production --acknowledge-decryption ``` > **Warning:** **`--gh-visibility all` hands the value to every repository in the organization** — including ones added tomorrow, and public ones. seekrit deliberately has no default here and defaults the CLI to `private`; state what you mean. `selected` takes numeric repository **IDs**, not names, because that is what GitHub's API takes. Get one with `gh api repos/acme/storefront --jq .id`. seekrit does not resolve names to IDs on your behalf: guessing which repository an ambiguous name meant would widen a secret's reach silently. Passing IDs with any other visibility is refused rather than ignored, so a stored binding never contradicts its own behavior. ## How a push behaves - **The value is encrypted before it leaves seekrit.** GitHub does not accept a plaintext secret: each value is sealed to the scope's own X25519 public key as a libsodium sealed box, and GitHub decrypts it on receipt. One nice consequence: GitHub never sees a plaintext value at all, so it cannot echo one back into an error message — the failure reasons stored on a run row cannot carry a secret value even in principle. Other connectors scrub responses for exactly that reason; here there is nothing to scrub. - **Each scope has its own key, fetched once per run.** A value sealed for a repository cannot be written to that repository's `production` environment, or to its organization — the three are different recipients. So a run is one `GET` for the key, then one `PUT` per name. - **A run is capped at 150 operations**, sized against GitHub's *secondary* rate limit rather than its primary one: 900 points a minute, where a `GET` costs 1 and a `PUT` or `DELETE` costs **5**. That is 180 mutating requests a minute, well below the 5,000-an-hour primary limit. - **seekrit cannot detect value drift here, only presence.** Actions secrets are write-only: once set, no one can read a value back through the UI, CLI, or API — which is what you want for something a secrets manager owns. Values take effect on the **next workflow run**; a sync run starts nothing. ## Name and value rules > **Note:** **Secret names are case-insensitive at GitHub**, which no other destination here is. `db_url` and `DB_URL` are one secret, so a name mapping that produces both would write it twice and report both as pushed while one value silently won. seekrit detects that before it writes anything and fails **every** name in the collision, naming them — it cannot know which one you meant. Watch for it if the binding folds case. Rename or filter all but one. > **Note:** GitHub will not accept every name seekrit will. Secret names take only letters, numbers, and underscores, must start with a letter or an underscore, and cannot begin with `GITHUB_`, which GitHub reserves for itself. Values stop at **48 KB**, checked against the plaintext — the number you can act on — and before sealing, so an over-size body is never built to be refused. A name that breaks one of those is reported as a failure against that name alone; the rest of the environment still pushes. Watch for it if you use a `--prefix`. ## Troubleshooting | Symptom | Cause | Fix | | --- | --- | --- | | `403` on every name | The token lacks the Secrets permission for that scope, or the fine-grained token doesn't include this repository | Repository, environment, and organization secrets are three permissions — check the one the binding writes | | `404` on the environment | The deployment environment doesn't exist | Create it in Settings → Environments. seekrit will not create one, because that would silently drop its protection rules | | `404` on the repository with a valid token | A fine-grained token that does not list this repository | Add it to the token's repository access | | A whole set of names failed as a collision | Two mapped names differ only by case | GitHub is case-insensitive; rename or filter all but one | | One name failed, rest landed | The mapped name starts with a digit, begins `GITHUB_`, or the value is over 48 KB | Fix the prefix or rename | | `422` on an org write | `--gh-visibility selected` with no repository IDs, or IDs given for another visibility | Pass numeric IDs with `selected`, and none otherwise | | Rate-limited, run reported partial | GitHub's secondary rate limit — mutating calls cost 5 points each | Nothing to do — seekrit backs off and retries the rest | | A rotated value isn't picked up | Actions read GitHub's copy at run start | The next workflow run has it. Or drop sync and use the Action, which needs no re-sync at all | ## See also - [`seekritdev/github-action`](https://github.com/seekritdev/github-action) — the option to prefer - [CI/CD & containers](/docs/guides/ci-cd) — wiring seekrit into pipelines without a copy at the destination - [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions --- # Sync to Google Secret Manager Sync to Secret Manager when something in your project already reads from it: a Cloud Run `--set-secrets` mount, the GKE Secret Manager CSI driver, a Terraform data source, or a team convention you are not going to change. Where you control the process instead, [`seekrit run`](/docs/guides/run) or [an SDK](/docs/guides/sdks) keeps decryption on your side. | At a glance | | | --- | --- | | **What seekrit writes** | One secret per name, or all of them as one JSON secret | | **Addressed by** | An optional ID prefix, or the one bundle secret's ID | | **Connection carries** | The service-account key JSON, and the project to write | | **IAM** | `roles/secretmanager.admin` on the project — never `versions.access` | | **Takes effect** | Next read by your app | | **Deletion** | Immediate and permanent — there is no recovery window | New to sync? Read [Third-party sync](/docs/guides/third-party-sync) first — the decryption grant, name mapping, deletions, and failure handling are the same on every destination. ## 1. Create a service account and a key seekrit authenticates as a service account, so make one for this and give it access to nothing else: ```bash gcloud iam service-accounts create seekrit-sync \ --display-name="seekrit sync" --project acme-prod gcloud projects add-iam-policy-binding acme-prod \ --member="serviceAccount:seekrit-sync@acme-prod.iam.gserviceaccount.com" \ --role="roles/secretmanager.admin" gcloud services enable secretmanager.googleapis.com --project acme-prod gcloud iam service-accounts keys create key.json \ --iam-account=seekrit-sync@acme-prod.iam.gserviceaccount.com ``` `roles/secretmanager.admin` is the convenient answer. The permissions actually used are narrower — `secretmanager.secrets.get`, `create`, `update`, `delete`, `versions.add`, plus `versions.destroy` if you turn on version pruning and `secrets.list` for **Test connection** — so a custom role holding just those works too. You can go tighter still and grant the role on individual secrets rather than on the project, but know what that costs: Google answers a request for a resource you have no access to with `PERMISSION_DENIED` whether or not it exists, and seekrit reads a secret's metadata as its first step. So a name the binding resolves that has no grant of its own — a secret you add to the environment later — fails the **whole run** with a permission error instead of being created. Project-level is the setup that keeps a new secret working without a second deploy. > **Note:** **Nothing here needs `secretmanager.versions.access`.** seekrit writes values and never reads one back, so the credential you hand it cannot be used to read your project's secrets. That is deliberate — it is why the connector compares a digest instead of comparing values. ## 2. Add the connection The credential is the whole key file, so pipe it in: ```bash seekrit sync connect --name acme-gcp --provider gcp-secret-manager \ --project-id acme-prod < key.json ``` One connection covers one project. The key JSON says which service account it is; the project is stated separately because a service account can be granted secrets in projects other than its own. `--project-id` takes the project ID (`acme-prod`) or its number — both work, and the ID is the one you can read off your own dashboard. Delete `key.json` afterwards — seekrit holds it encrypted to the connection's public key, and nothing else needs it. ## 3. Bind an environment The default writes one GCP secret per seekrit secret, under an optional prefix: ```bash seekrit sync verify acme-gcp --provider gcp-secret-manager seekrit sync enable --connection acme-gcp --provider gcp-secret-manager \ --gcp-prefix prod-storefront- \ --app storefront --env production --acknowledge-decryption ``` The alternative packs every value into **one** secret as a JSON object, which an app reads and parses at boot: ```bash seekrit sync enable --connection acme-gcp --provider gcp-secret-manager \ --layout json-bundle --secret-name prod-storefront-env \ --app storefront --env production --acknowledge-decryption ``` Prefer the bundle when your runtime reads secrets as a group — Secret Manager bills per *active version*, so fifty names cost fifty times as much stored separately. Prefer one-per-name when consumers mount them individually (`gcloud run deploy --set-secrets`, the GKE CSI driver) or you want per-secret IAM. > **Note:** **A prefix here is not a path.** A Secret Manager ID takes letters, digits, hyphens, and underscores — no slashes and no dots — so the namespace is spelled `prod-storefront-DB_URL`, not `prod/storefront/DB_URL`. A name that would break that rule is reported as a failure against that name alone; the rest of the environment still pushes. IDs stop at 255 characters, and values at **64 KiB**. ## Versions, and what they cost Secret Manager has no "set the value" call — `addVersion` appends, and every active version is billed for as long as it stays active. A sync run pushes the whole environment rather than a diff, so writing unconditionally would leave a new version on all fifty secrets every time one of them changed. seekrit therefore **writes a version only when the value changed**. It reads the secret's metadata first and compares a keyed digest it keeps in the secret's own annotations (`seekrit-digest`), so an unchanged secret costs one read and no version. The digest is an HMAC keyed by your service-account key, not a plain hash of the value — metadata is readable by anyone with `secretmanager.secrets.get`, and a bare hash of a short value would be a guessing oracle. The digest is written **after** the version it describes lands, never before. The other order would risk recording a digest for a value that failed to write, which would make the next run skip a secret that isn't there — silence being the one failure mode a secrets manager must not have. This order's worst case is one duplicate version. Two consequences worth knowing: - **Rotating the service-account key changes every digest**, so the run after a rotation writes one new version per secret. Nothing after it does. - **A version you add by hand stays live** until seekrit's own copy of that value changes. seekrit compares its own record, not your project's contents; it never reads a value back. `--gcp-prune-versions` closes the loop on the bill: with it on, the version each push supersedes is destroyed as soon as the new one lands, so a secret keeps exactly one active version. > **Warning:** **Both destructive paths here are permanent.** Secret Manager has no recovery window like AWS's 30-day scheduled deletion: removing a secret from the environment (with `onDelete: delete`) deletes the GCP secret and every version of it immediately, and `--gcp-prune-versions` destroys the superseded version outright. Pruning only ever touches a version seekrit wrote and recorded itself — never one added by anyone else — but it does mean you cannot roll a value back inside GCP. Roll it back in seekrit instead, which is the source of truth, and the next run pushes it. ## Replication and CMEK Replication is set when a secret is **created** and cannot be changed afterwards. `automatic` lets Google choose the locations and bills one replica; `--gcp-replication user-managed --gcp-locations us-east1,europe-west4` names the regions yourself, which is how data residency is stated, and bills each one. Changing your mind means deleting the secret and letting the next run recreate it. `--gcp-kms-key` encrypts with a customer-managed key, as its full resource name (`projects/…/locations/…/keyRings/…/cryptoKeys/…` — the only form the API accepts). KMS keys are regional and Secret Manager sets the key per replica, so seekrit accepts one key with `automatic` replication (where it must be a `global` key) or with a single location — several regions would each need their own, which a binding has no way to express. A binding that asks for both is refused when you create it rather than at the first run. Secrets seekrit creates are labelled `managed-by: seekrit`, so `gcloud secrets list --filter="labels.managed-by=seekrit"` finds them. A secret that already existed keeps its own labels. > **Note:** **Global secrets only.** seekrit writes through `secretmanager.googleapis.com`, not the per-location `secretmanager..rep.googleapis.com` endpoints that GCP's *regional* secrets live behind. Use user-managed replication for residency. ## How a push behaves - **One read per secret, then a write only if the value changed.** An unchanged environment costs one `GET` per name and nothing else. - **A run is capped at 400 requests**, sized against Secret Manager's quota of 600 write requests a minute **per project** — shared with everything else in it — rather than the Worker subrequest cap. - **A `RESOURCE_EXHAUSTED` waits for the quota window to roll**, not the few seconds a token bucket needs. Any `Retry-After` Google sends wins, clamped. ## Troubleshooting | Symptom | Cause | Fix | | --- | --- | --- | | `PERMISSION_DENIED` on the whole run | The service account lacks a permission, or the role was granted per secret and a new name has no grant | Grant `roles/secretmanager.admin` at project level — see above for why per-secret grants break new names | | `PERMISSION_DENIED` naming an API | Secret Manager isn't enabled on the project | `gcloud services enable secretmanager.googleapis.com` | | Every secret got a new version in one run | The service-account key was rotated, so every digest changed | Expected once. Nothing after that run rewrites unchanged values | | A hand-added version is still live | seekrit compares its own record, not the project's contents | Change the value in seekrit — it is the source of truth — and the next run supersedes it | | A deleted secret is gone for good | Secret Manager has no recovery window | Use `--on-delete retain` if you would rather clean up by hand | | One name failed, rest landed | The mapped ID contains a slash or dot, is over 255 characters, or the value is over 64 KiB | A prefix here is not a path — use hyphens | | Enabling refused a KMS key | A customer-managed key with several user-managed locations | KMS keys are regional. Use automatic replication, or one location | | `RESOURCE_EXHAUSTED`, run reported partial | The project's per-minute write quota, shared with everything else in it | Nothing to do — seekrit waits for the window and retries | ## See also - [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions - [Managed keys (KMS)](/docs/guides/kms) — seekrit's own client-side managed keys - [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag --- # Sync to LangGraph Platform LangGraph Platform runs your Agent Server for you, which means there is no container to inject into: the deployment's **secrets** are its environment, and the control plane is the only way to set them. A binding owns one deployment's secrets. | At a glance | | | --- | --- | | **What seekrit writes** | A deployment's `secrets`, delivered to the agent container as environment variables | | **Addressed by** | The deployment UUID | | **Connection carries** | The region (or a self-hosted control-plane URL), and a workspace ID for org-scoped keys | | **Token permission** | A LangSmith API key that can update the deployment | | **Takes effect** | On the **new revision** the write creates — a rebuild and rollout | | **Value visibility** | Readable through the control plane by anyone with a key for the workspace | New to sync? Read [Third-party sync](/docs/guides/third-party-sync) first — the decryption grant, name mapping, deletions, and failure handling are the same on every destination. > **Warning:** **A push redeploys the agent.** LangGraph Platform applies a secret change by creating a new revision, and its control plane offers no way to stage a value without shipping it. In-flight runs are interrupted. seekrit keeps that to the minimum it can: it reads the deployment first and **sends nothing when nothing would change**, so the periodic reconcile never rolls your agent on its own. But a rotation does. If the agent runs somewhere you control the process instead, [`seekrit run`](/docs/guides/run) injects the same values with no copy at the platform and no redeploy. ## 1. Create a LangSmith API key In LangSmith, **Settings → API keys → Create API key**. Prefer a **workspace-scoped** key (`lsv2_pt_…`): it names its own workspace, so the connection needs nothing else. An **organization-scoped** key reaches every workspace in the org, and without a workspace ID the control plane refuses it with a bare `403`. ```bash printf '%s' "$LANGSMITH_API_KEY" \ | seekrit sync connect --name acme-langgraph --provider langgraph-platform ``` That is the whole connection for a US account. Two things can change it: ```bash # An account in another region — chosen at signup and fixed afterwards. printf '%s' "$LANGSMITH_API_KEY" \ | seekrit sync connect --name acme-langgraph --provider langgraph-platform \ --langgraph-region eu # An organization-scoped key, which has to say which workspace. printf '%s' "$LANGSMITH_API_KEY" \ | seekrit sync connect --name acme-langgraph --provider langgraph-platform \ --langgraph-tenant 8f1c2b3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d ``` The region matters more than it looks. LangChain runs the control plane on four hosts, a key minted in one is **not accepted by another**, and the failure is a plain `401` — so the wrong region here looks exactly like a bad credential: | `--langgraph-region` | Control-plane host | | --- | --- | | `us` (default) | `https://api.host.langchain.com` | | `eu` | `https://eu.api.host.langchain.com` | | `apac` | `https://apac.api.host.langchain.com` | | `aws-us` | `https://aws.api.host.langchain.com` | An account created at `smith.langchain.com` is `us`. > **Note:** **Self-hosted LangSmith.** Pass `--base-url` instead of `--langgraph-region`, pointing at the control plane on your own host — it is served under `/api-host`, e.g. `https://langsmith.acme.com/api-host`. It must be `https:`, because that address carries the API key. Setting both a region and a base URL is refused rather than resolved in seekrit's favour. ## 2. Bind an environment A deployment has **one** set of secrets, so the deployment is the whole destination — there is no per-revision or per-graph scope, and no equivalent of Vercel's production/preview split. A deployment that needs different values is a different binding. ```bash seekrit sync verify acme-langgraph --provider langgraph-platform \ --langgraph-deployment 3970e0fe-8564-4903-9a55-c5f8de49fb8b seekrit sync enable --connection acme-langgraph --provider langgraph-platform \ --langgraph-deployment 3970e0fe-8564-4903-9a55-c5f8de49fb8b \ --app storefront-agent --env production --acknowledge-decryption ``` `--langgraph-deployment` is the deployment UUID from its dashboard URL, or the `id` from `GET /v2/deployments`; `--app` is the seekrit application the environment belongs to. Verify checks the deployment through its **revision list** rather than by fetching the deployment. Both prove the same three things you can get wrong from the connection dialog — key valid, workspace reachable, deployment real — but a revision carries only ids, timestamps, status and source, never a value. A deployment whose first revision is still building answers with an empty list, which passes: the deployment is there, and that is the question. ## How a push behaves - **It reads before it writes.** The array sent is the array the deployment ends up with, so seekrit reads the current secrets and writes back `(existing − removed) ∪ seekrit`. Sending only seekrit's names would delete every environment variable on the deployment that seekrit does not manage. - **Values you set by hand survive.** A `LOG_LEVEL` added in the dashboard, or another team's key, is carried through a push exactly as it was read. - **A run that would change nothing sends nothing.** This is the one that matters here: without it, the reconcile timer would build a new revision of your agent every few minutes forever. - **Only `secrets` is ever sent.** A `PATCH` carrying `source_config`, `source_revision_config` or `revision_source` would decide which *code* the new revision builds. Omitting them lets the control plane fall back to the deployment's own source, so a secrets push can never be the thing that ships new application code. `secret_references` — Kubernetes Secret references on self-hosted installs — is never sent either. - **A rejected write is blamed on every name in it**, which is honest here: the control plane validates the array before applying it, so a rejected `PATCH` leaves the deployment exactly as it was and nothing landed. - **A value the control plane echoes back in an error is scrubbed** before the reason is stored on the run row — including a foreign value the array carried through. ## Name and value rules > **Note:** LangGraph Platform will not accept every name seekrit will. It **reserves** the names it sets itself — `LANGSMITH_API_KEY`, `LANGCHAIN_PROJECT`, `POSTGRES_URI`, `REDIS_URI`, `PATH`, `PORT` and around thirty more. seekrit skips a reserved name rather than sending it, and reports it as a failure against that name alone, because the whole run is one request: one unacceptable name would otherwise take the entire environment down with it. Watch for it if you use a `--prefix`. A secret with an **empty value** is failed the same way. The control plane drops one rather than storing it, so a sent empty does not come back on the next read — and seekrit's "nothing changed" check would never agree, which would rebuild your agent on every reconcile tick for as long as the binding existed. Failing the one name is the cheap end of that trade. ## Troubleshooting | Symptom | Cause | Fix | | --- | --- | --- | | `401` on every name | The key was revoked — or the connection names the wrong region, which fails identically | Check the region against the table above, then re-create the connection | | `403` | An organization-scoped key with no workspace ID, or a key without update permission | Add `--langgraph-tenant`, or use a workspace-scoped key that can update the deployment | | `404` on the deployment | The deployment name in the ID slot, or the deployment was deleted | Use the UUID from the dashboard URL | | One name failed, rest landed | The mapped name is reserved by LangGraph, or its value is empty | Rename it (or fix the prefix); seekrit refuses to send it rather than fail the run | | Every name reported failed at once | The single `PATCH` was rejected | Nothing landed — the control plane validates before applying. Fix the cause and re-run | | The agent redeploys on every sync | A value really is changing every run — an interpolated reference, or a rotation schedule | Check the run ledger for which name; a stable environment sends no request at all | | A run fails with "refusing to write" | The control plane returned masked secret values, so writing them back would overwrite real ones | Please report it — the connector needs updating, and it fails loudly rather than destroying your environment | ## See also - [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions - [Agent sandboxes](/docs/guides/sandboxes) — the other half of agent hosting, where you *do* control the process - [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag --- # Self-hosted provisioner Temporary-access targets choose **where provisioning runs** via the executor. By default (`in_do`) the seekrit broker decrypts your database admin credential transiently to run `CREATE ROLE` / `DROP USER` itself. The **`remote`** executor moves that work into your network: the broker signs each command and sends it to a **`seekrit-provisioner`** daemon you run, which holds the admin credential locally. seekrit orchestrates but never sees it — **pure zero-knowledge even for provisioning.** > **Note:** The remote executor is for the **Postgres**, **MySQL/MariaDB**, and **Redis** providers. SSH certificate signing is `in_do`-only for now. ## How it works The broker holds only a **shared HMAC key** (wrapped to its per-org keypair, like any admin secret). Your **database admin credential** lives only on the daemon. Every command the broker sends is signed HMAC-SHA256; the daemon verifies the signature, rejects stale or replayed commands, and runs the already-rendered SQL. ``` broker (seekrit) your network ──────────────── ──────────── signs ProvisionCommand ──── POST ───▶ seekrit-provisioner ──▶ Postgres / MySQL (HMAC, shared key) verify sig · freshness · replay · run SQL as admin ``` The credential you hand a consumer stays zero-knowledge as always — the consumer generates it and sends only a verifier. The daemon only ever handles the *admin* credential, which now never leaves your network. ## 1. Mint the shared key ```sh seekrit provisioner keygen # → a base64 key on stdout. Keep it secret. ``` Use the **same** value in both places below: `--hmac-key` when you register the target, and `SEEKRIT_PROVISIONER_HMAC_KEY` on the daemon. ## 2. Register a remote target The admin connection string does **not** go to seekrit here — only the HMAC key is wrapped and uploaded. Point `--provisioner-url` at where the daemon will listen. ```sh KEY=$(seekrit provisioner keygen) seekrit pg target add \ --name prod-db --host db.internal --database app \ --access readonly \ --executor remote \ --provisioner-url https://provisioner.internal:8088/ \ --hmac-key "$KEY" ``` `seekrit mysql target add` and `seekrit redis target add` take the same `--executor remote --provisioner-url --hmac-key` flags. For the `readonly` / `readwrite` presets, run the one-time group-role setup SQL the command prints (Postgres) as a database admin. ## 3. Run the daemon Run it where it can reach the database, holding the same key plus the real admin connection string: ```sh docker run -d --name seekrit-provisioner -p 8088:8088 \ -e SEEKRIT_PROVISIONER_HMAC_KEY="$KEY" \ -e SEEKRIT_PROVISIONER_DATABASE_URL="postgres://admin:secret@db.internal:5432/app" \ seekritdev/provisioner:latest ``` Prebuilt multi-arch images (`amd64` + `arm64`) are published to Docker Hub as [`seekritdev/provisioner`](https://hub.docker.com/r/seekritdev/provisioner) — pin a release tag (e.g. `:0.1.0`) or `:edge` for the latest `main`. The image is a single static binary on `scratch` — no OS, no shell. `GET /healthz` returns `{"ok":true}` for liveness probes. ### Environment | Variable | Required | Default | Purpose | | --- | --- | --- | --- | | `SEEKRIT_PROVISIONER_HMAC_KEY` | yes | — | Base64 shared key; must match the target's `--hmac-key`. | | `SEEKRIT_PROVISIONER_DATABASE_URL` | yes | — | Admin `postgres://…`, `mysql://…`, or `redis://…`/`rediss://…` string. **Held only here.** | | `SEEKRIT_PROVISIONER_PROVIDER` | no | inferred from URL | `postgres`, `mysql`, or `redis`, if ambiguous. | | `SEEKRIT_PROVISIONER_DB_TLS` | no | `require` | `require` or `disable` (local/plaintext DB). Ignored for Redis, which uses the URL scheme (`rediss://` = TLS). | | `SEEKRIT_PROVISIONER_ADDR` | no | `0.0.0.0:8088` | Listen address. | | `SEEKRIT_PROVISIONER_MAX_SKEW_SECS` | no | `300` | Freshness window (and replay-cache TTL). | ## Lease as usual Nothing changes for the consumer. `seekrit pg lease prod-db` mints a credential exactly as with the in-DO executor — the difference is entirely in where the `CREATE ROLE` ran. > **Warning:** The HMAC authenticates and integrity-protects every command regardless of transport, so plain HTTP behind your own ingress is safe. Still, front the daemon with TLS if it is reachable beyond localhost, so command contents stay confidential in transit. The daemon holds a privileged database credential — keep it on a private network and scope that credential to only what provisioning needs. ## Security properties - **Authenticity** — a missing or bad signature is rejected (`401`) before any database work; verification is constant-time. - **Anti-replay** — commands outside a ±5-minute window are rejected (`400`), and each nonce is single-use within that window. - **Least exposure** — the daemon runs only the SQL the broker rendered, returns no query results, and never logs the admin URL, the key, or the statements. --- # Service tokens Service tokens are credentials for machines — CI jobs, Docker builds, Kubernetes workloads, and agent sandboxes. Each is an independent principal **bound to one application environment**: that binding selects the org, app, and environment it resolves at runtime, plus the group slices composed into it. A token can be revoked on its own. ## How they work A service token is self-contained: the token string carries its own private key. The server stores only a **SHA-256 hash** of the token (to authenticate it) and its **public key** (to wrap environment keys to it). This means a machine holding the token can unwrap any environment DEK granted to it entirely offline — the server never had the token's private key. Because tokens are generated on the client, the full token is shown **once** at creation. Copy it then; it cannot be retrieved later. ## Creating a token **In the web dashboard:** Organization → **Service tokens** → **Mint token**. You can also mint from where you notice the need for one — an environment's **Key access** panel, the **no token** warning on a matrix column, the application setup checklist, or the **Connect** panel — and those pre-fill the binding. Either way, copy the `skt_…` value from the one-time dialog. Minting in an organization that has no application environment yet doesn't dead-end: the dialog offers to create an application (its environments come with it) and then carries on with the binding pre-selected. ![The Service tokens page listing three tokens with the environment each is bound to, its id, status, and last use](https://seekrit.dev/screenshots/original/dashboard-service-tokens.webp) *The Service tokens page. Each token names the one app environment it is bound to, and a revoked token stays listed — the id remains attributable in the audit trail.* **With the CLI**, bind the token to an application environment. Creation auto-grants that environment's key **and** every group composed into it (at the matching slug): ```bash seekrit token create --name ci-deploy --app storefront --env production # prints: skt_XXXXXXXX_... (save it now) ``` To also authorize an alternate group slice for `run --with` overrides, add `--allow`: ```bash seekrit token create --name dev --app storefront --env dev --allow auth-providers=staging ``` Pass `--no-grant` to mint the token without any grants, then grant environments explicitly: ```bash seekrit token create --name ci-deploy --app storefront --env production --no-grant seekrit grant --token skt_XXXXXXXX --app storefront --env production ``` ## Admin tokens By default a token holds **member**-level API access — its real power is the environment keys wrapped to it. Pass `--admin` to mint an **org-scoped admin token** that also passes admin-gated routes: creating apps, groups, and environments, composing groups, granting keys, and minting further tokens. This is what lets automation and AI agents provision structure headlessly, with no browser session. ```bash seekrit token create --name agent-admin --admin # org-scoped: no --app/--env needed ``` An admin token needs no environment binding to provision, but you can still bind one (`--admin --app storefront --env production`) so it can both manage structure and decrypt that environment. Only an admin caller (an admin user, or another admin token) may create an admin token — capability never escalates itself. Scope admin tokens tightly and prefer a short lifetime. ## Using a token Set it as `SEEKRIT_TOKEN`; it selects the org, app, and environment on its own: ```bash export SEEKRIT_TOKEN=skt_XXXXXXXX_... seekrit export --format dotenv seekrit run -- ./deploy.sh ``` Service tokens never need a passphrase — their private key is in the token string. A token can only read or write the environments it was granted (its bound env + composed groups); it cannot reach other environments in the org. ## Granting and revoking Grants are per-environment. Grant a token from any environment's **Key access** panel, or with `seekrit grant --token --app --env `. When a token is retired: ```bash seekrit token revoke skt_XXXXXXXX ``` Revocation takes effect on the token's next request: revoking drops the cached copy of the token record that authentication reads, as part of the same operation. In the rare case that a copy somewhere outlives the revoke, it is re-checked against the database within a minute of continued use, so a stale copy corrects itself rather than lingering. If you need a hard cutoff — a leaked token, a departing teammate — revoke and then **rotate the environment key**. Revoking the grant doesn't rotate the key, and rotation is what makes a DEK the token already fetched useless; see [Access & key grants](/docs/concepts/access-control#revocation-vs-rotation). ## Deleting Revocation is the reversible-safe step: a revoked token authenticates nothing and stops counting against your plan's token limit, but it stays in the list so you keep the audit trail. When you want it gone for good, delete it: ```bash seekrit token delete skt_XXXXXXXX ``` Deletion is only allowed **after** a token is revoked, and it can't be undone — it drops every environment/KMS key grant that was wrapped to the token and hides it from your token list. The token record itself is retained for the audit trail (a soft delete); it just no longer appears anywhere or holds any keys. In the dashboard the **delete** action appears in the token's row once it shows as `revoked`. > **Warning:** Treat a service token like a password with decryption power. Scope it to only the environments it needs, store it in your platform's secret store (not in the repo), and rotate the environment key if a token leaks. ## Listing tokens ```bash seekrit token list ``` Shows each token's id, name, status (active / expired / revoked), and last-used time. Last-used is a coarse "when was this seen" signal, not a request log: it is recorded off the read path and settles within a couple of minutes, so a token that just resolved may still show its previous time. Use it to spot a credential nobody uses any more — for per-request history, read the [audit trail](/docs/concepts/security#audit-trail). ## Watching for leaked tokens A real token that escapes into an archived repo or a retired CI pipeline is the thing you can't see. [Honey tokens](/docs/guides/honey-tokens) are the other half of that problem: decoy credentials, identical in format to the tokens above, that unlock nothing and email your admins the instant anyone tries one. Plant one alongside wherever your real credentials have historically ended up, and you find out that a place has been read — instead of finding out later. --- # Honey tokens A honey token is a **decoy credential**. It looks exactly like a real seekrit service token, it grants nothing at all, and the moment anyone tries to authenticate with it, seekrit emails your admins and writes the attempt to your audit log. The point is what it tells you. Every other alert in a secrets manager answers "was this allowed?" — a question with a boring answer most of the time. A honey token answers a different one: **is someone reading things they shouldn't be?** Nothing legitimate ever holds one, so there is no benign explanation for a trip and nothing to triage. One alert, one conclusion: whatever you planted it in has been read by someone who wasn't supposed to. > **Note:** Honey tokens are a **plan feature**. Your [plan & billing](/docs/guides/billing) page shows whether your plan includes them; organizations on a plan without it see an upgrade prompt in place of the honey-token page. ## Where to plant one Think about the places a credential ends up that nobody ever cleans out. Those are the places worth watching: - An **archived or public repository** — in `.env.example`, a stale `docker-compose.yml`, a commented-out line in CI config. - A **CI/CD variable** on a pipeline you've since retired. - A **wiki page**, runbook, or onboarding doc that lists "the credentials you'll need". - A shared **password manager entry** or team drive folder. - A `.env.bak` or `.env.old` on a build box or a developer image. - A **support ticket** or chat thread where someone once pasted a secret. The rule of thumb: plant it where a thief would rummage, and **never anywhere your own tooling reads**. A honey token has no idea who is holding it — a deploy script that picks one up by mistake trips the alarm exactly as loudly as an intruder would. > **Note:** A honey token is safe to leave lying around. It carries no key material that unlocks anything, holds no grants, and is not a member of any environment, so the worst an attacker can do with one is tell you they found it. ## Plant a decoy > **Warning:** The decoy is shown **once**, when you create it. seekrit stores only a hash of it — the same way it handles real service tokens — so the value can't be recovered later, and not even seekrit's own database reveals what you planted where. Lost it? Delete the decoy and plant a fresh one. ### From the dashboard Open **Honey tokens** in the sidebar and choose **Plant decoy**. Give it a name and, optionally, a note recording where you're putting it. The note is repeated back to you in the alert email, which is the whole reason to bother with it: an alert that says *"legacy-ci-bait — archived acme/legacy-api repo"* tells you what was breached without any digging. ### From the CLI ```sh seekrit honey-token create \ --name legacy-ci-bait \ --placement "archived acme/legacy-api repo, .env.example" ``` The decoy goes to stdout and everything else to stderr, so you can pipe it straight where it needs to go: ```sh seekrit honey-token create --name ci-bait --placement "retired deploy pipeline" \ > /tmp/bait.txt ``` List what you have planted, and whether anything has taken the bait: ```sh seekrit honey-token list ``` ``` name status planted in last tripped id legacy-ci-bait TRIPPED 1× archived acme/legacy-api repo 2026-08-17T23:57:00Z skt_BNTz… wiki-bait untouched ops wiki "credentials" page never skt_9dPq… ``` ## What happens when one is tripped The instant a decoy is presented as a credential — on any endpoint, not just secret resolution — seekrit: 1. **Rejects the request.** The caller gets exactly the same `401 unknown service token` an unregistered token gets, byte for byte. That symmetry is deliberate: an attacker probing credentials must not be able to tell a decoy from a typo, or they'd know to back away quietly. 2. **Writes an audit row** (`honey_token.tripped`) recording the source IP, the user agent, the path they tried, and the decoy's name and placement. It is part of your normal append-only audit trail, so it also flows to your SIEM through [audit log export](/docs/guides/audit-export) with no extra setup. 3. **Emails your organization's admins**, throttled to one message per decoy per hour so a scripted retry loop can't bury the first alert. Every attempt still lands in the audit log — only the email is throttled. ### What to do about it Assume the container leaked, not just the decoy. Someone read the repo, the wiki page, or the CI config you planted it in — so treat **every real credential stored alongside it** as exposed and rotate it. The decoy itself needs no cleanup: it never granted anything. The audit log is the record to work from. Filter to `honey_token.tripped` for the IPs, user agents, and timing of every attempt, including the ones whose emails were throttled. ## Honey tokens vs. service tokens They're deliberately indistinguishable from the outside and completely different underneath: | | Service token | Honey token | | ---------------------- | ------------------------- | ---------------------------- | | Looks like | `skt_…` | `skt_…` (identical format) | | Can decrypt secrets | Yes, via its key grants | No — it holds no public key, so nothing can ever be wrapped to it | | Authenticates | Yes | Never | | On use | Bumps `last used` | Alerts your admins | | Lives in | `service_tokens` | A separate table with no key material | That last row is the load-bearing one. A honey token isn't a service token with permissions switched off — it is a different kind of record entirely, with no public key for a data key to be wrapped to. It unlocks nothing *by construction*, not by policy, so no future change to the authentication path can accidentally promote a decoy into a working credential. ## Deleting a decoy Deleting one stops it alerting, so pull the bait at the same time — otherwise you have left a credential lying in an archived repo with nothing watching it. ```sh seekrit honey-token delete skt_BNTzs78w3deu06FikXxQNo ``` Trips already recorded stay in your audit log; that trail is append-only and outlives the decoy. > **Note:** Alerts keep firing for decoys you've **already planted** even if your organization later moves to a plan without honey tokens. An alarm that goes quiet because a subscription lapsed would be worse than no alarm at all — you'd believe you were being watched when you weren't. You just can't plant or manage new ones until the feature is back on your plan. ## Related - [Service tokens](/docs/guides/service-tokens) — the real machine credentials honey tokens imitate. - [Email notifications](/docs/guides/notifications) — the **Honey token tripped** toggle, and every other alert seekrit sends. - [Audit log export](/docs/guides/audit-export) — get trips into your SIEM. --- # Rotate secrets on a schedule This guide is the how-to; see [Secret rotation](/docs/concepts/rotation) for how it works and what trust boundary it involves. Rotation is admin-managed and configured per secret. The step that makes it possible — wrapping the environment's data key to your organization's rotator key — happens in your browser or CLI, never on a server. ## 1. Pick what to rotate | You want to rotate | Kind | You also need | | --- | --- | --- | | A value only your own code checks (API key, webhook secret) | `generated` | nothing | | An existing Postgres role's password | `postgres` | a registered Postgres target | | An existing MySQL/MariaDB account's password | `mysql` | a registered MySQL target | | An existing Redis ACL user's password | `redis` | a registered Redis target | The secret must already exist — rotation replaces a value, it doesn't create one. For the database kinds you need a target registered for [temporary access](/docs/concepts/temporary-access); rotation reuses the same connection details and admin credential: ```bash seekrit pg target add --name prod-db --host db.example.com --database app \ --admin-url "postgres://admin:…@db.example.com:5432/app" ``` ## 2. Enable rotation **With the CLI** — a generated value, rotated every 30 days: ```bash seekrit rotation enable API_SIGNING_KEY --app web --env production \ --kind generated --every 30d --now ``` An existing Postgres role, re-keyed weekly: ```bash seekrit rotation enable DATABASE_PASSWORD --app web --env production \ --kind postgres --target prod-db --username app_user --every 7d --now ``` `--now` rotates immediately as well as on the schedule, which is the fastest way to confirm the target and account name are right — a misconfigured policy fails loudly here instead of quietly in an email tomorrow. **In the web dashboard:** open the environment → **Rotation** → **configure rotation**, pick the secret, the kind, and a cadence. For a database kind, pick the target and type the account name. The first time you enable rotation in an environment, your client unwraps that environment's data key and re-wraps it to the rotator key. That is the grant the broker needs to write a new value — you will be asked to unlock if your key is locked. A second rotating secret in the same environment needs no key access at all. > **Note:** `--username` names an account that **already exists**. Rotation changes its password in place; it never creates or drops the account, so grants and ownership survive. Make sure your registered target's admin credential can `ALTER` that account. ## 3. Live with it ```bash seekrit rotation list # every policy: cadence, status, next run seekrit rotation show DATABASE_PASSWORD # one policy in full, including the last failure seekrit rotation rotate DATABASE_PASSWORD # rotate now (e.g. after a suspected exposure) ``` Consumers need no changes — the next resolve returns the new value: ```bash seekrit run --app web --env production -- ./server ``` Every rotation appends a secret version, so `seekrit secrets get DATABASE_PASSWORD` always decrypts the current one, and the version history shows which versions rotation produced. ## 4. Pause, fix, resume If a rotation fails — the database is unreachable, the account was renamed, the admin credential lost its privileges — seekrit records the error, emails your admins, and retries with a backoff. After five consecutive failures the policy stops retrying and shows as `failed`. ```bash seekrit rotation show DATABASE_PASSWORD # `lastError` says what went wrong seekrit rotation pause DATABASE_PASSWORD # stop rotating while you work on it seekrit rotation resume DATABASE_PASSWORD # resume, clearing the failure streak seekrit rotation set-interval DATABASE_PASSWORD --every 30d ``` The dashboard's rotation card shows the same state inline, with the last error and per-policy rotate / pause / disable controls. > **Warning:** A rotation re-keys your database before it stores the new value. If the write-back fails in between, consumers hold a password that no longer works until the retry succeeds — seekrit emails you and retries with a fresh value automatically. Pick a cadence your deployment can absorb, and make sure your apps can re-resolve (or be restarted) after a rotation rather than caching a credential for their whole lifetime. ## Turning it off ```bash seekrit rotation disable DATABASE_PASSWORD ``` The secret and every version it has are untouched — only the schedule goes away. When the last policy in an environment is disabled, the rotator's key grant is dropped too, so seekrit's ability to decrypt that environment ends with the feature. Every action here is written to the [audit log](/docs/guides/audit-export) (`secret.rotation_configured`, `secret.rotated`, `secret.rotation_failed`, `secret.rotation_disabled`). --- # Set up recovery This guide is the how-to; see [Customer-controlled recovery](/docs/concepts/recovery) for how it works and why it stays zero-knowledge. Recovery is org-scoped and admin-managed. All of the crypto runs in your browser or CLI — seekrit only ever stores the recovery public key and shares it cannot open. ## 1. Enable recovery Choose custodians (org members who have finished [key setup](/docs/guides/web-app)) and a threshold **M** — how many of them are required to recover. **In the web dashboard:** Organization → **Settings** → **Customer-controlled recovery** → toggle each custodian on, set the threshold, then **Enable recovery**. The dashboard generates and splits the recovery key locally and immediately protects every environment you can decrypt. **With the CLI:** ```bash # 3-of-5 recovery: any three of these five custodians can recover. seekrit recovery setup --threshold 3 \ --custodian alice@example.com \ --custodian bob@example.com \ --custodian carol@example.com \ --custodian dan@example.com \ --custodian erin@example.com # A single --custodian with --threshold 1 is the "designated recovery admin" case. ``` ## 2. Cover every environment Recovery can only protect an environment whose DEK someone wraps to the recovery key, and only a principal that can already decrypt an environment can do that. New environments are covered automatically at creation; to backfill the rest, each admin who holds grants runs: ```bash seekrit recovery sync # wraps every environment you can decrypt but that isn't yet covered seekrit recovery status # shows M-of-N, custodians, and coverage (e.g. "7/8 protected") ``` Repeat from other admins until coverage is complete. In the dashboard, the recovery card shows a coverage bar and a **Sync coverage** button. ## 3. Recover access When someone loses their passphrase or leaves, an admin runs a recovery ceremony. Start it as the person who will end up with access (recovering for yourself is the default): ```bash seekrit recovery request # prints a request id: rrq_… # recover access for someone else instead: seekrit recovery request --target-user newadmin@example.com ``` Each custodian then approves — unwrapping their share locally and re-wrapping it to the target: ```bash seekrit recovery approve rrq_XXXXXXXX # run by each custodian until the quorum is met ``` Once a quorum has approved, the target completes the ceremony — reconstructing the recovery key in their own client and re-granting themselves access: ```bash seekrit recovery complete rrq_XXXXXXXX # restores access to every covered environment ``` The dashboard exposes the same flow: **Recover access** → **Start recovery**, an **Approve** button for custodians, and **Complete** once the quorum is reached. > **Warning:** A custodian's approval requires their passphrase (to unwrap their share) — it happens on their machine, never on a server. After a completed recovery, rotate the recovery key with `seekrit recovery rotate --threshold M --custodian …`, since it was briefly reconstructed in the clear on the target's device. ## Rotating and disabling ```bash seekrit recovery rotate --threshold 2 --custodian alice@example.com --custodian bob@example.com seekrit recovery disable # removes the recovery key and every recovery grant ``` Rotation generates a fresh recovery key, re-splits it to the custodian set you pass, and re-wraps the environments you can decrypt — run `seekrit recovery sync` from other admins afterward to restore full coverage. Every recovery action is written to the [audit log](/docs/guides/audit-export). --- # Managed keys (KMS) Managed keys let your application do its own encryption and signing with a key that seekrit stores but can never read. See [Managed keys (KMS)](/docs/concepts/kms) for the model; this guide is the how-to. Everything below happens client-side — the key material never reaches the server. ## Create a key Creating a key generates its material locally and wraps it to you (a self-grant), so you can use it immediately. ```bash # A symmetric key for application-layer encryption: seekrit kms create --name payments-field-key --purpose encrypt # A signing key (ECDSA P-256); its public key is published for verification: seekrit kms create --name release-signer --purpose sign # Scope a key to an app or group, and grant teammates up front: seekrit kms create --name billing-key --purpose encrypt \ --app billing --grant-user teammate@acme.dev --grant-token skt_ci… ``` In the dashboard, open **KMS keys** in the sidebar and choose **new key**. ![The KMS keys page listing an encrypt key and a sign key, each with its id, scope, version, and active status](https://seekrit.dev/screenshots/original/dashboard-kms-keys.webp) *The KMS keys page. Each key shows its purpose, the app or group it is scoped to, and its current version — the material itself only ever exists wrapped.* ## Encrypt & decrypt ```bash # Encrypt stdin → a ce1 ciphertext blob: echo -n "4111 1111 1111 1111" | seekrit kms encrypt --key payments-field-key > card.enc # Decrypt it back: seekrit kms decrypt --key payments-field-key < card.enc ``` Pass a matching `--context` to both sides to bind the ciphertext to where it belongs — decrypt fails if the context differs: ```bash echo -n "$SSN" | seekrit kms encrypt --key payments-field-key --context "field=ssn" > ssn.enc seekrit kms decrypt --key payments-field-key --context "field=ssn" < ssn.enc ``` For large payloads, mint an envelope data key: `seekrit kms generate-data-key --key ` prints the plaintext key (base64) and its wrapped form; recover it later with `seekrit kms open-data-key --key `. ## Sign & verify ```bash # Sign a message → an sg1 signature: echo -n "release-v1.2.3" | seekrit kms sign --key release-signer > release.sig # Verify it (needs only the published public key — no grant): echo -n "release-v1.2.3" | seekrit kms verify --key release-signer --signature "$(cat release.sig)" ``` ## Grant, rotate, revoke ```bash seekrit kms grant --key payments-field-key --user teammate@acme.dev seekrit kms grant --key payments-field-key --token skt_ci… seekrit kms rotate --key payments-field-key # new version, re-wrapped for every grantee seekrit kms revoke --key payments-field-key --user teammate@acme.dev seekrit kms disable --key payments-field-key # block all use: encrypt, decrypt, sign, and new grants/rotations seekrit kms delete --key payments-field-key # hide it from every listing; the name frees up for reuse ``` Rotation keeps old versions, so existing ciphertexts and signatures stay valid. Granting a new principal covers the current version onward. Deleting a key hides it everywhere and frees its name for reuse; the row and its versions are retained so existing ciphertexts and signatures still resolve, but the key can no longer be used. ## From AI agents The `seekrit mcp` server exposes the same operations as tools — `kms_list_keys`, `kms_create_key`, `kms_grant`, `kms_encrypt`, `kms_decrypt`, `kms_generate_data_key`, `kms_sign`, and `kms_verify` — so an agent can encrypt, decrypt, and sign locally. Like every plaintext-producing tool, these run on the agent's own machine; see [AI agents](/docs/guides/ai-agents). > **Note:** Under user auth, any operation that unwraps key material (encrypt, decrypt, sign, grant, rotate) needs your keyring unlocked — the CLI prompts, or set `SEEKRIT_PASSPHRASE`. Verification needs no key at all. --- # AWS KMS drop-in `seekrit-kms` is a small gateway that speaks the **AWS KMS JSON API** on a local endpoint. Point an AWS SDK's KMS endpoint at it and your existing KMS code works unchanged — but every operation runs **in the gateway process**, against a [managed key](/docs/concepts/kms) fetched as a wrapped grant and unwrapped locally. The seekrit API never sees your plaintext, your data keys, or the key material. It's a drop-in KMS that your provider literally cannot read. > **Note:** This covers KMS's envelope encryption (S3/DynamoDB client-side encryption, Tink, any `GenerateDataKey` pattern) **and** ECDSA signing (`Sign`/`Verify`/ `GetPublicKey`). RSA, HMAC (`GenerateMac`), and key-policy management are not part of this gateway today; GCP Cloud KMS is the next dialect on the roadmap. ## Run the gateway Give it a [service token](/docs/guides/service-tokens) that holds grants on the managed keys you want to use. It binds loopback by default. ```bash export SEEKRIT_TOKEN=skt_… seekrit-kms # listening on 127.0.0.1:9911 ``` As a container (bind `0.0.0.0` so the workload can reach it): ```bash docker run --rm \ -e SEEKRIT_TOKEN=skt_… -e SEEKRIT_KMS_LISTEN=0.0.0.0:9911 \ -p 9911:9911 seekritdev/kms ``` Startup is **fail-closed**: a missing or bad token, or an unreachable API, stops it from starting rather than serving a broken endpoint. ## Point your SDK at it Only the endpoint changes. The SDK still signs requests with its own credentials (dummy values are fine) — the gateway ignores the signature; the service token is the real authority. ```bash # AWS CLI export AWS_ENDPOINT_URL_KMS=http://127.0.0.1:9911 export AWS_ACCESS_KEY_ID=ignored AWS_SECRET_ACCESS_KEY=ignored AWS_REGION=us-east-1 aws kms generate-data-key --key-id billing --key-spec AES_256 ``` ```python # boto3 import boto3 kms = boto3.client("kms", endpoint_url="http://127.0.0.1:9911") # Envelope encryption: dk = kms.generate_data_key(KeyId="billing", KeySpec="AES_256") data_key = dk["Plaintext"] # use locally, then discard wrapped = dk["CiphertextBlob"] # store beside your ciphertext # ...later... data_key = kms.decrypt(CiphertextBlob=wrapped)["Plaintext"] # Direct encrypt/decrypt: ct = kms.encrypt(KeyId="billing", Plaintext=b"4111 1111 1111 1111")["CiphertextBlob"] pt = kms.decrypt(CiphertextBlob=ct)["Plaintext"] ``` ```js // @aws-sdk/client-kms (v3) import { KMSClient, GenerateDataKeyCommand } from "@aws-sdk/client-kms"; const kms = new KMSClient({ endpoint: "http://127.0.0.1:9911", region: "us-east-1" }); const dk = await kms.send(new GenerateDataKeyCommand({ KeyId: "billing", KeySpec: "AES_256" })); ``` ## Referring to keys A KMS `KeyId` resolves to a managed key by **name** or **id** — `billing`, `alias/billing`, the `kms_…` id, or an ARN the gateway returned all map to the same key. Create keys the usual way (`seekrit kms create --name billing --purpose encrypt`, or the dashboard) and grant the gateway's token access to them. ## Encryption context AWS `EncryptionContext` is bound as additional authenticated data, exactly like KMS: `Decrypt` must supply the same context or it fails. It's canonicalized order-independently, so `{tenant, field}` and `{field, tenant}` are equivalent. ## Signing A seekrit `sign` key (ECDSA P-256) drives `Sign`, `Verify`, and `GetPublicKey`. The private key is unwrapped in the gateway; signatures come back **DER-encoded**, the way AWS SDKs expect. ```python sig = kms.sign(KeyId="artifacts", Message=b"release-v1.2.3", MessageType="RAW", SigningAlgorithm="ECDSA_SHA_256")["Signature"] kms.verify(KeyId="artifacts", Message=b"release-v1.2.3", Signature=sig, SigningAlgorithm="ECDSA_SHA_256")["SignatureValid"] # True pub = kms.get_public_key(KeyId="artifacts")["PublicKey"] # SPKI DER ``` `MessageType` is `RAW` (the gateway hashes with SHA-256) or `DIGEST` (you pass a 32-byte SHA-256 digest). `Verify` tries every published key version, so a signature made before a rotation still validates; on mismatch it raises `KMSInvalidSignatureException`, matching KMS. `Verify` and `GetPublicKey` work on a disabled key; `Sign` does not. ## Supported operations | Operation | Notes | | --- | --- | | `Encrypt` / `Decrypt` | AES-256-GCM; encryption context bound as AAD | | `GenerateDataKey` / `…WithoutPlaintext` | `AES_256`, `AES_128`, or `NumberOfBytes` | | `Sign` / `Verify` | ECDSA P-256 (`ECDSA_SHA_256`); `RAW` or `DIGEST`; DER signatures | | `GetPublicKey` | SPKI-DER public key for the current version | | `DescribeKey`, `ListKeys` | metadata for the keys the token can see | | `GenerateRandom` | CSPRNG bytes | > **Warning:** You can't take over ciphertext that real AWS KMS produced. The gateway holds **seekrit** keys, not your AWS keys — so it's a drop-in for new usage or a re-encrypt migration, not a transparent takeover of an existing encrypted corpus. Ciphertext is interchangeable only within seekrit. ## Security The gateway is a plaintext boundary, like the [agent egress proxy](/docs/guides/agent-proxy) and the Kubernetes sidecar: it holds a token and produces data keys and plaintext. It performs **no AWS SigV4 verification**, so treat reachability to its port as access to everything the token can decrypt — keep it on loopback or a trusted local network, scoped to a token that grants only the keys that workload needs. A [disabled key](/docs/concepts/kms) stops working through the gateway within one refresh interval. --- # Audit log export Stream your organization's audit trail to your own security tooling. seekrit ships every new audit event — writes, grants, revocations, token and lease actions, denied resolves — to an **OTLP/HTTP logs endpoint** as OpenTelemetry log records, so you can alert, retain, and correlate seekrit activity alongside the rest of your infrastructure. ![The Audit trail page: rows of append-only events such as token.revoked, env.group_linked, secret.restored, and env.key_granted, each with actor, resource, and detail columns](https://seekrit.dev/screenshots/original/dashboard-audit-trail.webp) *The same events in the dashboard's audit trail — this is what gets exported, one OTLP log record per row.* > **Note:** Exported records carry only what's already in your audit log: action, attribution (user or service token), IP, resource ids, and redacted metadata (names and counts). They **never** contain secret values, ciphertext, private keys, passphrases, or token strings. > **Note:** Audit log export is a **plan feature** (**Audit log export (SIEM)**). Your [plan & billing](/docs/guides/billing) page shows whether your plan includes it; organizations on a plan without it see an upgrade prompt in place of the configuration form. ## How it works A per-organization background sweep runs **every minute**. For each configured sink it finds audit rows newer than a stored watermark, POSTs them to your endpoint as an OTLP `LogsData` payload (`content-type: application/json`), and advances the watermark **only after your collector returns a 2xx**. Delivery is therefore **at-least-once**: if your endpoint is briefly unavailable, the same events re-ship on a later tick rather than being dropped (repeated failures back off exponentially, up to a few minutes between attempts). Each event becomes one log record: - **Body** — the audit action (e.g. `secret.created`, `token.revoked`). - **Severity** — `WARN` for revocations, deletions, and denied resolves; `INFO` otherwise, so you can alert on security-significant events directly. - **Attributes** — `audit.id`, `audit.action`, `audit.resource_type`, `audit.resource_id`, `actor.type`, `actor.id`, `client.address`, and the event's redacted metadata as `audit.metadata.*`. - **Resource** — `service.name=seekrit`, plus `seekrit.org.id` and `seekrit.org.slug`. ## Configure a sink In the dashboard, open your organization → **settings** (admins only), or from the CLI: ```bash seekrit log-sink set https://collector.example.com/v1/logs \ --header "Authorization: Bearer $COLLECTOR_TOKEN" seekrit log-sink # endpoint, header names, and delivery health ``` Or against the API directly: ```bash curl -X PUT https://api.seekrit.dev/v1/orgs/$ORG/log-sink \ -H "authorization: Bearer $SEEKRIT_TOKEN" \ -H "content-type: application/json" \ -d '{ "endpoint": "https://collector.example.com/v1/logs", "headers": { "Authorization": "Bearer " }, "enabled": true }' ``` - **Endpoint** — the full OTLP/HTTP logs URL, typically ending in `/v1/logs`. - **Headers** — sent with every export request; use these for your collector's auth (e.g. `Authorization`, or `DD-API-KEY` for Datadog). They are **encrypted at rest** and never returned — reads only ever show the header *names*. Omit `headers` on a later update to keep the stored value; pass `{}` to clear it (`--header` and `--clear-headers` respectively, from the CLI). - **Enabled** — toggle export off without deleting the configuration. When first enabled, the watermark starts at the newest existing audit event, so you receive events from that point forward rather than a backfill of history. ## Test the connection Send a synthetic event to verify connectivity and auth before you rely on it — click **Send test event** in the dashboard, or: ```bash seekrit log-sink test # exits non-zero on failure, so it works as a health check ``` ```bash curl -X POST https://api.seekrit.dev/v1/orgs/$ORG/log-sink/test \ -H "authorization: Bearer $SEEKRIT_TOKEN" # → { "ok": true, "status": 200, "error": null } ``` The dashboard and `seekrit log-sink` both surface the last successful export time and the last error, so a misconfigured endpoint is easy to spot. > **Warning:** The header you configure is a credential for **your** collector. Anyone with admin access to the organization can change the endpoint — treat the ability to reconfigure the sink as security-sensitive, and prefer a scoped, rotatable token over a long-lived one. --- # Telemetry (OpenTelemetry) The seekrit components that run on **your** infrastructure emit **OpenTelemetry** traces, metrics, and logs over OTLP/HTTP, configured entirely through the standard `OTEL_*` environment variables. Point them at the collector you already run and they appear alongside the rest of your services. This covers the five self-hosted components: | Component | What it is | | ----------------------- | -------------------------------------------- | | `seekrit-run` | The launcher that injects secrets and execs | | `seekrit-proxy` | The agent egress proxy | | `seekrit-sdk-server` | The Kubernetes/ESO sidecar | | `seekrit-kms` | The AWS KMS-compatible gateway | | `seekrit-provisioner` | The self-hosted temporary-access executor | > **Note:** This telemetry goes to **your** collector, never to seekrit. It is a different thing from [audit log export](/docs/guides/audit-export), which streams the server-side audit trail from seekrit's API to your SIEM. These services export their own operational signals directly. ## Turn it on Set an OTLP endpoint. That is the whole configuration — there is no seekrit-specific telemetry setting: ```bash export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 seekrit-proxy --config seekrit-proxy.toml ``` With no endpoint set, **nothing is exported**: no exporters are created, no background threads start, and no connections are attempted. Unlike the OpenTelemetry default, these binaries do not assume a collector on `localhost:4318` — they run in containers and CI where there usually isn't one. The usual variables all work, because the standard SDK handles them: | Variable | Effect | | ------------------------------------------ | ------------------------------------------------------------- | | `OTEL_EXPORTER_OTLP_ENDPOINT` | Base endpoint; `/v1/traces`, `/v1/metrics`, `/v1/logs` appended | | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Per-signal override (full URL) | | `OTEL_EXPORTER_OTLP_HEADERS` | Collector credentials, e.g. `api-key=…` | | `OTEL_SERVICE_NAME` | Override the default service name | | `OTEL_RESOURCE_ATTRIBUTES` | Add resource attributes, e.g. `deployment.environment=prod` | | `OTEL_TRACES_EXPORTER=none` | Disable one signal (also `METRICS`, `LOGS`) | | `OTEL_SDK_DISABLED=true` | Disable everything | Only **OTLP over HTTP** is supported (`http/protobuf`). There is no gRPC exporter — it would roughly double the size of binaries that ship on `scratch`. Every collector accepts OTLP/HTTP on port 4318. ## What gets recorded — and what never does These processes hold decrypted secrets in memory. Telemetry leaves the process and lands somewhere with different access controls, so it is held to the same rule as the audit log: > **Warning:** Spans and metrics carry secret **names**, counts, upstream hosts, durations, status codes, and error kinds. They **never** carry secret values, ciphertext, data keys, private keys, service tokens, `Authorization` headers, request or response bodies, or the SQL a provisioning command runs. This is enforced by tests, not convention: each service has a `tests/telemetry.rs` that drives a real request carrying a sentinel secret value through the real handler and fails if that value appears anywhere in the exported spans. ### Signals by component **`seekrit-proxy`** — a span per proxied request (method, matched route, upstream host, injected secret names, status) plus: - `seekrit.proxy.requests` — by `plane` (reverse/forward) and `outcome` (`forwarded`, `denied`, `no_route`, `bad_request`, `upstream_error`) - `seekrit.proxy.injections` — substitutions performed, by upstream - `seekrit.proxy.upstream_duration` — upstream latency, by upstream A rising `outcome="denied"` rate is the one worth alerting on: it means a workload is asking for a secret its route has no claim to. **`seekrit-sdk-server`** — a span per API read, plus `seekrit.sdk_server.secret_reads` (by `hit`/`miss`/`unauthorized`), `seekrit.sdk_server.refreshes` (by `ok`/`error`), and `seekrit.sdk_server.snapshot_size`. Alert on refresh errors — they mean the snapshot is going stale and ESO will keep syncing old values. **`seekrit-kms`** — a span per KMS operation, plus `seekrit.kms.operations` and `seekrit.kms.operation_duration`, both labelled by `operation` and by `outcome` (the AWS error code, e.g. `NotFoundException`, or `ok`). **`seekrit-provisioner`** — a span per provisioning command (kind, lease id, statement count) plus `seekrit.provisioner.commands` and `seekrit.provisioner.command_duration`. Each rejection stage is its own outcome — `signature_invalid`, `stale`, `replay`, `sql_error` — so "someone is forging commands" is distinguishable from "the database is down". **`seekrit-run`** — one span per invocation with the secret count, the command name, and `seekrit.run.degraded`. That last flag is the useful one: it is `true` when the resolve failed and the command ran with only `.env` plus the live environment. A fleet quietly running without its managed secrets looks healthy otherwise. ## Connecting traces across the boundary `seekrit-run` reads `TRACEPARENT` from its environment, so if your CI is instrumented (the Jenkins OpenTelemetry plugin, GitLab, `otel-cli`) the run attaches to the pipeline step that launched it instead of starting a detached trace. The long-running services read `traceparent` from inbound requests and continue the caller's trace. `seekrit-proxy` can also **inject** trace context into the requests it forwards, which is **off by default**: ```toml propagate_trace_upstream = true ``` It is off because the proxy's upstreams are usually third-party APIs you don't operate, where propagation buys nothing and just hands an outside party a correlatable identifier. Turn it on when the upstream is your own instrumented service and you want one continuous trace. ## Kubernetes The [`seekrit-eso` chart](/docs/guides/kubernetes) takes an `otel` block: ```yaml otel: endpoint: http://otel-collector.observability:4318 serviceName: seekrit-sdk-server resourceAttributes: deployment.environment=prod headers: "" ``` Leave `endpoint` empty (the default) and the sidecar exports nothing. > **Note:** If you enable the chart's `networkPolicy`, remember it restricts **ingress** to the sidecar. Egress to your collector is unaffected — but a cluster-wide default-deny egress policy of your own will block export until you allow it. ## Docker ```bash docker run --rm \ -e SEEKRIT_TOKEN=skt_… \ -e OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318 \ -e OTEL_RESOURCE_ATTRIBUTES=deployment.environment=prod \ seekritdev/proxy --listen 0.0.0.0:8080 ``` ## Building without it Telemetry is compiled in by default and costs 350–540 KiB depending on the binary. If you build from source and want it gone entirely — most relevant for `seekrit-run`, the smallest binary and the one distributed standalone: ```bash cargo build --release --no-default-features ``` The instrumentation call sites remain and become no-ops; no OpenTelemetry SDK or exporter is linked. --- # Email notifications seekrit sends transactional emails for security-significant events — a new service token, an access grant, an expiring credential. They keep you aware of who can reach your secrets without opening the dashboard. > **Note:** Notification emails carry only metadata already in your audit log — names, roles, timestamps, organization and environment names, and links. They **never** contain secret values, ciphertext, private keys, passphrases, or token strings. ## What seekrit sends | Email | When | Who receives it | | --- | --- | --- | | Service token created | A service token is minted in an organization | Owners & admins | | Service token revoked | A service token is revoked | Owners & admins | | Environment access granted | You're granted a key for an environment | The person granted access | | Environment access revoked | Your access to an environment is removed | The person affected | | Denied-access alerts | A principal is denied when resolving secrets | Owners & admins (throttled to one per environment per hour) | | Welcome & key setup | You join or create an organization | You | | Service token expiring | A token expires within 7 days | Owners & admins (sent once per token) | | Temporary credential expired | A lease reaches its expiry and is revoked | The lease owner & admins | | Third-party sync failed | A sync to an external destination stops retrying, leaving it stale | Owners & admins | | Honey token tripped | Someone tries to use one of your [decoy credentials](/docs/guides/honey-tokens) | Owners & admins (throttled to one per decoy per hour) | | Secret rotation failed | A [scheduled rotation](/docs/guides/rotation) can't complete | Owners & admins (on the first failure and again if it gives up) | ## Managing your preferences Each type has its own on/off switch. In the dashboard, open the account menu (top right) → **notification settings**, or go to `/account/notifications`. Toggling a switch saves immediately. Preferences are per-user and apply across every organization you belong to. All types are on by default. Every email also includes a **Manage which emails you receive** link in its footer that leads to the same page. Or from the CLI: ```bash seekrit notifications # every type, on or off seekrit notifications set sync_failed off ``` --- {/* Plans aren't live until enforcement is on — hide this guide (404), matching the gated pricing page and the dashboard's billing UI. See `plansEnforced`. */} {!plansEnforced && notFound()} # Plans & billing Every organization is on a plan. The **Plan & billing** page in the dashboard shows the plan you're on, what it includes, how much of each metered dimension you've used, and — for admins — a one-click way to upgrade or manage payment. Find it in the org sidebar under **plan & billing**. ## Your current plan The top card shows your plan's name and status, and when it renews (or, if you've cancelled, when access ends). Below it, **what's included** breaks down your plan into: - **Features** — capabilities that are on or off (managed keys, temporary access, audit export, and so on). - **Limits** — hard caps like applications, environments per app, or service tokens. `unlimited` means there's no cap. > **Note:** Plans aren't enforced yet — every organization currently has full access regardless of its plan. The page shows what your plan **will** include once enforcement is turned on, so you can see where you stand ahead of time. Nothing you do today is blocked by a limit. ## Usage — the metered numbers The **Usage** section shows each *metered* dimension as **used / included**, with a bar that turns amber as you approach the included amount and red if you go over. Metered dimensions bill any usage **beyond** what your plan includes as overage. | Dimension | What the number counts | | --- | --- | | **Members** | Everyone in the organization right now. | | **Monthly resolves** | Successful secret resolutions (`GET /v1/resolve`) so far this **calendar month**. This is the request every `seekrit run`, service token, proxy, and CI job makes to fetch its secrets — so it grows with how often your workloads start up, not how many secrets you store. | `included` shows as **unlimited** when your plan sets no included cap on that dimension (you're never billed overage for it). If a number reads **not available here**, that deployment doesn't have usage metering wired up — treat it as unknown, not zero. > **Note:** Monthly resolves are metered from sampled analytics, so the count is a close estimate under heavy traffic rather than an exact ledger — it's meant for understanding usage and billing, not reconciliation. Denied resolves and every *mutating* action are recorded exactly in the [audit log](/docs/guides/audit-export). ## Limits & usage on each page You don't have to open this page to see where you stand. Each resource page shows its own usage against your plan's cap, right next to the title — the **Applications** page shows **3 / 3** when your plan includes three, **Members** shows seats used against what's included, and the same goes for groups, service tokens, KMS keys, and lease targets. The count turns amber as you approach the cap and red if you go over. Once enforcement is on, plans also gate the dashboard directly: - **Features your plan doesn't include** — managed keys, temporary access, or audit log page. - **Create actions disable at a cap** — e.g. the **new application** button greys out once you've reached your plan's application limit, with a tooltip explaining why. Metered dimensions like members aren't blocked; usage beyond what's included simply bills as overage. ## Changing plans Admins and owners see the **Change plan** section with a card per plan. Your current plan is marked **current** and its button is disabled; every other plan shows **Choose plan**. **Upgrading to a paid plan:** 1. Click **Choose plan** on the plan you want. You're taken to a secure Stripe checkout page. 2. Enter payment details and confirm. Payment is handled entirely by Stripe — seekrit never sees your card. 3. You're returned to the dashboard; your plan updates within a few moments. **Switching to the Free plan (downgrading):** Click **Choose plan** on **Free**. Because a downgrade can leave you over the new plan's limits, we confirm first: - If your current usage fits the plan, confirm and you're moved to Free right away — any paid subscription is cancelled. - If you're **over the Free plan's limits** on something (for example, more members than Free includes), the dialog lists what's over and by how much before you confirm. Once plan limits are enforced, usage above the new plan's limits has to be brought back within them to fit. Once you have a paid subscription, **Manage billing** (top-right of the current-plan card) opens the Stripe billing portal, where you can update your card or download invoices. > **Note:** Only organization **admins** and **owners** can change the plan or manage billing. Members see the plan and usage but not the controls. If a paid plan's **Choose plan** button is disabled, that plan isn't wired up for self-serve checkout on this deployment — reach out and we'll set it up. ## Promo codes If you've been given a promo code, redeem it in the **Promo code** section at the bottom of the billing page. A code moves your organization onto the plan it grants — Team or Enterprise — with no card and no checkout. 1. Type the code and click **Redeem**. Casing, spaces, and dashes don't matter: `launch-2026` and `LAUNCH2026` are the same code. 2. Your plan updates immediately. The current-plan card then shows which code granted it, and the date it runs until. Most codes run for a set period. When that period ends your organization returns to the **Free** plan automatically — nothing is charged, and you're not moved onto a paid plan without asking. Any secrets, apps, or members over the Free plan's limits stay exactly where they are; once plan limits are enforced you'd need to bring usage back within Free's limits or pick a paid plan. > **Note:** Only **admins** and **owners** can redeem a code. A code can be used once per organization, and can't be redeemed while you have an active paid subscription — cancel or manage that in the billing portal first. Every code that can't be redeemed reports the same message, so if you're sure the code is right, check with whoever gave it to you that it's still running. Applying a *discount* to a paid plan works differently: enter that code in the **promotion code** box on the Stripe checkout page itself, not here. ## From the CLI The same view and the same actions, without opening the dashboard. Checkout and the billing portal are browser flows, so those commands print a URL to open rather than completing a payment in your terminal. ```bash seekrit billing # plan, status, usage, and what this deployment supports seekrit billing entitlements # every entitlement and where its value came from seekrit billing checkout team # prints a Stripe checkout URL seekrit billing portal # prints a billing-portal URL seekrit billing cancel # back to Free; asks first ``` Redeeming a promo code is dashboard-only — there's no CLI command for it. ## Grandfathering When you subscribe, your plan is pinned to the exact version it was on. If seekrit later changes what a plan includes, existing subscriptions keep the terms they signed up with until you choose to move — so an upgrade never quietly changes the deal underneath you. --- # CLI commands `seekrit [options]`. At runtime a **service token selects the org, app, and environment** — so `seekrit run`/`export` need no config file. Management commands select their target with `--org`/`--app`/`--group`/`--env` flags (plus `--branch` for an ephemeral [branch config](/docs/guides/branches)), falling back to the optional `seekrit.json` written by `seekrit init`. ## Environment variables | Variable | Purpose | | --- | --- | | `SEEKRIT_TOKEN` | Service token (`skt_…`). Carries its bound org + app + environment. Also accepts a CLI session token (`skc_…`) if you'd rather pass one explicitly than save it. | | `SEEKRIT_CLIENT_ID` / `SEEKRIT_CLIENT_SECRET` | Machine (M2M) credentials. When set (and no `SEEKRIT_TOKEN` is), an admin token is minted from them automatically and cached — the seamless path for autonomous agents. | | `SEEKRIT_API_URL` | API base URL. Overrides saved config; defaults to `https://api.seekrit.dev`. | | `SEEKRIT_PASSPHRASE` | Passphrase to unlock your private key non-interactively. | | `SEEKRIT_CACHE` | Set to `1` to enable the [last-known-good cache](#last-known-good-cache) for `run` / `export` without passing `--cache`. | | `SEEKRIT_CACHE_DIR` | Where cached responses live. Defaults to `$XDG_CACHE_HOME/seekrit`, else `~/.cache/seekrit`. | | `SEEKRIT_CACHE_MAX_AGE` | How stale a cached response may be and still be used. Defaults to `24h`. | Precedence: environment variables override values saved by `seekrit login` in `~/.config/seekrit/config.json`. `seekrit run` additionally reads these from its `.env` file(s) (below `process.env`, above saved config) — see [Running & exporting](#running--exporting). ## Output conventions Everything the CLI can do from the dashboard, it can do from the terminal, and every listing reads the same way: - **Listings** print an aligned table with a header when stdout is a terminal, and plain **tab-separated rows with no header** when it is piped — so `seekrit app list | cut -f1` keeps working. - **`--json`** on any read command prints the API's own response instead. Use it when a table would flatten something you need (ids, nested config, timestamps). - **Data goes to stdout; everything else goes to stderr** — confirmations (`created app …`), "nothing to list" notes, and pagination hints. A command's stdout is always safe to pipe. - **Destructive commands ask first.** Anything that deletes or revokes prompts for confirmation at a terminal, and refuses outright when there is no terminal unless you pass `--yes` (or, for `sync enable`, `--acknowledge-decryption`). - `list` is aliased as `ls`, and `rm` as `delete`, wherever both read naturally. ## Auth & identity ### `seekrit login` With no flags, signs you in **through your browser**: it prints a URL and a pairing code, opens the URL when you press `[Enter]`, and waits while you authorize the device in the dashboard (re-entering your authenticator code if you have one). The session it saves authenticates as *you* — every org you belong to, at your role — and lasts 90 days. Nothing to copy and paste, and no org, app, or environment to choose. Passing a credential stores that instead and skips the browser entirely — the path machines and CI use. | Flag | Description | | --- | --- | | `--token ` | Service token (`skt_…`). Stored as-is; replaces any browser session. | | `--client-id ` / `--client-secret ` | Machine (M2M) credentials (given together). An admin token is minted from them automatically on next use and cached. | | `--api-url ` | API base URL. | | `--no-browser` | Print the sign-in URL instead of opening it (SSH sessions, headless machines). | A browser session carries no key material, so reading a secret's value still unlocks *your* private key with your passphrase (`SEEKRIT_PASSPHRASE`, else prompted) — exactly as the dashboard does. Service tokens carry their own key and don't prompt, which is why they remain the right credential for unattended jobs. ### `seekrit logout` Forget the saved credentials. A browser-authorized session is also **revoked** server-side, so the token can't be used again; a service token is only removed locally (other machines may hold it — revoke it with `seekrit token revoke`). Machine (M2M) credentials are left in place. ### `seekrit whoami` Show the authenticated identity. For a service token, prints its bound `org/app/env` scope; for a browser session, names the session so you can revoke it. ### `seekrit keys setup` Generate your P-256 keypair and upload your public key plus a passphrase-encrypted private key. Run once per account. Honors `SEEKRIT_PASSPHRASE`, otherwise prompts. ## Resources ### `seekrit init` `--org --app ` — write `seekrit.json` naming default org + app for management commands. **Environment-independent and safe to commit** — it never pins an environment (the token does that at runtime). ### Organizations (`seekrit org`) | Command | Description | | --- | --- | | `seekrit org list` | Every organization you can reach, with your role in each. | | `seekrit org show [slug]` | One org: slug, id, your role, and how many applications, groups, members, and service tokens it holds. A count your role can't read prints as `—`. | | `seekrit org tree [--org ]` | The whole org as a tree — every application and group with their environments. The fastest "what is in here?". | | `seekrit org create --name --slug ` | Create an organization (you become owner). | | `seekrit org rename [--org ] --name ` | Change the display name. Slugs are permanent identifiers and never change. | | `seekrit org member list [--org ]` | Members, their roles, and whether each has finished key setup. Someone showing `pending` cannot be granted an environment key yet. | | `seekrit org invite list [--org ]` | Outstanding invitations. | | `seekrit org invite add [--role admin\|member]` | Invite someone. Membership is created when they first sign in, so they will not appear in `member list` until then. | | `seekrit org invite rm ` | Rescind an invitation. | | `seekrit org mfa [--set required\|optional]` | Show — or change — the org-wide second-factor requirement. Reports "no identity provider configured" on a deployment without one. | ### Applications (`seekrit app`) | Command | Description | | --- | --- | | `seekrit app list [--org ]` | Applications in an org. | | `seekrit app show [slug]` | One application, its environments, and **whether you hold a key for each** — `no key` is why `secrets get` would fail there. Lists live branches too. | | `seekrit app create [--org ] --name --slug ` | Create an application. | | `seekrit app rename [slug] --name ` | Change the display name. | | `seekrit app rm [--yes]` | Delete an application, its environments, and every secret in them. Names the number of environments before asking. Cannot be undone. | ### Environments (`seekrit env`) | Command | Description | | --- | --- | | `seekrit env list [--app ]` | An application's environments and your access to each. | | `seekrit env show --env [--app ]` | One environment: its composed groups in precedence order, who holds a key for it (admins only), its secret count, and its branches. | | `seekrit env create [--org ] --app --name --slug ` | Create an application environment. Generates the environment's data key locally and wraps it to your public key. | | `seekrit env rm --env [--app \|--group ] [--yes]` | Delete an environment and every secret in it. Names the secret count before asking. Works for group environments too, via `--group`. | ### `seekrit branch create` ` [--org ] [--app ] --from [--name ] [--ttl ] [--no-share]` — fork an application environment into an ephemeral **branch** (a per-PR / preview config). The branch inherits its parent's secrets by layering at read time — nothing is copied or re-encrypted — and stores only what you override on it. Generates the branch's own data key locally and, unless `--no-share`, wraps it to everyone who already holds a grant on the parent. `--ttl` accepts `30m`, `12h`, `7d`, `2w`, … or `never` (default `7d`, max 30 days). Expired branches are deleted automatically, along with their overrides, key grants, and any service token bound to them. ### `seekrit branch list` `[--org ] [--app ] [--env ]` — list branches in an application, or just those of one environment. Prints `slug`, id, and expiry. ### `seekrit branch delete` ` [--org ] [--app ]` — tear down a branch and every value it overrode. The environment it overlays is untouched. Aliased as `branch rm`. ### Shared groups (`seekrit group`) A **group** is a reusable secret bag shared across applications, holding one environment per slug (its variants). | Command | Description | | --- | --- | | `seekrit group list [--org ]` | Groups in an org. | | `seekrit group show ` | One group and the environments it holds, with your access to each. | | `seekrit group create [--org ] --name --slug ` | Create a group. | | `seekrit group rename --name ` | Change the display name. | | `seekrit group rm [--yes]` | Delete a group, its environments, and their secrets. Every application environment composing it stops receiving these values on its next resolve. | | `seekrit group env list --group ` | The group's environments. | | `seekrit group env create --group --name --slug ` | Create a group environment (a per-slug value set / variant). Generates its data key locally. | ## Composition (`env groups`) Compose shared groups into an application environment. At resolve time each group is matched to the environment whose slug matches the app environment's (or a `--with` override). | Command | Description | | --- | --- | | `seekrit env groups add --app --env --group [--position ]` | Compose a group (higher position wins). | | `seekrit env groups list --app --env ` | List composed groups, lowest precedence first. | | `seekrit env groups rm --app --env --group ` | Remove a group. | ## Secrets Every `secrets` command targets one environment via flags: an application environment (`--app --env`, or the config's app + `--env`) or a group environment (`--group --env`). `--org` is inferred from `seekrit.json` or a lone org. | Command | Description | | --- | --- | | `seekrit secrets list --env [--app \|--group ]` | List secret names, versions, update times (no values). `--json` prints the same metadata — never a value, and never the stored ciphertext. | | `seekrit secrets get --env [--raw] [--version ] [--pretty] …` | Decrypt and print one value. `${OTHER_SECRET}` [references](/docs/guides/references) are expanded against that environment's own secrets; `--raw` prints the stored text. `--version` prints an earlier version instead of the current one, always as stored. `--pretty` re-indents the value if it is JSON and leaves it untouched if it is not — for reading, not for piping. | | `seekrit secrets set [value] --env [--file ] …` | Encrypt and store a value. Reads stdin if `value` is omitted or `-`; `--file` reads it from a file. Both drop one trailing newline and store the rest byte-for-byte — the way to store a JSON credential or a PEM key without shell quoting. | | `seekrit secrets import [file] --env [--dry-run] …` | Bulk-import a `.env` file (default `.env`; `-` reads stdin). Each `KEY=VALUE` is encrypted and stored; existing names are overwritten. Aborts before writing if any name is invalid; `--dry-run` lists what would change (marking each `new`/`update`) without writing. | | `seekrit secrets history [--limit ] --env …` | List the secret's versions — when each was saved, who saved it, and which ones were restores. Never prints values (default 20, max 200). | | `seekrit secrets restore --env …` | Roll the secret back to an earlier version. | | `seekrit secrets rm --env …` | Delete a secret, and with it every earlier version. | ### JSON and multi-line values A value is an opaque string to seekrit — a service-account key, a PEM block, or a certificate chain is stored and delivered byte-for-byte. The only thing that needs care is getting it *in* without a shell or a `.env` file mangling it first. Read it from a file and neither is involved: ```bash seekrit secrets set GOOGLE_SERVICE_ACCOUNT --file ./service-account.json \ --app storefront --env production cat ./key.pem | seekrit secrets set TLS_KEY - --app storefront --env production ``` Both drop a single trailing newline and store everything else exactly as written. To read one back formatted: ```bash seekrit secrets get GOOGLE_SERVICE_ACCOUNT --pretty --app storefront --env production ``` `--pretty` only re-indents values that parse as JSON; anything else prints unchanged. Without it, output stays byte-exact, which is what you want when piping to a file or another tool. ### `.env` syntax `seekrit secrets import`, the `--env-file` overlays, and the dashboard's **paste .env** tab all use one parser — as does the [`seekrit-run`](#seekrit-run-launcher) launcher, byte-for-byte: | Syntax | Meaning | | --- | --- | | `KEY=value` | Unquoted: single-line, and a trailing ` # comment` is dropped. | | `KEY='value'` | Single quotes: **literal**. No escapes are interpreted, so a `\n` inside stays two characters. | | `KEY="value"` | Double quotes: `\n`, `\r`, `\t`, `\"`, and `\\` are interpreted. | | `export KEY=value` | The `export` prefix is ignored. | | `# comment` | Whole-line comments and blank lines are skipped. | A quoted value **may span lines** — it runs to its closing quote, wherever that lands. That is what makes a pretty-printed credential storable: ```bash GOOGLE_SERVICE_ACCOUNT='{ "type": "service_account", "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQ…\n-----END PRIVATE KEY-----\n" }' ``` Single quotes are the right choice for JSON: the value is full of double quotes and `\n` escapes that must survive as written, and single quotes pass them through untouched. > **Warning:** An **unquoted** value stops at the end of its line. Paste a multi-line JSON credential without quotes and the lines below it are read as further assignments — you get `KEY={` plus a handful of garbage names, not an error. Quote it, or skip the file entirely and use `--file`. `seekrit export --format dotenv` writes this same syntax and quotes as needed, so exporting and re-importing a value round-trips exactly, escapes included. ### Point-in-time restore Every write appends to the secret's history, so a bad value is always one command away from being undone: ```bash seekrit secrets history DATABASE_URL --app storefront --env production ``` ``` VERSION WHEN BY NOTE v3 2026-07-24T18:22:05.994Z user:usr_KvxTL40S… current v2 2026-07-24T18:19:41.769Z user:usr_KvxTL40S… v1 2026-07-24T18:02:13.539Z user:usr_KvxTL40S… ``` Check what you're rolling back to, then roll back: ```bash seekrit secrets get DATABASE_URL --version 2 --app storefront --env production seekrit secrets restore DATABASE_URL 2 --app storefront --env production ``` Restoring is a **roll-forward**: v2's value is written back as v4, so nothing in the history is lost and the rollback is itself undoable. Because the server simply replays ciphertext it already stores, no key is involved — a machine (M2M) credential that cannot decrypt the value can still roll it back. The restore is audited as `secret.restored`. > **Note:** Deleting a secret deletes its history with it. After `seekrit secrets rm` there is nothing left to restore. ## Running & exporting Both resolve a **layered** environment for the current principal: ``` group secrets < app-env secrets < .env file < process env (highest wins) ``` With a service token, org/app/env come from the token. As a logged-in user, pass `--app --env` (and `--org` if ambiguous). Once the layers are merged, `${OTHER_SECRET}` [references](/docs/guides/references) inside values are expanded — locally, in the same process that decrypted them. | Flag | Applies to | Description | | --- | --- | --- | | `--branch ` | run, export | Read an ephemeral [branch](/docs/guides/branches) of the target environment. Defaults to `SEEKRIT_BRANCH` (process env, then `.env`). | | `--with =` | run, export | Resolve one group at a different slug for this invocation (repeatable). | | `--env-file ` | run, export | A `.env` file to overlay; repeatable; defaults to `.env`. | | `--explain` | run, export | Print each variable's source layer to stderr (never values). Marks variables whose `${OTHER_SECRET}` references were expanded, and lists references that matched nothing. | | `--no-interpolate` | run, export | Leave `${OTHER_SECRET}` [references](/docs/guides/references) as literal text. | | `--cache` | run, export | Keep a [last-known-good](#last-known-good-cache) copy of the encrypted response and fall back to it when the API is unreachable. Off by default. | | `--cache-dir ` | run, export | Where to keep it. Defaults to `$XDG_CACHE_HOME/seekrit`, else `~/.cache/seekrit`. | | `--cache-max-age ` | run, export | How stale that copy may be and still be used — `15m`, `24h`, `7d`. Defaults to `24h`. | ### `seekrit run -- ` Run a command with the resolved environment injected. Everything after `--` is the command. ```bash SEEKRIT_TOKEN=skt_… seekrit run -- ./start-server # swap only the auth group to its staging slice for this boot: seekrit run --with auth-providers=staging -- pnpm dev ``` `run` reads its `.env` file(s) before authenticating, so `SEEKRIT_TOKEN` and `SEEKRIT_API_URL` can live in `.env` — resolved as `process.env` > `.env` > saved config, matching the `seekrit-run` launcher. Drop a token into a project's `.env` and `seekrit run` uses it with no global login. Resolving seekrit secrets is **best-effort**: if no credentials are configured or seekrit can't be reached (network, auth, or decryption failure), `run` logs a warning to stderr and still launches the command with just the `.env` overlay and `process.env`. This mirrors the [`seekrit-run` launcher](#seekrit-run-launcher). (`seekrit export` does not degrade — it errors if it can't resolve the secrets.) `run` is a transparent wrapper: it forwards termination signals (`SIGINT` from Ctrl-C, plus `SIGTERM`/`SIGHUP`/`SIGQUIT`) to the command **and every process below it**, then stays alive until the command exits and exits with its status (signal death is re-raised, so Ctrl-C yields `130`). Relaying down the whole process tree, rather than to the one process, is what makes Ctrl-C reliably tear down commands that spawn their own children (`npm`/`pnpm` scripts running `nodemon`, for example), since those don't always pass the signal on themselves. The command keeps your terminal: `run` does not put it in a separate session, so it stays visible in `ps` as usual, still gets the terminal's own Ctrl-C and its hangup when you close the window, and can still prompt on `/dev/tty` for things like a `git` credential or an SSH passphrase. **Nothing the command started outlives `run`.** Once the command itself has exited, anything still alive underneath it has both ignored the signal and lost the process that started it, so nothing is left to stop it — not the terminal either, which only ever hangs up its own foreground group. Rather than trust it, `run` escalates: `SIGTERM`, then `SIGKILL` two seconds later, printing the pids it had to force-kill. A process that ignores both is usually a signal handler in the app that cleans up but never exits — in Node, `process.on("SIGINT", …)` *replaces* the default action, so the process stops dying on Ctrl-C unless the handler itself exits. Watch for that if you see the message; it names the pids. A command that exits on its own is never chased this way, so if it deliberately leaves a daemon running in the background, that keeps working. The compiled `seekrit-run` launcher replaces itself with the command (`exec`), so signals and the process tree behave exactly as if you had run the command directly — with no wrapper left to escalate, cleaning up a command that ignores signals is up to the command. See its exit-code notes below. ### `seekrit export` Print the resolved secrets (managed layers + `.env`, without `process.env`). `--format ` (default `dotenv`). ## `seekrit-run` launcher `seekrit-run` is a separate, compiled single-file binary — a dependency-free `seekrit run` for machines (containers, CI, agents). It is **service-token only** and reproduces `seekrit run`'s precedence and `.env` parsing exactly. See the [launcher guide](/docs/guides/run) for install and container usage. ``` seekrit-run [OPTIONS] [--] [args...] seekrit-run run [OPTIONS] [--] [args...] # `run` is optional ``` | Flag | Default | Description | | --- | --- | --- | | `-t, --token ` | `SEEKRIT_TOKEN` (env or `.env`) | Service token. | | `--api-url ` | `SEEKRIT_API_URL` or `https://api.seekrit.dev` | API base URL. | | `-e, --env-file ` | `.env` | A `.env` file to overlay (repeatable). | | `--no-env-file` | | Do not load the default `.env`. | | `--branch ` | `SEEKRIT_BRANCH` (env or `.env`) | Read an ephemeral [branch](/docs/guides/branches) of the bound environment. | | `--with ` | | Override one composed group's slice (repeatable). | | `--explain` | | Print each variable's source to stderr (names only). | | `--no-interpolate` | | Leave `${OTHER_SECRET}` [references](/docs/guides/references) as literal text. | | `--cache` | off | Keep a [last-known-good](#last-known-good-cache) copy of the encrypted response and fall back to it when the API is unreachable. Also `SEEKRIT_CACHE=1`. | | `--no-cache` | | Override `SEEKRIT_CACHE=1` for this run. | | `--cache-dir ` | `SEEKRIT_CACHE_DIR`, else `$XDG_CACHE_HOME/seekrit` | Where to keep it. | | `--cache-max-age ` | `24h` | How stale that copy may be and still be used. | Like `seekrit run`, it degrades gracefully: a missing/malformed token or an unreachable API is logged to stderr, and the command runs with just `.env` + the live environment. Exit codes: `2` usage error, `1` a local failure (an unreadable explicit `--env-file`, or a reference cycle in one), `127` command not found; otherwise the command's own exit code (Unix `exec`). Honors `HTTPS_PROXY` / `ALL_PROXY`. Its default API URL is the hosted `https://api.seekrit.dev` — the same default the Node `seekrit run` uses when nothing else is configured. ## Last-known-good cache Off by default. With `--cache`, a successful resolve is written to disk and a later run **falls back to it when the seekrit API cannot be reached** — so a deploy, a CI job, or an agent boot still works during an outage. ```bash seekrit run --cache -- ./start-server ``` ``` seekrit-run: could not reach the seekrit API: … — using cached secrets fetched 6m ago ``` **Only the encrypted response is stored** — ciphertext plus your token's wrapped data keys, the same bytes the API serves. Decrypting still requires the service token's private key, so the file is no more sensitive than the token sitting beside it; it is written `0600` inside a `0700` directory. The zero-knowledge [invariant](/docs/concepts/security) is untouched: nothing is written in plaintext, and nothing new is sent to the server. How it behaves: - **Live first, always.** Every invocation tries the API before touching the cache, so a recovered network is picked up immediately — the cache never serves a request that could have been answered fresh. - **A refused resolve does not fall back.** If the API answers `401`/`403`/`404`, the entry is **deleted** and the command fails as it would have anyway. Revoking a token still takes effect on the next run. Only "the API is unreachable" — a network failure, a `5xx`, or a rate limit — uses the cache. - **Bounded by `--cache-max-age`** (default `24h`). Past that, the entry is ignored and pruned. - **Scoped to the exact request.** The entry is keyed by API URL, token, `--branch`, and `--with` overrides, so a different environment or override never reads another's copy. - **Shared with `seekrit-run`.** Both use the same file format and directory, so whichever one runs first warms the cache for the other. Two trade-offs worth stating plainly, both consequences of a copy that outlives the network: - A **revoked token keeps working offline** until the entry expires. That window is exactly `--cache-max-age`, and it only applies while the API is unreachable (a reachable API that refuses the token clears the entry immediately). Set a shorter max-age if that window matters more to you than the outage coverage. - A run served from cache **makes no resolve call**, so it produces no `env.resolve_denied` audit entry. If you monitor for revoked credentials still in use, a cached client is invisible to that signal until it next reaches the API. For the long-lived integrations, the same cache is configured in their own way: `[cache] enabled = true` in the [egress proxy](/docs/guides/agent-proxy)'s config, and `cache.enabled=true` in the [Kubernetes chart](/docs/guides/kubernetes). Both also retry in the background and switch to live secrets as soon as the API answers. ## Access Who can decrypt an environment. Every command targets the environment with `--env` plus `--app` or `--group`. | Command | Description | | --- | --- | | `seekrit grant --env --user \|--token ` | Give a member or service token the environment's key. The key is unwrapped on your machine and re-wrapped to the recipient, so the API only ever sees ciphertext. Members must have finished key setup (`seekrit org member list` shows who has). | | `seekrit grant list --env ` | Who currently holds a key, by email or token name rather than raw ids. Admins only. | | `seekrit grant rm --env --user \|--token [--yes]` | Take the key away. This removes their wrapped copy — **anything they already decrypted stays decrypted**, so rotate the value too if it may have leaked. Aliased as `grant revoke`. | `seekrit grant --user … --env …` is the bare grant verb and behaves exactly as it always has; `list` and `rm` are subcommands beside it. ## Service tokens | Command | Description | | --- | --- | | `seekrit token create --name --app --env [--allow =] [--no-grant]` | Mint a **runtime** token bound to an app environment; prints it once. Auto-grants that env's key and every composed group's matching slice. `--allow` pre-authorizes an alternate group slice for `run --with`; `--no-grant` skips granting. | | `seekrit token create --name --admin [--org ]` | Mint an **admin** token: org-scoped, no env binding, passes admin-gated routes (create apps/groups/envs, compose, grant, mint tokens). For headless provisioning by agents/automation. Only an admin caller may create one. | | `seekrit token list [--org ]` | List tokens with role, status, and last-used time. | | `seekrit token revoke [--org ]` | Revoke a token. Reversible-safe: a revoked token authenticates nothing and frees its plan slot, but stays in the list. | | `seekrit token delete [--org ]` | Delete a token and drop the keys granted to it. Only allowed once the token has been revoked. Cannot be undone. | An admin token can also be bound to an environment (pass `--admin --app --env`) to both provision structure **and** decrypt that environment. ## Honey tokens Decoy credentials that unlock nothing and alert your admins the moment anyone presents one. See the [honey tokens guide](/docs/guides/honey-tokens). | Command | Description | | --- | --- | | `seekrit honey-token create --name [--placement ] [--org ]` | Mint a decoy credential and print it once (stdout), so it can be piped straight into the file or variable you're baiting. `--placement` records where you planted it and is repeated back in the alert email. | | `seekrit honey-token list [--org ]` | List decoys with trip count, placement, and the time and source IP of the last trip. Aliased as `honey-token ls`. | | `seekrit honey-token delete [--yes] [--org ]` | Delete a decoy, which stops it alerting — pull the planted bait too. Trips already recorded stay in the audit log. Aliased as `honey-token rm`. | A decoy is byte-for-byte indistinguishable from a real `skt_` token, and presenting one returns exactly the same `401 unknown service token` an unregistered token gets — so an attacker probing credentials can't tell bait from a typo. Plant them where a thief would look, never anywhere your own tooling reads: a deploy script that tries one by mistake trips the alarm just as loudly. ## Agent integration ### `seekrit mcp` Run an [MCP](https://modelcontextprotocol.io) server over stdio so AI agents (Claude Code and other MCP clients) can drive seekrit as tools. It reads the same credentials as every other command — a `SEEKRIT_TOKEN`, machine credentials (`SEEKRIT_CLIENT_ID` + `SEEKRIT_CLIENT_SECRET`, which auto-mint an admin token), or the saved config. ```bash # Register with Claude Code (token selects the org; admin token enables provisioning): SEEKRIT_TOKEN=skt_… claude mcp add seekrit -- seekrit mcp ``` For a fully autonomous agent, pass machine credentials instead — the server mints and caches its own admin token from them (see the [AI agents guide](/docs/guides/ai-agents)): ```bash claude mcp add seekrit \ --env SEEKRIT_CLIENT_ID=… --env SEEKRIT_CLIENT_SECRET=… -- seekrit mcp ``` All decryption happens **locally**, in this process — the server exposes the tools that touch plaintext (secret values, data keys, decryption-capable grants), which is why it runs on your machine rather than a hosted endpoint. Call `get_started` first — an in-protocol tool (and the server's `instructions`, shown to the model on connect) that returns the recommended first-project recipe so an agent landing here mid-context can orient itself. Other tools include `create_org`/`create_app`/`create_env`, `set_secret`/`get_secret`, `create_token`, `grant_env`, and `run_command` (inject secrets into a subprocess without returning their values). Because stdin is the transport, user-auth sessions that decrypt need `SEEKRIT_PASSPHRASE` set (token auth needs nothing extra). Prefer `run_command` over `get_secret` with `reveal:true` so plaintext never enters the agent's context. ## Temporary Postgres credentials (`pg`) Mint short-lived database logins that auto-expire. Minting happens **client-side**: the password and its SCRAM verifier are generated on your machine and only the verifier is sent, so the plaintext never reaches seekrit or Postgres at rest. See [Temporary access](/docs/concepts/temporary-access). | Command | Description | | --- | --- | | `seekrit pg target add --name --host --database [--access readonly\|readwrite\|custom] [--schema public] [--port 5432] [--executor in_do\|remote] [--provisioner-url ] [--hmac-key ] [--admin-url ] [--create-statement ]…` | Register a provisioning target. `--access` (default `readonly`) sets what leased credentials can do; for the presets the command prints a one-time group-role setup query to run as admin. `custom` uses `--create-statement`/`--revoke-statement`. For `--executor in_do`, the admin connection string (or `SEEKRIT_PG_ADMIN_URL`) is wrapped to the broker locally; for `--executor remote`, pass the shared HMAC key via `--hmac-key` (or `SEEKRIT_PROVISIONER_HMAC_KEY`) instead — see the [self-hosted provisioner guide](/docs/guides/provisioner). *(admin)* | | `seekrit pg target list [--org ]` | List targets (id, name, connection, access level, executor). | | `seekrit pg target setup-sql [--org ]` | Reprint the group-role setup SQL for a preset target. | | `seekrit pg target rm [--org ]` | Remove a target. | | `seekrit pg lease [--role ] [--ttl 1h] [--json]` | Mint a credential; prints a ready-to-use `postgres://` URL (the password is shown once and stored nowhere). `--ttl` accepts `30m`/`1h`/`7d`. | | `seekrit pg leases [--org ]` | List the lease ledger (status, role, expiry). | | `seekrit pg revoke [--org ]` | Revoke a lease now (drops the role immediately). | ```bash # Register a read-only target (prints the group-role setup SQL to run once): SEEKRIT_PG_ADMIN_URL=postgres://admin:…@db.example.com:5432/app \ seekrit pg target add --name prod-db --host db.example.com --database app --access readonly # Lease a 30-minute credential and hand the URL straight to psql: psql "$(seekrit pg lease prod-db --ttl 30m)" ``` ## Temporary MySQL / MariaDB credentials (`mysql`) The MySQL/MariaDB analog of `pg`. Minting is client-side too: the password and its `mysql_native_password` hash are generated on your machine and only the hash is sent, so the plaintext never reaches seekrit or MySQL at rest. Presets apply their `GRANT`s inline per user (no one-time setup step), and there is no account-level expiry — the broker drops the user at the deadline. See [Temporary access](/docs/concepts/temporary-access). | Command | Description | | --- | --- | | `seekrit mysql target add --name --host --database [--access readonly\|readwrite\|custom] [--user-host %] [--port 3306] [--executor in_do\|remote] [--provisioner-url ] [--hmac-key ] [--admin-url ] [--create-statement ]…` | Register a provisioning target. `--access` (default `readonly`) sets what leased credentials can do; `custom` uses `--create-statement`/`--revoke-statement`. `--user-host` is the host part of created accounts (`'name'@''`, default `%`). For `--executor in_do`, the admin connection string (or `SEEKRIT_MYSQL_ADMIN_URL`) is wrapped to the broker locally; for `--executor remote`, pass the shared HMAC key via `--hmac-key` (or `SEEKRIT_PROVISIONER_HMAC_KEY`) instead — see the [self-hosted provisioner guide](/docs/guides/provisioner). *(admin)* | | `seekrit mysql target list [--org ]` | List MySQL targets (id, name, connection, access level, executor). | | `seekrit mysql target rm [--org ]` | Remove a target. | | `seekrit mysql lease [--user ] [--ttl 1h] [--json]` | Mint a credential; prints a ready-to-use `mysql://` URL (the password is shown once and stored nowhere). `--ttl` accepts `30m`/`1h`/`7d`. | | `seekrit mysql leases [--org ]` | List the MySQL lease ledger (status, user, expiry). | | `seekrit mysql revoke [--org ]` | Revoke a lease now (drops the user immediately). | ```bash # Register a read-only target (no setup SQL needed — grants apply inline): SEEKRIT_MYSQL_ADMIN_URL=mysql://admin:…@db.example.com:3306/app \ seekrit mysql target add --name prod-db --host db.example.com --database app --access readonly # Lease a 30-minute credential and hand the URL straight to the mysql client: mysql "$(seekrit mysql lease prod-db --ttl 30m)" ``` ## Temporary Redis credentials (`redis`) The Redis (6+) analog of `pg`/`mysql`. Minting is client-side too: the password and its SHA-256 digest are generated on your machine and only the digest is sent (`ACL SETUSER … on #`), so the plaintext never reaches seekrit or Redis at rest. Presets apply their ACL rules inline per user (no one-time setup step), and there is no account-level expiry — the broker deletes the ACL user at the deadline. See [Temporary access](/docs/concepts/temporary-access). | Command | Description | | --- | --- | | `seekrit redis target add --name --host [--access readonly\|readwrite\|custom] [--port 6379] [--db ] [--executor in_do\|remote] [--provisioner-url ] [--hmac-key ] [--admin-url ] [--create-statement ]…` | Register a provisioning target. `--access` (default `readonly`) sets what leased credentials can do; `custom` uses `--create-statement`/`--revoke-statement` (each a Redis command line, `{{name}}`/`{{verifier}}` templated). `--db` is the logical database index used in the printed URL. For `--executor in_do`, the admin `redis://`/`rediss://` connection string (or `SEEKRIT_REDIS_ADMIN_URL`) is wrapped to the broker locally; for `--executor remote`, pass the shared HMAC key via `--hmac-key` (or `SEEKRIT_PROVISIONER_HMAC_KEY`) instead — see the [self-hosted provisioner guide](/docs/guides/provisioner). *(admin)* | | `seekrit redis target list [--org ]` | List Redis targets (id, name, connection, access level, executor). | | `seekrit redis target rm [--org ]` | Remove a target. | | `seekrit redis lease [--user ] [--ttl 1h] [--json]` | Mint a credential; prints a ready-to-use `redis://` URL (the password is shown once and stored nowhere). `--ttl` accepts `30m`/`1h`/`7d`. | | `seekrit redis leases [--org ]` | List the Redis lease ledger (status, user, expiry). | | `seekrit redis revoke [--org ]` | Revoke a lease now (deletes the ACL user immediately). | ```bash # Register a read-only target (ACL rules apply inline, no setup step): SEEKRIT_REDIS_ADMIN_URL=rediss://default:…@cache.example.com:6379 \ seekrit redis target add --name prod-cache --host cache.example.com --access readonly # Lease a 30-minute credential and hand the URL straight to redis-cli: redis-cli -u "$(seekrit redis lease prod-cache --ttl 30m)" ``` ## Agent egress proxy (`proxy`) Fetch, configure, and run [`seekrit-proxy`](/docs/guides/agent-proxy) without a Rust toolchain and without hand-writing a config file. The proxy itself is unchanged by these commands — they resolve the released binary (verifying its SHA-256) and generate the same TOML you would otherwise write. | Command | Description | | --- | --- | | `seekrit proxy run` | Fetch the binary if needed and run it. With `--preset`/`--host`/`--agent` it generates a config on the fly and leaves nothing behind. | | `seekrit proxy init` | Write a reviewable `seekrit-proxy.toml`. | | `seekrit proxy presets` | List the ready-made upstream presets. | | `seekrit proxy compose` | Print a `docker compose` sidecar snippet for a generated config. | | `seekrit proxy install` | Download the binary and print its path. | | `seekrit proxy where` | Show which binary `run` would use, without fetching it. | ### Generating a config `init` and `run` share these flags. Pass presets, ad-hoc hosts, or an agent identity — the last takes the rules from [published policy](/docs/guides/agent-proxy/policy) and cannot be combined with the first two, because server-policy mode rejects local rules rather than silently ignoring them. | Flag | Default | Description | | --- | --- | --- | | `--preset ` | | A preset from `seekrit proxy presets` (repeatable). | | `--host ` | | Ad-hoc rule: a bare hostname and, optionally, the secrets that may reach it (repeatable). Omitting `=SECRET` permits the operation without letting a credential travel with it. | | `--base-url ` | | Upstream base URL for an OpenAI-compatible gateway. Required by the `openai-compatible` preset. | | `--secret ` | preset's own | Override a preset's secret name. | | `--prefix ` | preset's own | Override a preset's route prefix. | | `--agent ` | | Take the rules from published agent policy (server mode). | | `--agents ` | the `--agent` one | Additional identities this proxy may serve, for [session tickets](/docs/guides/agent-proxy#several-agents-behind-one-proxy) (repeatable). | | `--org ` | | Organization, for `--agent`. | | `--mode ` | `reverse` | Which data plane(s) to configure. | | `--listen ` | `127.0.0.1:8080` | Reverse-proxy address. | | `--forward-listen ` | `127.0.0.1:8081` | Forward-proxy address. | | `--unmatched ` | `tunnel` | What to do with an unruled host in forward mode. | | `--ca-cert ` / `--ca-key ` | `seekrit-proxy-ca[-key].pem` | Interception CA paths (forward mode). | | `--cache` | off | Add a `[cache]` block so the proxy can start during a seekrit outage. | | `--cache-max-age ` | `24h` | How stale a cached resolve may be (implies `--cache`). | | `--refresh ` | `30s` file / `10s` server | Re-resolve (and, in server mode, re-fetch) interval. | | `--control ` | | Add a `[control]` listener for per-agent session tickets. | `init` additionally takes `-o, --out ` (default `./seekrit-proxy.toml`), `--print` to write to stdout instead, and `--force` to overwrite. `run` additionally takes `-c, --config `, `--proxy-version `, and `--print-config`. With no generation flags it runs the config file as-is. ### Resolving the binary | Variable | Default | Purpose | | --- | --- | --- | | `SEEKRIT_PROXY_BIN` | | Path to a binary you already have. Skips the download entirely. | | `SEEKRIT_PROXY_VERSION` | the CLI's pinned version | Version to fetch. `latest` is re-resolved every run rather than cached. | | `SEEKRIT_PROXY_BASE_URL` | `https://proxy.seekrit.dev` | Where artifacts come from. | Downloads are cached under `$XDG_CACHE_HOME/seekrit/proxy/v//`, so the fetch happens once per version. A binary already on `PATH` is deliberately **not** used: silently running a different version than the one the CLI pins is the kind of surprise that costs an afternoon. > **Note:** `seekrit proxy` never sees a secret *value* — it generates config and launches a process. The proxy resolves and decrypts on its own, with its own `SEEKRIT_TOKEN`, which is also why that token must live somewhere the workload cannot read. ## Self-hosted provisioner (`provisioner`) Helpers for the [remote executor](/docs/guides/provisioner) — the `seekrit-provisioner` daemon that runs a target's provisioning SQL inside your own network, so seekrit never sees the database admin credential. | Command | Description | | --- | --- | | `seekrit provisioner keygen` | Generate a shared HMAC key (base64) for a `remote` target. Use the same value for `--hmac-key` when registering the target and for the daemon's `SEEKRIT_PROVISIONER_HMAC_KEY`. | ## Temporary SSH access (`ssh`) Issue short-lived SSH **certificates**. seekrit acts as a certificate authority: `target add` generates a CA keypair locally (only the private half is wrapped and uploaded) and prints the public key to install on your hosts. Minting generates an ephemeral keypair on your machine and sends only the public key; the signed certificate comes back and the private key never leaves. See [Temporary access](/docs/concepts/temporary-access). | Command | Description | | --- | --- | | `seekrit ssh target add --name [--host ] [--user ] [--principal ]… [--extension ]… [--max-ttl ]` | Create an SSH CA target. Generates the CA locally, wraps its private key to the broker, and prints the CA public key + one-time host setup (`TrustedUserCAKeys`). `--principal` allow-lists which login users a cert may request (blank = any); `--host`/`--user` seed the printed `ssh` command. *(admin)* | | `seekrit ssh target list [--org ]` | List SSH targets (id, name, host, allowed principals). | | `seekrit ssh target setup [--org ]` | Reprint the host setup instructions for a target. | | `seekrit ssh target rm [--org ]` | Remove a target. | | `seekrit ssh lease [--principal ]… [--ttl 1h] [--out ] [--json]` | Mint a certificate; writes `id_ed25519` + `id_ed25519-cert.pub` and prints a ready-to-run `ssh` command. `--principal` defaults to the target's user/allow-list. `--ttl` accepts `30m`/`1h`/`8h`. | | `seekrit ssh leases [--org ]` | List the SSH lease ledger (status, expiry). | | `seekrit ssh revoke [--org ]` | Mark a lease revoked in the ledger. The issued certificate stays valid until it expires (short TTLs are the control). | ```bash # Create a CA target and install the printed CA key on your hosts: seekrit ssh target add --name prod-fleet --host bastion.example.com --user deploy --principal deploy # Issue an 8-hour cert, then run the ssh command it prints: seekrit ssh lease prod-fleet --ttl 8h ``` ## Temporary AWS credentials (`aws`) Mint short-lived AWS credentials via STS **AssumeRole** (a tier-2 provider — the credential comes back **wrapped to your machine's ephemeral key**, never in the clear through seekrit). `target add` registers one assumable IAM role and wraps a base IAM credential (needs only `sts:AssumeRole`) to the broker. Minting generates an ephemeral P-256 keypair locally, sends only the public key, and unwraps the returned credential on your machine. See [Temporary access](/docs/concepts/temporary-access). | Command | Description | | --- | --- | | `seekrit aws target add --name --role-arn --region [--external-id ] [--session-policy ] [--max-ttl ] [--access-key-id ] [--secret-access-key ]` | Register an AWS role target. The base IAM credential comes from the flags or `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` (and `AWS_SESSION_TOKEN` if set) and is wrapped to the broker locally. `--session-policy` further restricts the leased credential; `--max-ttl` clamps requested lifetime. Prints the IAM trust policy the role needs. *(admin)* | | `seekrit aws target list [--org ]` | List AWS targets (id, name, region, role ARN). | | `seekrit aws target trust [--org ]` | Reprint the IAM trust-policy setup for a target. | | `seekrit aws target rm [--org ]` | Remove a target. | | `seekrit aws lease [--ttl 1h] [--json]` | Mint credentials; prints `export AWS_…` lines (or JSON with `--json`). `--ttl` accepts `15m`–`12h` (STS minimum is 15 minutes; clamped to the role's `MaxSessionDuration`). | | `seekrit aws leases [--org ]` | List the AWS lease ledger (status, expiry). | | `seekrit aws revoke [--org ]` | Mark a lease revoked in the ledger. The issued credential stays valid until it expires (short TTLs are the control). | ```bash # Register a role (base creds from your AWS env vars), then source a 1h credential: seekrit aws target add --name prod-deploy \ --role-arn arn:aws:iam::123456789012:role/seekrit-deploy --region us-east-1 eval "$(seekrit aws lease prod-deploy --ttl 1h)" aws sts get-caller-identity ``` ## Temporary GCP credentials (`gcp`) Mint short-lived GCP access tokens via IAM **`generateAccessToken`** (a tier-2 provider — the token comes back **wrapped to your machine's ephemeral key**, never in the clear through seekrit). `target add` registers one impersonable service account and wraps a base service-account key (needs `roles/iam.serviceAccountTokenCreator` on the target) to the broker. Minting generates an ephemeral P-256 keypair locally, sends only the public key, and unwraps the returned token on your machine. See [Temporary access](/docs/concepts/temporary-access). | Command | Description | | --- | --- | | `seekrit gcp target add --name --service-account [--scope …] [--delegate …] [--max-ttl ] [--key-file ]` | Register a GCP service-account target. The source SA key JSON comes from `--key-file` or `GOOGLE_APPLICATION_CREDENTIALS` and is wrapped to the broker locally. `--scope` (repeatable) sets the OAuth scopes (default `cloud-platform`); `--max-ttl` clamps requested lifetime. Prints the IAM binding the target needs. *(admin)* | | `seekrit gcp target list [--org ]` | List GCP targets (id, name, service account). | | `seekrit gcp target setup [--org ]` | Reprint the IAM setup for a target. | | `seekrit gcp target rm [--org ]` | Remove a target. | | `seekrit gcp lease [--ttl 1h] [--json]` | Mint a token; prints `export CLOUDSDK_AUTH_ACCESS_TOKEN` / `export GOOGLE_OAUTH_ACCESS_TOKEN` lines (or JSON with `--json`). `--ttl` accepts `1m`–`12h` (tokens over 1h need the credential-lifetime-extension org policy). | | `seekrit gcp leases [--org ]` | List the GCP lease ledger (status, expiry). | | `seekrit gcp revoke [--org ]` | Mark a lease revoked in the ledger. The issued token stays valid until it expires (short TTLs are the control). | ```bash # Register a service account (source key from GOOGLE_APPLICATION_CREDENTIALS), # then source a 1h token: seekrit gcp target add --name prod-deploy \ --service-account deploy@my-project.iam.gserviceaccount.com eval "$(seekrit gcp lease prod-deploy --ttl 1h)" gcloud storage ls ``` ## Temporary MongoDB credentials (`mongodb`) Mint short-lived MongoDB users (a tier-2 provider — MongoDB hashes the password server-side, so the broker generates it, runs `createUser`, and returns the credential **wrapped to your machine's ephemeral key**, never in the clear through seekrit). `target add` registers one cluster and wraps an admin `mongodb://` connection string (needs `userAdmin` on the database) to the broker. Minting generates an ephemeral P-256 keypair locally, sends only the public key, and unwraps the returned credential on your machine. Unlike AWS, revoke is real — it drops the user. See [Temporary access](/docs/concepts/temporary-access). | Command | Description | | --- | --- | | `seekrit mongodb target add --name --database [--uri ] [--access readonly\|readwrite\|custom] [--role …] [--auth-source ] [--max-ttl ] [--no-tls]` | Register a MongoDB target. The admin connection string comes from `--uri` or `SEEKRIT_MONGODB_ADMIN_URL` and is wrapped to the broker locally. `--access` picks the built-in `read`/`readWrite` role on `--database` (or `custom` with one or more `--role role@db`); `--max-ttl` clamps requested lifetime. Prints the provisioning-user setup. *(admin)* | | `seekrit mongodb target list [--org ]` | List MongoDB targets (id, name, host:port, database, access). | | `seekrit mongodb target rm [--org ]` | Remove a target. | | `seekrit mongodb lease [--ttl 1h] [--json]` | Mint a user; prints `export MONGODB_URI='…'` (or JSON with `--json`). `--ttl` accepts `60s`–`7d`, clamped to the target's `--max-ttl`. | | `seekrit mongodb leases [--org ]` | List the MongoDB lease ledger (status, expiry). | | `seekrit mongodb revoke [--org ]` | Revoke a lease now — drops the MongoDB user immediately. | ```bash # Register a cluster (admin creds from SEEKRIT_MONGODB_ADMIN_URL), then lease a 1h user: export SEEKRIT_MONGODB_ADMIN_URL='mongodb://admin:pw@mongo.example.com:27017/?authSource=admin' seekrit mongodb target add --name prod-app --database app --access readonly eval "$(seekrit mongodb lease prod-app --ttl 1h)" mongosh "$MONGODB_URI" ``` ## Managed keys (`kms`) Client-side managed keys for application-layer encryption and signing. Material is generated locally and wrapped to grantees; the server never sees it. See the [Managed keys guide](/docs/guides/kms). | Command | Description | | --- | --- | | `seekrit kms create --name --purpose [--org ] [--app \|--group ] [--grant-user …] [--grant-token …]` | Create a key. Generates material locally, self-grants, and optionally grants others. `encrypt` = AES-256-GCM; `sign` = ECDSA P-256 (its public key is published). Scope to an app or group, or leave org-wide. | | `seekrit kms ls [--org ]` | List keys you can see (admins see all; members see granted keys). | | `seekrit kms grant --key (--user \|--token ) [--org ]` | Grant a principal the current version. | | `seekrit kms revoke --key (--user \|--token ) [--org ]` | Revoke a principal from the key (all versions). | | `seekrit kms rotate --key [--org ]` | Add a new version and re-wrap it for every current grantee. Old versions stay valid. | | `seekrit kms disable --key [--org ]` | Block all use: encrypt, decrypt, sign, and new grants/rotations. | | `seekrit kms delete --key [--org ]` | Delete a key — hides it from every listing and read path; the name frees up for reuse. | | `seekrit kms encrypt --key [--context ] [--org ]` | Encrypt stdin → a `ce1.` blob. `--context` is bound as AAD and must match on decrypt. | | `seekrit kms decrypt --key [--context ] [--org ]` | Decrypt a `ce1.` blob from stdin. | | `seekrit kms generate-data-key --key [--org ]` | Print `{ plaintextBase64, wrapped }` — an envelope data key for large payloads. | | `seekrit kms open-data-key --key [--org ]` | Recover a data key from a `dk1.` blob on stdin (prints base64). | | `seekrit kms sign --key [--org ]` | Sign stdin with a signing key → an `sg1.` signature. | | `seekrit kms verify --key --signature [--org ]` | Verify an `sg1.` signature over stdin (exit 0 = valid). Needs only the published public key. | ```bash # Encrypt a field, bound to its context, then read it back: echo -n "$SSN" | seekrit kms encrypt --key pii --context "field=ssn" > ssn.enc seekrit kms decrypt --key pii --context "field=ssn" < ssn.enc # Sign a release and verify it: echo -n "release-v1.2.3" | seekrit kms sign --key release-signer > r.sig echo -n "release-v1.2.3" | seekrit kms verify --key release-signer --signature "$(cat r.sig)" ``` ## Secret rotation (`rotation`) Managed, scheduled replacement of a stored secret's value — and, for the database kinds, of the password on the account it belongs to. *(admin)*. Enabling rotation wraps the environment's data key to your org's rotator key locally; see the [rotation guide](/docs/guides/rotation) and [Secret rotation](/docs/concepts/rotation). Every command accepts a rotation id (`rot_…`) or the secret's name when that name rotates in only one environment. Durations accept `s`/`m`/`h`/`d` suffixes (minimum 5 minutes, maximum 365 days). | Command | Description | | --- | --- | | `seekrit rotation enable --env --kind --every [--app \|--group ] [--target ] [--username ] [--user-host ] [--length ] [--alphabet ] [--now] [--org ]` | Configure rotation for an existing secret. `--target` + `--username` are required for the database kinds and name an account that **already exists**. `--alphabet` (`generated` only) is `alphanumeric`, `hex`, `base64url`, or `printable`. `--now` also rotates immediately. Prints the `rot_…` id. *(admin)* | | `seekrit rotation list [--org ] [--json]` | List policies: id, secret, kind, cadence, status, next run. Never values. *(admin)* | | `seekrit rotation show [--org ]` | One policy in full, including `lastError` from the last failed attempt. *(admin)* | | `seekrit rotation rotate [--org ]` | Rotate now — the same path the scheduler uses. Prints the new version. *(admin)* | | `seekrit rotation pause [--org ]` | Stop rotating, keeping the policy. *(admin)* | | `seekrit rotation resume [--org ]` | Resume rotating; also clears a failed streak. *(admin)* | | `seekrit rotation set-interval --every [--org ]` | Change the cadence. *(admin)* | | `seekrit rotation disable [--org ]` | Remove the policy. The secret and its versions are untouched; the rotator's key grant is dropped when the environment has no rotation left. *(admin)* | ```bash # Re-key an existing Postgres role weekly, against a registered target: seekrit rotation enable DATABASE_PASSWORD --app web --env production \ --kind postgres --target prod-db --username app_user --every 7d --now # A value only your own code checks — nothing external is contacted: seekrit rotation enable API_SIGNING_KEY --app web --env production \ --kind generated --every 30d seekrit rotation list seekrit rotation rotate DATABASE_PASSWORD # e.g. after a suspected exposure ``` ## Customer-controlled recovery (`recovery`) Org-scoped M-of-N recovery. The recovery key is generated and split locally; seekrit stores only its public half and shares it cannot open. Management is *(admin)*; any custodian can approve a ceremony. See the [recovery guide](/docs/guides/recovery). | Command | Description | | --- | --- | | `seekrit recovery setup --threshold --custodian … [--org ]` | Enable recovery: split a fresh recovery key across the custodians, then cover the environments you can decrypt. *(admin)* | | `seekrit recovery status [--org ]` | Show the threshold, custodians, and environment coverage. *(admin)* | | `seekrit recovery sync [--org ]` | Recovery-protect environments you can decrypt but that aren't yet covered. *(admin)* | | `seekrit recovery rotate --threshold --custodian … [--org ]` | Rotate to a fresh recovery key and custodian set; re-wraps the environments you can decrypt. *(admin)* | | `seekrit recovery disable [--org ]` | Remove the recovery key and every recovery grant. *(admin)* | | `seekrit recovery request [--target-user \|--target-token ] [--reason ] [--org ]` | Start a recovery ceremony (defaults to recovering access for yourself). Prints an `rrq_…` id. *(admin)* | | `seekrit recovery approve [--org ]` | As a custodian, unwrap your share and contribute it, re-wrapped to the target. | | `seekrit recovery complete [--org ]` | As the target, reconstruct the recovery key and restore your environment access. *(admin)* | | `seekrit recovery cancel [--org ]` | Cancel an open recovery request. *(admin)* | ```bash # Enable 3-of-5 recovery, then check coverage: seekrit recovery setup --threshold 3 \ --custodian alice@example.com --custodian bob@example.com --custodian carol@example.com \ --custodian dan@example.com --custodian erin@example.com seekrit recovery status # Run a ceremony: start it, custodians approve, then the target completes. seekrit recovery request # prints rrq_… seekrit recovery approve rrq_XXXXXXXX # each custodian, until the quorum is met seekrit recovery complete rrq_XXXXXXXX ``` ## Your account Your devices and your email preferences. Both are per-user, not per-org, so neither takes `--org`. | Command | Description | | --- | --- | | `seekrit session list [--all]` | The devices `seekrit login` has authorized, newest first, with the one you're calling from marked. Shows only live sessions unless you pass `--all` (which includes revoked and expired ones — the ledger is kept). | | `seekrit session revoke [--yes]` | Sign a device out; its token stops authenticating on its next request (revoking drops the cached copy as it commits, and any copy that outlives it is re-checked within a minute of use). Revoking the session you're currently using is allowed, and says so before it asks. | | `seekrit notifications` | Your per-type email preferences, on or off. | | `seekrit notifications set ` | Turn one notification on or off. An unknown type lists the valid ones. | ## Audit ### `seekrit audit` Print the org's audit trail — the append-only record of every mutation. Admins only. | Flag | Description | | --- | --- | | `--org ` | Which organization. | | `--limit ` | Entries per page (default 50, max 200). | | `--action ` | Only this action, e.g. `env.key_granted`. An unrecognized action is rejected rather than silently returning nothing. | | `--resource-type ` | Only this resource type, e.g. `environment`. | | `--cursor ` | Continue from the cursor a previous page printed. | | `--all` | Page through the whole trail rather than stopping after one page. | | `--metadata` | Add each entry's metadata as a JSON column. | When more entries remain, the next cursor is printed to stderr so it doesn't corrupt piped output. ### `seekrit audit actions` List every action the trail can record — the vocabulary for `--action`. ```bash # Everything that changed who can decrypt something, as JSON: seekrit audit --action env.key_granted --all --json ``` ## Audit export (`log-sink`) Ship every audit row to your own OTLP/HTTP collector (SIEM) within about a minute of it being written. Admins only. Header **values** are write-only — encrypted at rest and never returned — so `show` reports only the header names. | Command | Description | | --- | --- | | `seekrit log-sink` | The configured endpoint, whether it's enabled, the header names, and delivery health (last success, last attempt, last error). | | `seekrit log-sink set [--header "Name: value"] [--clear-headers] [--disabled]` | Point the export at an OTLP/HTTP logs endpoint. `--header` is repeatable and **replaces** the whole header set, so pass all of them each time. Passing none leaves the stored headers alone — that's how you change the endpoint without re-entering the credential; `--clear-headers` removes them. | | `seekrit log-sink test` | Send a probe and report the result. Exits non-zero on failure, so it works as a health check. | | `seekrit log-sink rm [--yes]` | Stop exporting and forget the endpoint. | ```bash seekrit log-sink set https://collector.example.com/v1/logs \ --header "Authorization: Bearer $SIEM_TOKEN" seekrit log-sink test ``` ## Third-party sync Push an environment's resolved secrets to a platform that keeps its own copy: Vercel project env vars, Cloudflare Worker secret bindings, Cloudflare Pages env vars, Cloudflare Secrets Store secrets, Railway service variables, AWS Secrets Manager secrets, AWS SSM parameters, a Render service or environment group, a Fly.io app's secrets, a Northflank secret group, DigitalOcean App Platform variables, Heroku config vars, Netlify site variables, Bunnyshell environment or project variables, GitHub Actions secrets, or Google Secret Manager secrets. Admins only. Each destination has its own guide under [Third-party sync](/docs/guides/third-party-sync). > **Warning:** Sync is the **one** place seekrit's servers hold plaintext: a destination needs the value and runs when nobody is logged in, so the sync engine decrypts in memory for the length of a push. Enabling it for an environment is therefore an explicit, audited decision — `sync enable` requires `--acknowledge-decryption` (or a yes at the prompt), and who acknowledged is recorded. Where the runtime lets you decrypt on your own side instead, prefer [`seekrit run`](#running--exporting), the proxy, or the SDKs. A **connection** is a destination account; a **binding** is one environment syncing to one place in it. | Command | Description | | --- | --- | | `seekrit sync connections` | Destination accounts, with status and last error. | | `seekrit sync connect --name [--provider ] [--team-id ] [--account-id ] [--token-kind ] [--region ] [--access-key-id ] [--base-url ] [--project-id ] [--langgraph-region ] [--langgraph-tenant ]` | Register an account. The credential is **read from stdin** (or prompted) — never a flag, so it can't land in shell history — and is wrapped to the connection's public key before it is sent. `--team-id` is Vercel's (omit for a personal account); `--account-id` is required for every Cloudflare provider and for Netlify, where it is the team slug (or account ID) whose environment variables the connection writes; `--token-kind` is Railway's (`account` or `project` — Railway sends the two in different headers); `--region` and `--access-key-id` for every AWS one; `--project-id` is `gcp-secret-manager`'s, the project whose Secret Manager the connection writes; `--base-url` is GitHub's and LangGraph Platform's, and only for a self-hosted install — a **GitHub Enterprise Server** appliance (`https://github.acme.com/api/v3`) or a self-hosted LangSmith control plane (`https://langsmith.acme.com/api-host`) — omit it for github.com, Enterprise Cloud, and a LangChain-hosted LangSmith, and note it must be `https`, since the URL carries the token; `--langgraph-region` picks which of LangChain's four control-plane hosts a `langgraph-platform` connection addresses (`us` by default, then `eu`, `apac`, `aws-us`) and matters because a key minted in one region is refused by another with a bare `401`, so the wrong one looks exactly like a bad credential — it is mutually exclusive with `--base-url`; `--langgraph-tenant` is the LangSmith workspace UUID, needed only for an organization-scoped key, which reaches several workspaces and is refused without it; `render`, `fly`, `northflank`, `digitalocean`, `heroku`, and `bunnyshell` take none of them — a Render API key is user-scoped, a Northflank token names its own team, a DigitalOcean token its own account, a Heroku token its user's access to every app they can reach, a Bunnyshell token its user's access to every organization they belong to, and a Fly token is pasted whole, `FlyV1` prefix included, because seekrit reads which auth scheme it takes from the token itself. On AWS the credential read from stdin is the **secret access key** — the access key id is an identifier, not a secret, so it is stored in the clear where the dashboard can show it. On GCP it is the whole **service-account key JSON**, the same credential shape `seekrit gcp target add` takes, so it is usually piped from the key file (`… < key.json`). | | `seekrit sync verify [--provider ] ` | Check the stored credential against a destination. Takes the same destination flags as `sync enable`. Exits non-zero on failure. | | `seekrit sync disconnect [--yes]` | Delete an account, its bindings, and its keypair. Values already pushed stay on the destination. | | `seekrit sync bindings` | What is syncing where, with mode, last run, and last error. | | `seekrit sync enable …` | Start syncing one environment (see below). | | `seekrit sync pause ` / `resume ` | Stop and restart pushing without deleting the binding. | | `seekrit sync disable [--yes]` | Delete a binding and revoke seekrit's key for that environment. Aliased as `sync rm`. | | `seekrit sync run ` | Push now, synchronously, and report what landed. Exits non-zero unless the run fully succeeded — `partial` is a real outcome. | | `seekrit sync runs [--binding ]` | The run history. | `seekrit sync enable` takes the environment (`--env`, plus `--app`), the connection (`--connection`), and the destination: | Flag | Description | | --- | --- | | `--connection ` | Destination account, by name or id. | | `--provider ` | `vercel` (default), `cloudflare-workers`, `cloudflare-pages`, `cloudflare-secrets-store`, `railway`, `aws-secrets-manager`, `aws-parameter-store`, `render`, `fly`, `northflank`, `digitalocean`, `heroku`, `netlify`, `bunnyshell`, `github-actions`, `gcp-secret-manager`, or `langgraph-platform`. | | `--project ` | **vercel:** project id (`prj_…`) or name. **cloudflare-pages:** project name. **northflank:** project id — the slug in its URL. **bunnyshell:** project ID — writes the project's variables, which every environment created in it afterwards inherits. | | `--target ` | Comma-separated deployment targets (default `production`). **vercel:** `production`, `preview`, `development`. **cloudflare-pages:** `production`, `preview`. **netlify:** `production`, `deploy-preview`, `branch-deploy`, `branch`, `dev`. | | `--git-branch ` | **vercel:** restrict `preview` writes to one git branch. **netlify:** the branch a `--target branch` context applies to (required with it). | | `--script ` | **cloudflare-workers:** the Worker's name. A Wrangler environment is its own Worker — `my-api --env staging` is the Worker `my-api-staging`. | | `--store-id ` | **cloudflare-secrets-store:** the store ID (32 hex). | | `--scopes ` | **cloudflare-secrets-store:** comma-separated scopes for secrets seekrit creates (default `workers`). | | `--railway-project ` | **railway:** the project ID (a UUID). | | `--railway-environment ` | **railway:** the Railway environment ID (a UUID) — its deployment environment, not the seekrit one. | | `--service ` | **railway:** the service ID (a UUID). Omit to write the environment's shared variables. **render:** the service ID from its dashboard URL (`srv-…`, or `crn-…` for a cron job). | | `--skip-deploys` | **railway:** stage values without triggering the redeploy that would put them live. | | `--path ` | **aws-parameter-store:** the hierarchy to write under, `/prod/storefront/` (leading and trailing slash). **aws-secrets-manager:** an optional name prefix, `prod/storefront/`. | | `--layout ` | **aws-secrets-manager, gcp-secret-manager:** `secret-per-name` (default) or `json-bundle` — every value as one JSON secret, the shape ECS and Lambda read with `secret-arn:json-key::`. | | `--secret-name ` | **aws-secrets-manager, gcp-secret-manager:** required with `--layout json-bundle` — the one secret to write. | | `--param-type ` | **aws-parameter-store:** `SecureString` (default) or `String`. | | `--tier ` | **aws-parameter-store:** `Standard` (default), `Advanced`, or `Intelligent-Tiering`. | | `--kms-key-id ` | **aws:** a customer-managed KMS key, as an ID, ARN, or alias. Defaults to the AWS-managed key. | | `--env-group ` | **render:** the environment group ID (`evg-…`). Pass this *or* `--service`, not both — which flag you use picks the destination. | | `--fly-app ` | **fly:** the Fly app name (not `--app`, which is the seekrit application). One secret set per app, staged until Machines restart — `fly secrets deploy` rolls them out. | | `--secret-group ` | **northflank:** the secret group to write, by the slug in its URL. It must already exist. | | `--do-app ` | **digitalocean:** the App Platform app ID — the UUID in its dashboard URL, not the app's name. | | `--component ` | **digitalocean:** write one component's own variables instead of the app-level ones. A component-level name overrides an app-level one. | | `--env-scope ` | **digitalocean:** `RUN_TIME` (default), `BUILD_TIME`, or `RUN_AND_BUILD_TIME`. Run time keeps values out of build logs and buildpacks. | | `--heroku-app ` | **heroku:** the Heroku app name, or its UUID (not `--app`, which is the seekrit application). One set of config vars per app; writing them cuts a release and restarts the app's dynos. | | `--netlify-site ` | **netlify:** the site's **API ID** — the UUID under Project configuration → General → Project information, not the site name or its `.netlify.app` address. Netlify resolves no site names on the environment variable endpoints, and a site it cannot resolve gets the variables written to the whole team. | | `--no-netlify-secret` | **netlify:** create readable variables instead of write-only Netlify secrets. seekrit creates secrets by default; pass this if your plan has no Secrets Controller. | | `--bunnyshell-environment ` | **bunnyshell:** the environment ID, from `bns environments list` or the dashboard URL — its variables are inherited by every component in the environment. Omit it and pass `--project` to write the project's variables instead; passing both is an error, since a binding writes to one. | | `--no-bunnyshell-secret` | **bunnyshell:** create variables visible in Bunnyshell's dashboard instead of secret ones. seekrit marks them secret by default. Bunnyshell encrypts every variable at rest either way, so the flag decides who can read it, not whether it is stored in the clear. | | `--gh-repo ` | **github-actions:** the repository, exactly as GitHub writes it (`acme/storefront`). Taken as one flag because that is how GitHub writes a repository everywhere; asking for it in two invites pasting the pair into one of them. | | `--gh-environment ` | **github-actions:** write to one deployment environment's secrets rather than the repository's. Needs `--gh-repo`. The environment must already exist — seekrit will not create one, because an environment is a deployment gate and creating an unprotected one from a typo would remove it. | | `--gh-org ` | **github-actions:** write **organization** secrets instead of a repository's. Mutually exclusive with `--gh-repo` and `--gh-environment`. | | `--gh-visibility ` | **github-actions** org secrets: `all`, `private` (CLI default), or `selected`. `all` includes repositories added later, and public ones. | | `--gh-repo-ids ` | **github-actions:** comma-separated numeric repository **IDs** for `--gh-visibility selected`. GitHub's API takes IDs, not names — `gh api repos/acme/storefront --jq .id`. | | `--gcp-prefix ` | **gcp-secret-manager:** prepended to every secret ID, e.g. `prod-storefront-`. Not `--path`: a Secret Manager ID takes letters, digits, hyphens, and underscores — no slashes or dots. | | `--gcp-replication ` | **gcp-secret-manager:** `automatic` (default) or `user-managed`. Set when a secret is created and immutable after. | | `--gcp-locations ` | **gcp-secret-manager:** comma-separated regions, required with `--gcp-replication user-managed`. Each is billed as its own active version. | | `--gcp-kms-key ` | **gcp-secret-manager:** a Cloud KMS key, as its full resource name (`projects/…/cryptoKeys/…`). KMS keys are regional, so one key covers automatic replication or a single location. | | `--langgraph-deployment ` | **langgraph-platform:** the deployment UUID, from its dashboard URL or the `id` in `GET /v2/deployments`. A deployment is the whole destination — its secrets belong to the deployment, so there is no narrower scope to name. Every write creates a new **revision**, which rebuilds and rolls out the Agent Server; seekrit reads first and sends nothing when nothing would change, so a stable environment never redeploys on the reconcile timer. | | `--gcp-prune-versions` | **gcp-secret-manager:** destroy the version each push supersedes, keeping one active version per secret. Only ever a version seekrit itself wrote. | | `--prefix ` | Prepend this to every destination key name. | | `--include ` / `--exclude ` | Comma-separated name globs. `include` allows, `exclude` then removes; exclusion wins. | | `--on-delete delete\|retain` | What happens on the destination when a secret is removed here (default `delete`). | | `--mode auto\|manual` | Push on every write (default), or only on `sync run`. | | `--acknowledge-decryption` | Required non-interactively; confirms seekrit's servers may decrypt this environment. | Enabling wraps the environment's data key — and the key of every group it composes — to the connection, on your machine. You need a key for all of them, so run it as someone who can already read the environment. ```bash printf '%s' "$VERCEL_TOKEN" | seekrit sync connect --name acme-vercel --team-id team_… seekrit sync enable --connection acme-vercel --app storefront --env production \ --project prj_… --target production --acknowledge-decryption seekrit sync run syb_… # Cloudflare: one scoped token per product, plus the account id printf '%s' "$CLOUDFLARE_API_TOKEN" | seekrit sync connect --name acme-cf \ --provider cloudflare-workers --account-id 0123456789abcdef0123456789abcdef seekrit sync enable --connection acme-cf --provider cloudflare-workers \ --script my-api --app storefront --env production --acknowledge-decryption # Railway: three UUIDs address a variable, and the token kind picks the header printf '%s' "$RAILWAY_TOKEN" | seekrit sync connect --name acme-railway \ --provider railway --token-kind project seekrit sync enable --connection acme-railway --provider railway \ --railway-project 11111111-1111-4111-8111-111111111111 \ --railway-environment 22222222-2222-4222-8222-222222222222 \ --service 33333333-3333-4333-8333-333333333333 \ --app storefront --env production --acknowledge-decryption # AWS: the region and key id are config, the secret access key is the credential printf '%s' "$AWS_SECRET_ACCESS_KEY" | seekrit sync connect --name acme-aws \ --provider aws-secrets-manager --region us-east-1 --access-key-id AKIA… seekrit sync enable --connection acme-aws --provider aws-secrets-manager \ --path prod/storefront/ --app storefront --env production --acknowledge-decryption # Render: one key for the whole workspace, then a service or an env group printf '%s' "$RENDER_API_KEY" | seekrit sync connect --name acme-render --provider render seekrit sync enable --connection acme-render --provider render \ --service srv-abc123 --app storefront --env production --acknowledge-decryption seekrit sync enable --connection acme-render --provider render \ --env-group evg-xyz789 --app storefront --env staging --acknowledge-decryption # Fly.io: the app name is the whole destination; nothing to set on the connection printf '%s' "$(fly tokens create deploy -a storefront-production)" \ | seekrit sync connect --name acme-fly --provider fly seekrit sync enable --connection acme-fly --provider fly \ --fly-app storefront-production \ --app storefront --env production --acknowledge-decryption # Northflank: one secret group, named by slug — the token carries its own team printf '%s' "$NORTHFLANK_API_TOKEN" | seekrit sync connect --name acme-northflank \ --provider northflank seekrit sync enable --connection acme-northflank --provider northflank \ --project default-project --secret-group app-secrets \ --app storefront --env production --acknowledge-decryption # DigitalOcean App Platform: app-level variables, written encrypted. # Each push submits a new app spec, which starts a deployment. printf '%s' "$DIGITALOCEAN_TOKEN" | seekrit sync connect --name acme-digitalocean \ --provider digitalocean seekrit sync enable --connection acme-digitalocean --provider digitalocean \ --do-app 4f6c71e2-1e90-4762-9fee-6cc4a0a9f2cf \ --app storefront --env production --acknowledge-decryption # Heroku: config vars on one app — each push restarts its dynos printf '%s' "$(heroku authorizations:create --short)" \ | seekrit sync connect --name acme-heroku --provider heroku seekrit sync enable --connection acme-heroku --provider heroku \ --heroku-app storefront-production \ --app storefront --env production --acknowledge-decryption # Netlify: one site's variables, per deploy context — the team is on the connection printf '%s' "$NETLIFY_AUTH_TOKEN" \ | seekrit sync connect --name acme-netlify --provider netlify --account-id acme seekrit sync enable --connection acme-netlify --provider netlify \ --netlify-site 3970e0fe-8564-4903-9a55-c5f8de49fb8b \ --target production,deploy-preview \ --app storefront --env production --acknowledge-decryption # Bunnyshell: one environment's variables — the connection carries no scope printf '%s' "$BUNNYSHELL_TOKEN" \ | seekrit sync connect --name acme-bunnyshell --provider bunnyshell seekrit sync enable --connection acme-bunnyshell --provider bunnyshell \ --bunnyshell-environment env-9f3a2b \ --app storefront --env production --acknowledge-decryption # Bunnyshell: the project instead, to seed ephemeral environments that don't exist yet seekrit sync enable --connection acme-bunnyshell --provider bunnyshell \ --project prj-4c8d1e \ --app storefront --env preview --acknowledge-decryption # GitHub Actions: prefer seekritdev/github-action, which decrypts in the run. # Sync only for what an Action cannot reach — `with:` inputs of third-party # actions, `secrets: inherit`, job-level container/services credentials. printf '%s' "$GITHUB_TOKEN" \ | seekrit sync connect --name acme-github --provider github-actions # ...to one deployment environment's secrets, the narrowest scope GitHub has seekrit sync enable --connection acme-github --provider github-actions \ --gh-repo acme/storefront --gh-environment production \ --app storefront --env production --acknowledge-decryption # Google Secret Manager: the credential is the whole key file, so pipe it in seekrit sync connect --name acme-gcp --provider gcp-secret-manager \ --project-id acme-prod < key.json seekrit sync enable --connection acme-gcp --provider gcp-secret-manager \ --gcp-prefix prod-storefront- --gcp-prune-versions \ --app storefront --env production --acknowledge-decryption ``` ## Billing | Command | Description | | --- | --- | | `seekrit billing` | The org's plan, subscription status, current usage against what the plan includes, and which self-serve actions this deployment supports. While limits aren't enforced it says so — the numbers are informational. | | `seekrit billing entitlements` | Every entitlement the org resolves to, and where each value came from. | | `seekrit billing checkout ` | Start a self-serve upgrade. Prints a checkout URL to open. | | `seekrit billing portal` | Print a billing-portal URL for payment methods and invoices. | | `seekrit billing cancel [--yes]` | Cancel the subscription and drop back to the Free plan. | --- # REST API The API is a Cloudflare Worker. All application endpoints are under `/v1` and require authentication. Values are always **ciphertext** — the API neither encrypts nor decrypts. ## Authentication Send one of: - `Authorization: Bearer ` — a Stytch B2B session JWT (web sessions), or a machine (M2M) access token. - `Authorization: Basic base64(client_id:client_secret)` — machine (M2M) client credentials. The API runs the OAuth 2.0 client-credentials exchange server-side and authorizes the caller as a machine client — the same credential used on the hosted metadata plane (`mcp.seekrit.dev`). It holds no key material and cannot decrypt; use it to bootstrap an admin service token, then switch to that token for anything that touches ciphertext. - `Authorization: Bearer skt_…` — a service token. Its stored `role` is `member` (runtime credential) or `admin` (passes admin-gated routes, for headless provisioning). `owner` is never issued to a token. A service token is **never** a valid Basic credential — it carries a private key. - `Authorization: Bearer skc_…` — a CLI session, created by `seekrit login` and authorized in the dashboard. It authenticates **as the user** who authorized it, with that member's role in every org they belong to. It carries no key material, so decryption still needs that user's passphrase-unlocked private key. Errors use `{ "error": { "code": "...", "message": "..." } }` with a matching HTTP status (`400`, `401`, `403`, `404`, `409`, `429`, `500`). Organizations you don't belong to return `404`. One code is recoverable rather than final: `mfa_required` (403) means the caller must re-verify their second factor and retry (see CLI sign-in below). ## Signup (agent self-service) | Method | Path | Description | | --- | --- | --- | | `POST` | `/v1/signup` | Create an organization and a machine (M2M) client for it — the zero-human on-ramp for agents. No auth; rate-limited. Body: `{ "orgName", "orgSlug", "clientName"? }` — `orgName` and `orgSlug` are **required** and should name the real project/company (not placeholders); `orgSlug` is lowercase alphanumeric + hyphens, disambiguated on collision. Returns `{ "org", "m2m": { "clientId", "clientSecret" } }` — the secret is shown **once**. The org starts memberless; a human joins later via invite. | Also reachable as `POST https://mcp.seekrit.dev/signup` (the hosted MCP server proxies to this endpoint). ## CLI sign-in (browser-approved) What `seekrit login` drives. The CLI generates its own session token, registers only the SHA-256 hash, and a human authorizes that hash in the dashboard — so the credential never travels through the API, and an approval response carries nothing secret. | Method | Path | Description | | --- | --- | --- | | `POST` | `/v1/cli-login` | Open a sign-in request. No auth (the caller has no credential yet); rate-limited per IP. Body: `{ "sessionId": "skc_…", "tokenHash", "deviceLabel", "client"? }` — `deviceLabel`/`client` are self-reported and shown to the human, never used for authorization. Returns `{ "code", "verifyUrl", "requestExpiresAt", "pollIntervalSeconds" }`. Requests expire in 10 minutes. | | `POST` | `/v1/cli-login/poll` | Ask whether the request was authorized. No auth. Body: `{ "code" }`. Returns `{ "status": "pending" \| "approved" \| "denied" \| "expired" }`, plus `{ "sessionId", "email", "expiresAt" }` once approved. | | `GET` | `/v1/cli-login/:code` | The request's details, for the approval screen: `{ "request": { code, status, deviceLabel, client, ipAddress, createdAt, requestExpiresAt } }`. Requires a **browser session** — a CLI session may not authorize another device. | | `POST` | `/v1/cli-login/:code/approve` | Authorize the device; the registered hash becomes a usable credential (90 days). Requires a browser session, and — for members with a second factor — that it was re-entered within the last 10 minutes, else `403 mfa_required`. Returns `{ "session": { id, expiresAt } }`. Audits `cli_session.approved`. | | `POST` | `/v1/cli-login/:code/deny` | Decline the request. Requires a browser session. Audits `cli_session.denied`. | Manage the resulting sessions under [Identity](#identity). ## Health | Method | Path | Description | | --- | --- | --- | | `GET` | `/health` | Liveness check (no auth). | ## Identity | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/me` | The current user and their organizations. | | `GET` | `/v1/me/keys` | Your public key and passphrase-encrypted private key. | | `PUT` | `/v1/me/keys` | Upload your keys (one-time key setup). | | `GET` | `/v1/me/cli-sessions` | Devices authorized with `seekrit login`: `{ "sessions": [...], "currentSessionId" }`. Revoked and expired sessions stay listed (the ledger of what has signed in as you); `currentSessionId` is set when the caller *is* a CLI session. | | `DELETE` | `/v1/me/cli-sessions/:sessionId` | Revoke a CLI session — it stops authenticating on its next request. Allowed from a CLI session for its own id (that's `seekrit logout`). Audits `cli_session.revoked`. | | `GET` | `/v1/me/notifications` | Your email notification preferences (all types, defaults applied). | | `PUT` | `/v1/me/notifications` | Update notification preferences. Body: `{ "prefs": { "": true \| false } }` (partial). Returns the full merged map. | ## Organizations | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/orgs` | List organizations you can access. | | `POST` | `/v1/orgs` | Create an organization (creator becomes owner). | | `GET` | `/v1/orgs/:orgId` | Get one organization and your role. | | `PATCH` | `/v1/orgs/:orgId` | Rename an organization: `{ name }` (display name only — the slug is fixed). *(admin)* | | `GET` | `/v1/orgs/:orgId/members` | List members (includes public keys, for granting). | | `GET` | `/v1/orgs/:orgId/mfa-policy` | Get the org's second-factor policy: `{ required, configured }`. `configured` is false when the identity provider isn't set up. *(admin)* | | `PATCH` | `/v1/orgs/:orgId/mfa-policy` | Require (or stop requiring) MFA for all members: `{ required }`. *(admin)* | | `GET` | `/v1/orgs/:orgId/invites` | List pending invitations. *(admin)* | | `POST` | `/v1/orgs/:orgId/invites` | Invite someone by email `{ email, role? }` (`role` defaults to `member`, `admin` allowed — never `owner`). Sends a magic-link invitation; they join at that role on first sign-in. *(admin)* | | `DELETE` | `/v1/orgs/:orgId/invites/:inviteId` | Revoke a pending invitation. *(admin)* | ## Applications & environments | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/orgs/:orgId/apps` | List applications. | | `POST` | `/v1/orgs/:orgId/apps` | Create an application. *(admin)* | | `GET` | `/v1/orgs/:orgId/apps/:appId` | Get an application and its environments. | | `PATCH` | `/v1/orgs/:orgId/apps/:appId` | Rename an application: `{ name }` (display name only — the slug is fixed). *(admin)* | | `DELETE` | `/v1/orgs/:orgId/apps/:appId` | Delete an application. *(admin)* | | `GET` | `/v1/orgs/:orgId/apps/:appId/envs` | List environments. | | `POST` | `/v1/orgs/:orgId/apps/:appId/envs` | Create an environment (body includes the wrapped DEK). *(admin)* | | `GET` | `/v1/orgs/:orgId/envs/:envId` | Get an environment. | | `DELETE` | `/v1/orgs/:orgId/envs/:envId` | Delete an environment. *(admin — or any grant-holder when it's a branch)* | | `GET` | `/v1/orgs/:orgId/envs/:envId/grantees` | The public keys of this environment's grant-holders, for wrapping a new key to them. No key material is returned. | An environment is owned by **either** an application or a group; `applicationId` and `groupId` are both present on the row, with the unused one `null`. Environment lists exclude [branches](/docs/guides/branches) — they're returned by the branch endpoints below. Each environment returned by `GET /v1/orgs/:orgId/apps/:appId` and `GET /v1/orgs/:orgId/groups/:groupId` also carries a `canDecrypt` boolean: whether the calling principal holds a key grant for that environment. Secret names and versions are visible to any member, but values only decrypt with a grant — so the dashboard uses `canDecrypt` to lock the environments you can't read. It's computed per request, not a stored field. ## Branches A [branch](/docs/guides/branches) is an ephemeral environment overlaying another: an ordinary environment row with a `parentEnvironmentId` and an `expiresAt`. It stores only the values that differ; the rest resolve from its parent at read time, so creating one copies and re-encrypts nothing. | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/orgs/:orgId/envs/:envId/branches` | List branches of an environment. | | `POST` | `/v1/orgs/:orgId/envs/:envId/branches` | Fork this environment: `{ slug, name?, ttlSeconds?, wrappedDek, recoveryWrappedDek?, grants? }`. `envId` is the **parent**. | | `GET` | `/v1/orgs/:orgId/apps/:appId/branches` | Every branch in an application. | | `DELETE` | `/v1/orgs/:orgId/envs/:branchId` | Tear one down (the environment-delete route above). | `ttlSeconds` omitted takes the default (7 days); pass an explicit `null` for no expiry. The cap is 30 days. Expired branches are deleted automatically, along with their overrides, key grants, and any service token bound to them. `grants` wraps the branch's new DEK to principals that already hold a grant on the parent (get their public keys from `…/envs/:envId/grantees`) — a grant naming anyone else is rejected. Creating a branch requires a grant on the parent, not an admin role: it conveys no access the caller doesn't already have. Parents must be application environments and must not themselves be branches. ## Groups A group is a reusable, org-scoped secret bag. Its environments (matched by slug) reuse the same secret and key-grant endpoints above. | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/orgs/:orgId/groups` | List groups. | | `POST` | `/v1/orgs/:orgId/groups` | Create a group. *(admin)* | | `GET` | `/v1/orgs/:orgId/groups/:groupId` | Get a group and its environments. | | `PATCH` | `/v1/orgs/:orgId/groups/:groupId` | Rename a group: `{ name }` (display name only — the slug is fixed). *(admin)* | | `DELETE` | `/v1/orgs/:orgId/groups/:groupId` | Delete a group. *(admin)* | | `GET` | `/v1/orgs/:orgId/groups/:groupId/envs` | List a group's environments. | | `POST` | `/v1/orgs/:orgId/groups/:groupId/envs` | Create a group environment (body includes the wrapped DEK). *(admin)* | ### Composition Which groups an application environment pulls in, and their precedence. | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/orgs/:orgId/envs/:envId/groups` | List composed groups (slug, name, position). | | `POST` | `/v1/orgs/:orgId/envs/:envId/groups` | Compose a group `{ groupId, position? }`. *(admin)* | | `DELETE` | `/v1/orgs/:orgId/envs/:envId/groups/:groupId` | Remove a composed group. *(admin)* | ## Resolve | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/resolve` | The full layered environment for the calling principal. | For a service token, the org + app + environment come from the token itself. For a user session pass `?env=`. Repeat `?with=:` to resolve specific groups at a different slug. Add `?branch=` to read an ephemeral [branch](/docs/guides/branches) of that environment — a service token stays bound to its own environment, so it can only name branches of that one. Returns `{ scope, layers }`, where `layers` are ordered lowest-precedence first (composed groups → the app environment → the branch's overrides) and each carries its `environmentId`, secret ciphertexts, and the DEK wrapped to the caller. A branch resolve additionally reports `scope.branchOf`, and needs a grant on every layer including the parent — so branch access never exceeds parent access. The client decrypts and merges; the server sees only ciphertext. The [language SDKs](/docs/guides/sdks), CLI, and `seekrit-run` all implement this same decrypt-and-merge against this endpoint. > **Note:** Resolve is edge-cached per principal, so a fleet of identical containers or CI jobs sharing one service token is served from cache without re-querying the database. Responses reflect secret writes, key changes, and composition changes within seconds (a write invalidates the cache; a short TTL is the backstop). Because the cache key includes the calling principal, one caller never receives another's wrapped DEK. Successful resolves are metered for usage/billing but are not written to the audit log; a *denied* resolve is audited (`env.resolve_denied`). ## Secrets Values are opaque ciphertext blobs produced by the client. | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/orgs/:orgId/envs/:envId/secrets` | List secrets (with ciphertext). | | `PUT` | `/v1/orgs/:orgId/envs/:envId/secrets/:name` | Create or update a secret (appends a version). | | `GET` | `/v1/orgs/:orgId/envs/:envId/secrets/:name/versions?limit=` | The secret's history, newest first: `{ versions: [{ version, ciphertext, createdByType, createdById, restoredFromVersion, createdAt }], currentVersion }`. `limit` defaults to 50, max 200. | | `POST` | `/v1/orgs/:orgId/envs/:envId/secrets/:name/restore` | Roll back to an earlier version: `{ version }`. Returns `{ secret, restoredFrom }`. | | `DELETE` | `/v1/orgs/:orgId/envs/:envId/secrets/:name` | Delete a secret and its whole version history. | > **Note:** **Restore is keyless.** It replays a ciphertext the server already stores — no decryption, no re-encryption — so a machine (M2M) credential can roll a secret back even though it can never read the value. The replayed blob lands as a *new* version (`restoredFromVersion` points at its source), so history stays append-only and a rollback is itself undoable. `404` if the version doesn't exist; `400` if the secret already holds that exact value. ## Key grants | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/orgs/:orgId/envs/:envId/key` | The calling principal's own wrapped DEK. | | `GET` | `/v1/orgs/:orgId/envs/:envId/keys` | List all grants for the environment. *(admin)* | | `POST` | `/v1/orgs/:orgId/envs/:envId/keys` | Grant a wrapped DEK to a principal. *(admin)* | | `DELETE` | `/v1/orgs/:orgId/envs/:envId/keys/:grantId` | Revoke a grant. *(admin)* | ## KMS keys Client-side managed keys for application-layer encryption and signing. Material is generated by the client and stored only as wrapped grants; `sign` keys also publish a public key per version. Management is *(admin)*; using a key needs a grant. See [Managed keys](/docs/concepts/kms). | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/orgs/:orgId/kms/keys` | List keys the caller can see (admins: all org keys; members: keys they hold a grant for). | | `POST` | `/v1/orgs/:orgId/kms/keys` | Create a key: `{ name, purpose ('encrypt'\|'sign'), spec, applicationId?, groupId?, publicKeyJwk? (sign only), grants: [{ principalType, principalId, wrappedKey }] }`. At most one of `applicationId`/`groupId`. *(admin)* | | `GET` | `/v1/orgs/:orgId/kms/keys/:keyId` | Key metadata + every version (public keys for `sign`). *(admin)* | | `GET` | `/v1/orgs/:orgId/kms/keys/:keyId/key` | The caller's wrapped material across granted versions (`{ purpose, spec, currentVersion, grants: [{ version, wrappedKey }] }`). `403` if the key is disabled. | | `GET` | `/v1/orgs/:orgId/kms/keys/:keyId/public` | Published public keys of a `sign` key, per version. Grant-free within the org. | | `GET` | `/v1/orgs/:orgId/kms/keys/:keyId/grants` | Grantees, each with the versions they hold. *(admin)* | | `POST` | `/v1/orgs/:orgId/kms/keys/:keyId/grants` | Grant the current version: `{ principalType, principalId, wrappedKey }`. *(admin)* | | `DELETE` | `/v1/orgs/:orgId/kms/keys/:keyId/grants?principalType=&principalId=` | Revoke a principal entirely (all versions). *(admin)* | | `POST` | `/v1/orgs/:orgId/kms/keys/:keyId/rotate` | Add a version and re-wrap for grantees: `{ publicKeyJwk? (sign only), grants: [{ principalType, principalId, wrappedKey }] }`. *(admin)* | | `POST` | `/v1/orgs/:orgId/kms/keys/:keyId/disable` | Disable a key — blocks all use (encrypt/decrypt/sign, new grants/rotations); grant rows retained. *(admin)* | | `DELETE` | `/v1/orgs/:orgId/kms/keys/:keyId` | Soft-delete a key — hides it from every listing and read path; row and versions retained for audit; name frees up for reuse. *(admin)* | > **Note:** Key material never reaches the server. For an `encrypt` key each `wrappedKey` is the AES key wrapped to a principal (`wd1.`); for a `sign` key it is the wrapped PKCS8 private key, and the public key is stored per version for grant-free verification. Ciphertext (`ce1.`), wrapped data keys (`dk1.`), and signatures (`sg1.`) are produced and consumed entirely client-side. ## Recovery Customer-controlled M-of-N recovery. The client generates and Shamir-splits the recovery key; these routes carry only the recovery **public** key, opaque wrapped shares, and ciphertext grants. Management is *(admin)*; a custodian reads their own share and contributes to a ceremony. See [Customer-controlled recovery](/docs/concepts/recovery). | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/orgs/:orgId/recovery` | Status: threshold, custodians, and environment coverage. *(admin)* | | `POST` | `/v1/orgs/:orgId/recovery` | Enable recovery: `{ recoveryPublicKeyJwk, threshold, shares[], grants[] }`. *(admin)* | | `POST` | `/v1/orgs/:orgId/recovery/rotate` | Rotate to a fresh recovery key, custodian set, and env re-wraps. *(admin)* | | `DELETE` | `/v1/orgs/:orgId/recovery` | Disable recovery and delete every recovery grant. *(admin)* | | `POST` | `/v1/orgs/:orgId/recovery/grants` | Backfill recovery grants: `{ grants: [{ environmentId, wrappedDek }] }`. *(admin)* | | `GET` | `/v1/orgs/:orgId/recovery/share` | The caller's own wrapped recovery share (custodian only). | | `GET` | `/v1/orgs/:orgId/recovery/env-keys` | Every DEK wrapped to the recovery key, for the target to reconstruct. *(admin)* | | `GET` | `/v1/orgs/:orgId/recovery/requests` | List recovery ceremonies. *(admin)* | | `POST` | `/v1/orgs/:orgId/recovery/requests` | Start a ceremony: `{ targetPublicKeyJwk, targetType?, targetId?, reason? }`. *(admin)* | | `GET` | `/v1/orgs/:orgId/recovery/requests/:id` | A request plus its contributed (re-wrapped) shares. | | `POST` | `/v1/orgs/:orgId/recovery/requests/:id/shares` | A custodian contributes: `{ shareIndex, contributedShare }`. | | `POST` | `/v1/orgs/:orgId/recovery/requests/:id/complete` | Target restores access: `{ principalType, principalId, grants[] }`. *(admin)* | | `POST` | `/v1/orgs/:orgId/recovery/requests/:id/cancel` | Cancel an open request. *(admin)* | > **Note:** The server never sees a recovery share or the recovery key in the clear. Shares are wrapped to each custodian (`wd1.`); during a ceremony each is unwrapped and re-wrapped to the target on the custodian's own device; the recovery key is reconstructed only on the target's machine, from ciphertext this API merely relays. ## Service tokens | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/orgs/:orgId/tokens` | List service tokens. *(admin)* | | `POST` | `/v1/orgs/:orgId/tokens` | Register a token (client sends hash + public key; `role` defaults to `member`, pass `admin` for a provisioning token; optional `environmentId` binds it to an app environment). *(admin)* | | `DELETE` | `/v1/orgs/:orgId/tokens/:tokenId` | Revoke a token (idempotent). *(admin)* | | `DELETE` | `/v1/orgs/:orgId/tokens/:tokenId/permanent` | Delete a token: drops the env/KMS grants wrapped to it and marks it deleted (soft delete — the row is retained for the audit trail but hidden from listings). Requires the token to already be revoked (`409` otherwise). *(admin)* | A service token may read or write secrets only for environments it holds a key grant for — its bound environment and the group slices composed into it. It cannot address other environments in the org. ## Honey tokens Decoy credentials that grant nothing and alert on use. All routes are *(admin)*, and gated on the **Honey tokens** plan feature. See the [guide](/docs/guides/honey-tokens). | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/orgs/:orgId/honey-tokens` | List decoys with their trip counts and the time, source IP, and user agent of the last trip. Never returns the token hash. | | `POST` | `/v1/orgs/:orgId/honey-tokens` | Register a decoy (client sends `tokenId` + `tokenHash`, plus an optional `placement` note). Deliberately takes **no** `publicKeyJwk`: a decoy must not be wrappable, so nothing can be granted to it. | | `DELETE` | `/v1/orgs/:orgId/honey-tokens/:honeyTokenId` | Delete a decoy, which stops it alerting. A hard delete — there are no grants to clear, and recorded trips live on in the append-only audit log. | There is no endpoint that *trips* a honey token, because tripping isn't an operation: presenting one as `Authorization: Bearer skt_…` on **any** route records the trip, alerts the org's admins, and returns the same `401 unknown service token` an unregistered token receives. The responses are identical by construction so a decoy can't be identified by probing. ## Temporary access (leases) Vault-style short-lived credentials. All routes are *(admin)*. | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/orgs/:orgId/leases/broker-key` | The org broker's public key. Wrap the admin credential to it before registering a target. | | `GET` | `/v1/orgs/:orgId/leases/targets` | List provisioning targets (never the wrapped admin secret's plaintext). | | `POST` | `/v1/orgs/:orgId/leases/targets` | Register a target. **Postgres/MySQL/Redis:** `config` = provider, executor, connection, `accessLevel` `readonly`/`readwrite`/`custom` (Postgres adds `schema`; MySQL adds `userHost`; Redis's connection takes an optional `db` index), or custom create/revoke templates. **SSH:** `config` = `provider: "ssh"`, `executor: "in_do"`, `caPublicKey`, optional `allowedPrincipals`/`extensions`/`maxTtlSeconds`/`connection`. **AWS:** `config` = `provider: "aws"`, `executor: "in_do"`, `roleArn`, `region`, optional `externalId`/`sessionPolicy`/`maxTtlSeconds`. **GCP:** `config` = `provider: "gcp"`, `executor: "in_do"`, `serviceAccount`, optional `scopes`/`delegates`/`maxTtlSeconds`. **MongoDB:** `config` = `provider: "mongodb"`, `executor: "in_do"`, `connection`, `accessLevel` `readonly`/`readwrite`/`custom` (custom adds a `roles` array of `{role,db}`), optional `authSource`/`tls`/`maxTtlSeconds`. `wrappedAdminSecret` is the admin credential (a pg/mysql/redis/mongodb connection string, the SSH CA private key, the AWS base IAM credential as JSON `{accessKeyId,secretAccessKey}`, or the GCP service-account key JSON) wrapped to the broker key. | | `DELETE` | `/v1/orgs/:orgId/leases/targets/:targetId` | Remove a target. | | `GET` | `/v1/orgs/:orgId/leases` | List the lease ledger (status, resource ref, provider, expiry — no secret material). | | `POST` | `/v1/orgs/:orgId/leases` | Mint a lease. The body is tagged by `provider` and must match the target. **Postgres:** `{ provider: "postgres", targetId, roleName, verifier, ttlSeconds }` → returns `connection` (host/port/db/username), never the password. **MySQL:** `{ provider: "mysql", targetId, roleName, verifier, ttlSeconds }` (verifier = `mysql_native_password` hash) → returns `connection`, never the password. **Redis:** `{ provider: "redis", targetId, roleName, verifier, ttlSeconds }` (verifier = lowercase-hex SHA-256 digest; `database` in the response is the logical db index) → returns `connection`, never the password. **SSH:** `{ provider: "ssh", targetId, publicKey, principals[], ttlSeconds }` → returns `ssh` (the signed `certificate`, `principals`, `caPublicKey`, expiry), never a private key. **AWS:** `{ provider: "aws", targetId, recipientPublicKey, ttlSeconds }` (`recipientPublicKey` = an ephemeral P-256 JWK; `ttlSeconds` 900–43200) → returns `aws` (`wrappedCredential` — the STS credential wrapped to `recipientPublicKey` — plus `region`, `roleArn`, expiry), never a plaintext credential. **GCP:** `{ provider: "gcp", targetId, recipientPublicKey, ttlSeconds }` (`recipientPublicKey` = an ephemeral P-256 JWK; `ttlSeconds` 60–43200) → returns `gcp` (`wrappedCredential` — the OAuth access token wrapped to `recipientPublicKey` — plus `serviceAccount`, `scopes`, expiry), never a plaintext token. **MongoDB:** `{ provider: "mongodb", targetId, recipientPublicKey, ttlSeconds }` (`recipientPublicKey` = an ephemeral P-256 JWK) → returns `mongodb` (`wrappedCredential` — the created user's password wrapped to `recipientPublicKey` — plus `host`, `port`, `database`, `authSource`, expiry), never a plaintext credential. | | `DELETE` | `/v1/orgs/:orgId/leases/:leaseId` | Revoke a lease. Postgres/MySQL/Redis/MongoDB drop the role/user immediately; SSH, AWS, and GCP mark the ledger (the certificate / token stays valid until it expires). | > **Note:** Postgres, MySQL, Redis, and SSH are tier 1 (verifier injection): the client generates the secret half locally and sends only a verifier — a **SCRAM verifier** (Postgres), a **`mysql_native_password` hash** (MySQL), a **SHA-256 digest** (Redis), or an **SSH public key** (SSH). None can authenticate on its own, so the plaintext password / private key never reaches the API. **AWS, GCP, and MongoDB are tier 2**: the target mints the credential (STS `AssumeRole`; GCP `generateAccessToken`; a MongoDB `createUser` password generated by the broker), so the broker wraps it to the consumer's ephemeral public key (`recipientPublicKey`) and returns only that ciphertext — decryptable solely on the requesting machine. The admin credential used to provision (a pg/mysql/redis/mongodb admin connection string, the SSH CA private key, the AWS base IAM credential, or the GCP service-account key) is wrapped to the broker Durable Object's public key and is never seen in plaintext by the control plane. ## Secret rotation Managed, scheduled replacement of a stored secret's value. All routes are *(admin)*. No route returns a secret value; a rotated value is read back through the normal secret/resolve paths. See [Secret rotation](/docs/concepts/rotation). | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/orgs/:orgId/rotation/rotator-key` | The rotator public key (the org broker's) plus `grantedEnvironmentIds` — the environments that already hold a rotator grant. Wrap an environment's DEK to this key client-side before configuring rotation. | | `GET` | `/v1/orgs/:orgId/rotation` | List rotation policies: kind, cadence, status, last/next run, `lastError`. Never values. | | `POST` | `/v1/orgs/:orgId/rotation` | Configure (or replace) a secret's policy. `{ environmentId, secretName, config, intervalSeconds, targetId?, wrappedDek?, rotateNow? }`. `config` is tagged by `kind`: `{ kind: "generated", length?, alphabet? }` contacts nothing; `{ kind: "postgres"\|"mysql"\|"redis", username, passwordLength?, statements? }` re-keys an **existing** account (`mysql` adds `userHost`) and requires a matching `targetId` from temporary access. `intervalSeconds` is 300–31536000. `wrappedDek` is the environment DEK wrapped to the rotator key — **required unless that environment already holds a rotator grant**, and impossible for the server to produce. `rotateNow` rotates once immediately and returns the new `version`. | | `GET` | `/v1/orgs/:orgId/rotation/:rotationId` | One policy. | | `PATCH` | `/v1/orgs/:orgId/rotation/:rotationId` | Change `intervalSeconds`, `config` (same kind only), or `status` (`active`/`paused`). Resuming clears the failure streak. | | `POST` | `/v1/orgs/:orgId/rotation/:rotationId/rotate` | Rotate now — the same path the scheduler uses. Returns `{ rotation, version, rotatedAt }`. | | `DELETE` | `/v1/orgs/:orgId/rotation/:rotationId` | Remove the policy; the secret and its versions are untouched. `rotatorRevoked` reports whether the environment's rotator grant was dropped (it is, once nothing there rotates). | > **Note:** Rotation is the one path where a server-side component can encrypt into an environment — writing a new value requires the environment's data key. The boundary is one ordinary key grant: an `environment_keys` row with `principal_type = rotator`, holding the DEK wrapped to the per-org broker Durable Object's public key, whose private half never leaves DO storage (so a database dump is still only ciphertext). You create that grant from your own client — the API cannot — it covers only the environments where you enabled rotation, it is re-read on every rotation so revoking it halts rotation at once, and it is dropped when the environment's last policy goes away. For the database kinds the target still receives only a **verifier**, exactly as with temporary access. ## Audit | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/orgs/:orgId/audit` | Paginated audit entries. *(admin)* Query: `cursor`, `limit`, `action`, `resourceType`. | > **Note:** Token creation is client-driven: the client generates the keypair and token string locally and submits only the token id, a SHA-256 hash, and the public key. The server never sees the token secret. ## Plans & billing Read the org's plan, current metered usage, and start self-serve billing. See the [plans & billing guide](/docs/guides/billing). | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/orgs/:orgId/billing` | The org's plan and entitlements. Returns `{ subscription, plan, promo, entitlements[], usage[], overrides[], enforced, manage }`. Readable by any member. | | `POST` | `/v1/orgs/:orgId/billing/checkout` | Start a Checkout Session to upgrade. Body `{ family }` (a plan family). Returns `{ url }` to redirect the browser to. *(admin)* | | `POST` | `/v1/orgs/:orgId/billing/portal` | Open the Billing Portal to manage/update payment. Returns `{ url }`. *(admin)* | | `POST` | `/v1/orgs/:orgId/billing/cancel` | Downgrade to the Free (default) plan: cancels any active paid subscription in the biller and reverts the org to Free immediately. Returns `{ ok: true }`. *(admin)* | | `POST` | `/v1/orgs/:orgId/billing/promo` | Redeem a promo code, moving the org onto the plan the code grants. Body `{ code }` (normalized server-side). Returns the same body as `GET /billing`. *(admin)* | `usage[]` is the **metered** dimensions, each `{ key, metric, label, quantity, included, available }` — `quantity` is the current usage for this period, `included` is what the plan covers before overage (`null` = unlimited), and `available` is `false` when a metric can't be measured (shown as unknown, not zero). `manage` reports which self-serve actions this deployment supports: `{ checkout, checkoutFamilies[], portal }`. > **Note:** An organization can **pay** for a higher plan (checkout/portal), but it can't **grant** itself one — comped plan assignment and per-org entitlement overrides are an operator-only surface, not reachable by org admins. While `enforced` is `false`, plan limits are informational: every org has full access and the usage numbers preview what a plan will include once enforcement is on. ## Audit log export (OTLP) Stream an organization's audit trail to your own SIEM/observability tooling. See the [audit log export guide](/docs/guides/audit-export). | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/orgs/:orgId/log-sink` | Get the org's OTLP log-sink config: `endpoint`, `enabled`, `headerNames`, and delivery status (`lastSuccessAt`, `lastAttemptAt`, `lastError`). Header **values** are never returned. *(admin)* | | `PUT` | `/v1/orgs/:orgId/log-sink` | Configure the sink: `{ endpoint, headers?, enabled? }`. `endpoint` is the OTLP/HTTP logs URL; `headers` (e.g. `{ "Authorization": "Bearer …" }`) is encrypted at rest — omit it to leave the stored value unchanged, pass `{}` to clear it. *(admin)* | | `DELETE` | `/v1/orgs/:orgId/log-sink` | Remove the sink. *(admin)* | | `POST` | `/v1/orgs/:orgId/log-sink/test` | Synchronously POST a synthetic record to the endpoint and return `{ ok, status, error }`. *(admin)* | > **Note:** Delivery is **at-least-once** and near-real-time: an every-minute sweep ships new audit rows to your endpoint as OTLP log records, advancing a per-org watermark only after the collector accepts a batch (a transient outage re-ships rather than drops). Records carry attribution and the audit log's redacted metadata only — **never** secret values, ciphertext, or credentials. ## Agent access policy Which upstream each agent may reach, with which credential, for which operations. The policy bundle is signed in the publishing admin's browser and is **opaque to the API** — see the [agent access policy guide](/docs/guides/agent-proxy/policy). | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/orgs/:orgId/agents` | List agent identities: name, slug, bound environment, live policy version, and when a proxy last fetched it. *(admin)* | | `POST` | `/v1/orgs/:orgId/agents` | Create an identity: `{ name, slug, environmentId? }`. Holds no key material and can never be granted any — it is a policy subject only. *(admin)* | | `GET` | `/v1/orgs/:orgId/agents/:agentId` | One identity, plus its bound environment's slug. *(admin)* | | `PATCH` | `/v1/orgs/:orgId/agents/:agentId` | Update `{ name?, enabled?, environmentId? }`. Disabling is the revocation path: policy fetches start failing, and each proxy's current bundle expiry bounds the window. Audits `agent.updated`. *(admin)* | | `DELETE` | `/v1/orgs/:orgId/agents/:agentId` | Delete the identity and its published versions. Audits `agent.deleted`. *(admin)* | | `GET` | `/v1/orgs/:orgId/agents/:agentId/policies` | Published versions (newest first, 50 max), each with its signed `bundle`, signer thumbprint, expiry, and decoded `rules`. *(admin)* | | `POST` | `/v1/orgs/:orgId/agents/:agentId/policies` | Publish `{ bundle }` — an `ap1..` envelope signed client-side. Requires a **user session**: an `admin` service token cannot publish, so an agent cannot widen its own policy. The bundle must be signed by the caller's own key, name this org/agent/slug, carry `policy_version` = current + 1 (else `409`), and expire between 1 hour and 90 days out. Audits `agent.policy_published` with the signer and version. *(admin)* | | `POST` | `/v1/orgs/:orgId/agents/:agentId/policies/:version/rollback` | Republish an earlier version's bundle as a new version — a roll-*forward*, so nothing is re-signed and the restored bundle keeps its original expiry. Audits `agent.policy_rolled_back`. *(admin)* | | `GET` | `/v1/orgs/:orgId/agents/signers/me` | Your own signing thumbprint (RFC 7638), for the trust-anchor snippet: `{ "signer": { userId, thumbprint } \| null }`. *(admin)* | | `GET` | `/v1/agents/:agentRef/policy` | **The proxy's read path.** `agentRef` is the identity's id or slug, scoped to the caller's org. Returns `{ bundle, version, expiresAt, agent, signerThumbprint }` with an `ETag`; send `If-None-Match` and a steady state costs a `304`. Requires a service token (or a user session, for debugging) — a machine (M2M) credential is refused. `403` when the identity is disabled or the token is bound to a different environment; `404` when nothing is published yet. Not audited per fetch (it is a poll, like `/v1/resolve`); it records `lastPolicyFetchAt`, throttled to a minute. | > **Warning:** The bundle is stored and served verbatim: the API cannot forge one, because the signature is made in a browser with a key it never holds. A proxy verifies every bundle against signer thumbprints pinned in its **own local file** before acting on a rule, so withholding policy makes a proxy fail closed — and widening it is not something this API can do. ## Third-party sync Push an environment's resolved secrets to a platform that holds its own copy. See the [third-party sync guide](/docs/guides/third-party-sync). | Method | Path | Description | | --- | --- | --- | | `GET` | `/v1/orgs/:orgId/sync/connections` | List sync connections. Returns `publicKeyJwk` and status; **never** the wrapped credential. *(admin)* | | `GET` | `/v1/orgs/:orgId/sync/connections/:connectionId/public-key` | Mint (or return) the keypair for a connection id, before the connection exists. Wrap the destination credential and the environment DEKs to this key. *(admin)* | | `POST` | `/v1/orgs/:orgId/sync/connections` | Create a connection: `{ id?, name, config, wrappedCredential }`. `config` is tagged by `provider`: `{ provider: "vercel", teamId? }`, `{ provider: "cloudflare-workers" \| "cloudflare-pages" \| "cloudflare-secrets-store", accountId }` (32 hex), `{ provider: "railway", tokenKind }` (`account` \| `project`, default `account` — it selects the auth header), `{ provider: "aws-secrets-manager" \| "aws-parameter-store", region, accessKeyId }`, `{ provider: "render" }` (a Render API key is user-scoped — no account field), `{ provider: "fly" }` (nothing to scope either: a Fly app name is globally unique, and the auth scheme is read from the token), `{ provider: "northflank" }` / `{ provider: "digitalocean" }` / `{ provider: "heroku" }` (also empty — a Northflank token carries its own team scope, a DigitalOcean token its own account, and a Heroku token its user's access to every app they can reach), `{ provider: "netlify", accountId }` (the team slug or account ID — Netlify keeps environment variables on the account, and a personal access token does not say which team to write), `{ provider: "bunnyshell" }` (empty again — a Bunnyshell access token carries its user's access to every organization they belong to, and both variable collections name their parent by a globally unique ID), or `{ provider: "github-actions", baseUrl? }` (empty for github.com too — a GitHub token addresses everything by `owner/repo` or `org`, which the destination names; `baseUrl` points the connection at a self-hosted **GitHub Enterprise Server** API root and must be `https`), `{ provider: "gcp-secret-manager", projectId }` (the project ID or number whose Secret Manager to write — a service-account key names its own identity, but can be granted secrets in projects other than its own), or `{ provider: "langgraph-platform", region?, baseUrl?, tenantId? }` (all three optional and all about *where the control plane is*: `region` is one of `us` (the default), `eu`, `apac`, `aws-us`, and matters because a LangSmith key minted in one region is refused by another with a bare `401`; `baseUrl` points the connection at a self-hosted LangSmith control plane, must be `https`, and is rejected together with `region`; `tenantId` is the workspace UUID, needed only for an organization-scoped key, which reaches several workspaces). `wrappedCredential` is the destination's credential wrapped to the connection's public key — an API token, for AWS the **secret access key** alone (the access key id is an identifier and lives in `config`), and for GCP the whole **service-account key JSON**. Pass the same `id` you fetched the key for. *(admin)* | | `POST` | `/v1/orgs/:orgId/sync/connections/:connectionId/verify` | Test the stored credential against `{ destination }`. Returns `{ ok: true }` or `400 { ok: false, error }`. *(admin)* | | `DELETE` | `/v1/orgs/:orgId/sync/connections/:connectionId` | Delete the connection, its bindings, and every `sync` key grant made to it, then destroy its keypair. *(admin)* | | `GET` | `/v1/orgs/:orgId/sync/bindings` | List bindings with their destination, filters, and last-run status. *(admin)* | | `POST` | `/v1/orgs/:orgId/sync/bindings` | Enable sync for one environment: `{ connectionId, environmentId, destination, nameTransform?, include?, exclude?, onDelete?, mode?, wrappedDeks, acknowledgedDecryption }`. `destination` is tagged by `provider` and must match the connection: **vercel** `{ projectId, targets[], gitBranch? }`; **cloudflare-workers** `{ scriptName }`; **cloudflare-pages** `{ projectName, environments[] }` (`production`/`preview`); **cloudflare-secrets-store** `{ storeId, scopes[] }`; **railway** `{ projectId, environmentId, serviceId?, skipDeploys? }` (all UUIDs; omit `serviceId` to write the environment's shared variables); **aws-secrets-manager** `{ layout: "secret-per-name" \| "json-bundle", pathPrefix?, secretName?, kmsKeyId? }` (`secretName` required for `json-bundle`); **aws-parameter-store** `{ path, type: "SecureString" \| "String", tier, kmsKeyId? }` (`path` starts and ends with `/`); **render** `{ kind: "service", serviceId }` or `{ kind: "env-group", envGroupId }`; **fly** `{ appName }`; **northflank** `{ projectId, secretGroupId }` (both are the slugs from their URLs); **digitalocean** `{ kind: "app", appId, scope? }` or `{ kind: "component", appId, componentName, scope? }` (`appId` is the App Platform app UUID; `scope` is `RUN_TIME` (default), `BUILD_TIME`, or `RUN_AND_BUILD_TIME`); **heroku** `{ app }` (the app name or its UUID); **netlify** `{ siteId, contexts[], branch?, secret? }` (`siteId` is the site's API ID — a UUID, since Netlify resolves no site names on this endpoint and an unresolved one writes to the whole team; `contexts` is one or more of `production`, `deploy-preview`, `branch-deploy`, `branch`, `dev`, with `branch` requiring `branch`; `secret` defaults to `true` and marks what seekrit **creates** as a write-only Netlify secret); **bunnyshell** `{ kind: "environment", environmentId, secret? }` or `{ kind: "project", projectId, secret? }` (IDs are Bunnyshell's own opaque strings, from `bns environments list` / `bns projects list`; a `project` binding's values are inherited by every environment created in it *after* the push, which is how ephemeral environments get seeded, and existing ones are untouched; `secret` defaults to `true` and sets `isSecret` on variables seekrit **creates**). **github-actions** `{ kind: "repo", owner, repo }`, `{ kind: "environment", owner, repo, environment }`, or `{ kind: "org", org, visibility, selectedRepositoryIds? }` — the three scopes are three endpoints with three blast radii and three separate encryption keys; `visibility` is `all` \| `private` \| `selected` and has **no default** (`all` reaches every repository in the org, including ones added later), and `selectedRepositoryIds` is required, non-empty, and numeric for `selected` and rejected otherwise. **gcp-secret-manager** `{ layout: "secret-per-name" \| "json-bundle", idPrefix?, secretId?, replication: "automatic" \| "user-managed", locations?, kmsKeyName?, pruneVersions? }` (`secretId` required for `json-bundle`; `idPrefix` takes only `[A-Za-z0-9_-]` — a Secret Manager ID has no slashes; `locations` required for `user-managed` replication, which is immutable once a secret exists; `kmsKeyName` is a full Cloud KMS resource name and, keys being regional, is accepted only with `automatic` replication or a single location; `pruneVersions` destroys the version each push supersedes, and only ever one seekrit itself wrote). **langgraph-platform** `{ deploymentId }` (the deployment UUID — a deployment's secrets belong to the deployment, so there is nothing narrower to address, and every write creates a new **revision**, which rebuilds and rolls out the Agent Server). `wrappedDeks` is one `{ environmentId, wrappedDek }` per environment the run reads — the target **plus every composed group environment**. `acknowledgedDecryption` must be `true`. *(admin)* | | `PATCH` | `/v1/orgs/:orgId/sync/bindings/:bindingId` | Update destination, filters, `onDelete`, `mode`, or `enabled`. Cannot move a binding to another environment or connection — delete and recreate, so a fresh grant and acknowledgment are required. *(admin)* | | `DELETE` | `/v1/orgs/:orgId/sync/bindings/:bindingId` | Delete the binding and revoke its environment's grant to that connection (unless another binding still needs it). *(admin)* | | `POST` | `/v1/orgs/:orgId/sync/bindings/:bindingId/run` | Push now, synchronously. Returns `{ run: { id, status, pushed[], deleted[], failures[], error } }`. *(admin)* | | `GET` | `/v1/orgs/:orgId/sync/runs` | Run history (optionally `?bindingId=`), newest first. Carries destination key **names**, counts, and error strings — never values. *(admin)* | > **Warning:** Sync is the one feature where seekrit's servers decrypt. It applies only to environments with a `sync` key grant, and only for the destination that grant names. The grant is computed client-side, so the API cannot create one on its own — `POST /sync/bindings` fails without `wrappedDeks`. Every run writes a durable `sync.run_succeeded` / `sync.run_failed` audit row.