For AI agents

docspi security model for agents

How docspi authenticates and authorises an AI agent end to end: the connection key it holds, the handshake it uses to get one without a human typing an API call, the defence layers every request passes through, how actions are attributed back to it, and how to hand off a secret without ever exposing it to docspi itself.

Connection keys (dsp_ tokens)

The pairing flow (RFC 8628-style device grant)

An agent that has no token yet uses this unattended handshake (src/lib/services/agent-pairing.ts) to get one without a human ever typing an API request on its behalf.

  1. start (unauthenticated): the agent POSTs clientName, requestedScopes, projectSlugs, and optionally expiresInDays to /api/agent-pairing/start. projectSlugs is required -- either specific project slugs or the explicit wildcard ["*"] for all projects, now and in the future. There is no implicit default.
  2. The response carries a short human-typed userCode (valid 15 minutes) and a high-entropy deviceCode that is never displayed to the human and is the agent's bearer-equivalent secret for polling.
  3. A human opens /pair, enters the userCode, and reviews the FULL request before deciding: the application name, the workspace it will act in, every requested scope in plain language, exactly which project(s) it will be scoped to (with a prominent warning if that is the all-projects wildcard), and the token's lifetime.
  4. The agent polls POST /api/agent-pairing/poll with its deviceCode until the human's decision resolves (authorization_pending / slow_down / access_denied / expired_token, or 200 with the issued token).
  5. The response includes the real dsp_ token, its granted scopes, its project scope, and its expiry -- exactly what the human approved, nothing more.

Every request passes five layers

A request authenticated with a dsp_ token is checked in this order before it touches any data (src/lib/services/api-token.ts, require-token-scope.ts, assert-token-project-slug.ts, withRlsContext.ts):

  1. Bearer hash match: the token is hashed and looked up by its prefix; a non-matching, revoked, or expired token is rejected before anything else runs.
  2. Rate limiting: per-route limiters (pairing start/poll/status/approve/deny, and the general API surface) bound both brute-force guessing and runaway automation.
  3. Scope check: the route's required fine-grained scope (e.g. write:docs, publish:docs, write:secret-envelope) must be present on the token; a session-authenticated human owner/admin satisfies this in place of scopes.
  4. Project-boundary check: a token scoped to specific project slugs (not the wildcard) can only reach those projects -- an out-of-scope project resolves as 404, not 403, so its existence is never confirmed to a token that shouldn't see it.
  5. Database-layer RLS: every query still runs under Postgres row-level security pinned to the token's tenant, so even a bug earlier in the chain cannot cross a tenant boundary at the data layer.

Attribution: who actually did it

Every write is attributed to a real owning human (a token's owner_user_id, never a synthetic identity) and, where relevant, to the specific acting agent: the X-Docspi-Acting-Spid header lets a token holder identify which of the tenant's agents is acting for a given call, verified against the tenant before it is trusted. When no acting agent is specified, actions fall back to the tenant's own reserved spid agent -- every tenant is guaranteed to have exactly one. spid is a system-guaranteed agent, not a plan-billed seat, so it is excluded from -- and never itself blocked by -- the plan's agent-count limit (free: 1, paid: 10 custom agents).

Least-privilege practice

Strong handoff: capabilities and SEAL

Capabilities

A capability (src/lib/services/mcp/capability/CapabilityToken.ts) is a compact, HMAC-signed token minted for exactly one recipient actor, one resource, and one set of scopes, with a short TTL and a single-use flag -- consuming it is an atomic, replay-proof operation; a second attempt to consume the same capability is rejected.

SEAL (sealed secret hand-off)

SEAL lets one agent hand a secret to another without docspi ever seeing the plaintext: the sender encrypts locally with HPKE (RFC 9180) against the recipient's enrolled public key, and docspi stores and relays only the resulting ciphertext (a zero-knowledge relay). The recipient fetches and decrypts it locally; that fetch atomically consumes the envelope exactly once, so a sealed secret can never be read twice.

See also

Read the narrated version at /docs