Security
Where the key is, at every hop
This page is written for the person who has to sign off on putting a production credential behind a service. It says what the design holds by construction and what your own catalogue and policy decide. Both are checkable on your own machine, and this page says how.
The architecture
Three hops and one field
Two roles run from one binary. Control holds the database, the vault and the journal. The executor makes the outbound call and lives on a network with no route to either. A credential leaves the vault once per call, inside the process that is about to use it.
The line
What the design holds, and what your catalogue decides
The first list is structural. It is true because of how the code is shaped, and tests fail when it stops being true. The second list is yours to steer, and the console shows you the whole of it for your own tenant.
- Held by construction
- No response to an agent carries a credential value. No route on this platform reads one back, for an owner, for support, or over the API. The executor replaces the exact injected bytes, and their base64 form, in the body, the headers and the Location of every answer. Outgoing bytes are scanned for our own token prefixes and for the injected value, and a match refuses the call.
- Decided by your catalogue
- Your agent does not get a secret from us. It can get a secret from an upstream if you allow it a path that hands one out. Those paths are known and named: GitHub installation tokens, GitLab CI variables, Vercel env with decrypt, Vault kv. The recipe marks them, they take the destructive class, the default is refuse, and your console lists every one of them in your own tenant.
- Held by the two database roles
- The control plane connects as a role that owns nothing and cannot bypass row-level security. Isolation runs at two levels, tenant and workspace, set inside every transaction. A partitioned table gets its policy on every partition, and a test over the catalogue tables fails the build if one is missing.
- Held by the network
- The executors sit on one network with the control plane and nothing else: no database, no object storage, no keys. They receive one credential for one call over mutual TLS. For SSH they never receive the private key at all and ask the control plane to sign each challenge.
The exact list
The paths that hand out a credential of their own
Some endpoints answer with somebody else's secret, and a GET is enough. GitLab hands over every variable a project has. Vercel decrypts an environment on request. AWS mints session credentials. Each of these is marked in the catalogue, takes the destructive class whatever its verb, and is refused until you allow it. This table is generated from the catalogue this site ships with, so it is the list the engine classifies by.
| Service | Path | What happens |
|---|---|---|
| Amazon Web Services | ^sts(\.[a-z0-9-]+)?\.amazonaws\.com$ | destructive, refused by default |
| Amazon Web Services | X-Amz-Target: (?i)(^|[._])CreateAccessKey$ | destructive, refused by default |
| Amazon Web Services | ?Action=^CreateAccessKey$ | destructive, refused by default |
| Amazon Web Services | Action=^CreateAccessKey$ | destructive, refused by default |
| Amazon Web Services | X-Amz-Target: (?i)(^|[._])GetSecretValue$ | destructive, refused by default |
| Amazon Web Services | X-Amz-Target: (?i)(^|[._])GetParameters?$ $.WithDecryption ~ ^true$ | destructive, refused by default |
| GitHub API | POST ^/app/installations/[^/]+/access_tokens$ | destructive, refused by default |
| GitHub API | POST ^/(repos/[^/]+/[^/]+|orgs/[^/]+)/actions/runners/(registration|remove)-token$ | destructive, refused by default |
| GitLab API | ^(/api/v4)?/(projects|groups)/[^/]+/variables | destructive, refused by default |
| GitLab API | POST ^(/api/v4)?/(projects|groups)/[^/]+/(deploy_tokens|access_tokens|runners) | destructive, refused by default |
| Sentry API | GET HEAD POST ^/api/0/projects/[^/]+/[^/]+/keys/ | destructive, refused by default |
| Slack Web API | ^/api/(oauth\.(v2\.)?access|apps\.connections\.open|tooling\.tokens\.rotate)$ | destructive, refused by default |
| Vercel API | /env ?decrypt=^true$ | destructive, refused by default |
| Vercel API | GET HEAD ^/v[0-9]+/projects/[^/]+/env/[^/]+$ | destructive, refused by default |
Your console shows the same list for your own tenant, filtered to the bindings your agents actually have.
The vault
Envelope encryption, and no way to read a value back
Four steps, each one narrower than the last. A root wrapper on the host opens a per-tenant key. That opens a per-domain key: one for credentials, one for the journal, one for the bodies and SSH session recordings you choose to keep. From it a per-record subkey is derived with HKDF-SHA256. The record is sealed with ChaCha20-Poly1305 under an authenticated header of tenant, domain, record and version. Change any of the four and the record stops opening.
- One unwrap path, and it opens on one state
- The engine unwraps a value for a call that has already moved to dispatching. There is no second caller, no export, no support tool and no admin screen, and a test walks the package to keep it that way.
- The root key has a second copy from the minute it exists
- At generation it is split into three shares, and any two of them rebuild it. One share with the owner's secret manager, one offline, one in a cloud key service. Taking a share is an event in the platform journal.
- A daily self-test, so a monitor tells you first
- A job unwraps a test key every day and publishes the result as a metric with an alert on it. The failure mode this catches is the one where everything answers and every call with a credential fails.
The journal
Written before the call, sealed away from its writer
The intent row and the chain update share one transaction with the dispatch decision, so the record exists before the side effect does. Sequence numbers come from a counter row rather than a database sequence. A sequence hands out numbers that survive a rollback, and an unexplained gap is a gap an auditor reads as a deletion.
- Sealed with age, to a recipient the writer does not hold
- Each event body is encrypted to a per-tenant X25519 recipient before it is hashed. The process that writes the journal holds the public half and reads nothing back.
- Checkpoints you keep, signed with Ed25519
- A Merkle root over the chain, signed, available over the API and pushed to your SIEM with the sequence number and the row hash. Your SIEM becomes a witness for the segment it holds. The chain format carries its own version string, spc-chain/1, so a verifier written today keeps working against an export taken tomorrow.
- A verifier that holds none of our keys
- Point the open-source tool at any export. It recomputes the chain, checks the signature and tells you where the first break is. A corrupted row in the golden export is part of our own test suite.
Your data, not only your keys
The bodies your calls carry
A credential is not the only thing that moves through here. The Stripe response with a customer record on it, the diff from GitHub, the output of an SSH command: they all pass through the executor. Here is what happens to them.
- Capture is off until you turn it on
- A binding keeps request and response bodies only when you ask it to. Until then the journal holds the shape of the call and its result, and none of the content.
- What is kept is sealed the way the journal is
- Stored bodies and SSH session recordings sit under their own per-domain key, encrypted per record, referenced from the journal by handle. They follow the retention on your plan and their own time to live.
- Notifications carry no content, ever
- A Telegram card or an email holds the target of a call and a link to an authenticated screen. A decision is only ever taken on a surface you signed in to.
- Deleting a tenant destroys the keys that open them
- Crypto-shredding, rather than a delete job you have to trust. After it, no record of that tenant opens again, here or anywhere else.
The executor holds a body for exactly as long as the call takes. It has no database, no object storage and no route to either.
Egress
An agent cannot aim the gateway at the gateway
The deny list is compiled into the binary and configuration can only add to it. Private ranges, loopback, link-local, carrier-grade NAT, the deploy network and our own hostnames are refused whether or not a binding exists for them.
- The name is resolved again on every execution
- Including the execution that follows a wait for approval. A host that was public when a person answered the card can point at a metadata address by the time the call runs. The address is resolved and pinned at dispatch, and the call goes to the address that was checked.
- Redirects with a credential on them stop
- A 3xx comes back to the agent as a result. The gateway does not follow it with your key attached.
- A limit sends the call to approval
- Three limiters run at once, per agent, per binding and per tenant. When one fires, the call goes to approval rather than to a dead end. A rate limit answers with a retry time, in words a model can act on.
The specification
The MCP revision of 2026-07-28 asks for this shape and forbids the other one
A server that holds tokens bound to a user must not transmit them to the client. Passing a client's token through to an upstream is refused in the same words. Every vendor that hands your agent a raw token is on the other side of that sentence.
- Both live revisions, one handler each
- The 2026-07-28 revision is stateless: no session header, no server-sent stream, discovery instead of initialize. The 2025-11-25 revision keeps its session. One handler doing both is wrong for both.
- The tool list is private to a binding
- Your list differs per agent, so it is marked private. A client that caches it shared would show one tenant another tenant's tools.
- An authorization server with the flags the connectors need
- Protected resource metadata, client id metadata documents, dynamic registration for compatibility, proof key exchange, the resource indicator and the issuer parameter. A consent registry per user and client, with a consent screen written against the specification's own checklist for a proxy holding a static client id.
When we are down
You keep working without us
Every workspace has a printable sheet in the console, kept current by the product. Each binding, the service, the credential, the provider that issued it, and the URLs to reissue and revoke it. A team that cannot work while we are down is a team that will not put us on a critical path.
- Pending approvals survive a restart
- State lives in the database. A control plane deploy does not drop a waiting call, and an agent continues on the same call id.
- An unknown outcome stays unknown
- If a replica dies after the upstream acknowledged the request, the call is marked with an outcome nobody knows and it is never retried automatically. A duplicate charge is worse than a missing row.
- A stolen agent token is a detectable, revocable, bounded incident
- A new origin raises a signal, the agent pauses, the token rotates with an overlap, and the journal shows exactly which calls carried it.
Read the verifier before you trust the journal
The tool is open, the export is a file, and the check runs on your machine with none of our keys.
Free for 10,000 calls a month. Every plan exports the journal and every plan gets checkpoints.