Security

STACK is the runtime control plane for AI agents. This page describes what the product protects, where trust boundaries sit, and what remains the operator's responsibility. For the conceptual model of each layer, see the Concepts section.

Data residency and GDPR

Every byte STACK stores sits in the EU. The control plane runs on Fly.io in the fra region (Frankfurt); persistent storage (credentials, passports, audit log, drop-off payloads, identity claims) lives in Fly Managed Postgres in the same region, with KMS-wrapped envelope encryption backed by AWS eu-north-1. Team is in Stockholm. Nothing crosses the Atlantic.

  • EU data residency - all PII, credentials, and audit history remain in the EU
  • GDPR compliant by design - lawful_basis declarations required on every L2 identity requirement; right-to-erasure implemented via claim revocation with operator auto_revoke cascade
  • KMS envelope encryption at rest - PII uses a separate key from service credentials
  • Claim references only in JWTs - passport tokens carry claim_id pointers, never raw identity data
  • Audit transparency - operators can export and externally verify the hash chain at any time

Threat model

STACK's primary threat is a compromised or misbehaving agent talking to upstream services it was never authorized to touch. The five layers defend against this in order:

  • L1 Passport - replaces long-lived API keys with short-lived EdDSA-signed JWTs scoped to declared services and parameters
  • L2 Proxy - injects credentials at the boundary, verifies the passport on every call, and refuses anything outside its scope or constraints
  • L3 Detectors - score behavior for drift, burst, and post-checkout access; fire security events and, in enforced mode, revoke mid-session
  • L4 Audit - every decision from every layer lands in a per-operator SHA-256 hash chain (see /docs/concepts/audit)
  • L5 Revocation - single call kills a passport; cascade and Redis pub/sub push the kill across the fleet within 60 seconds

Out of scope: SQL injection into services STACK does not proxy, compromise of the agent runtime itself (the LLM provider, the host), and any long-lived token the operator has already issued outside STACK and still accepts upstream.

Authentication

Three token types, each with a distinct purpose:

  • Operator API keys (sk_live_op_…) - hashed with an HMAC-SHA256 pepper before storage; plaintext never persists; resolved via constant-time compare
  • Team member API keys (sk_live_mem_…) - same hashing; each key scoped to a role and an optional allowed_connections list
  • Session JWTs - EdDSA-signed, 24-hour TTL, issued from passwordless magic-link flow; audience stack:dashboard
  • Passport JWTs - EdDSA-signed, 15-minute default TTL (1 hour max), audience stack:passport; verifiable offline against the public JWKS endpoint

Cryptography

  • Passport signing - EdDSA (Ed25519); public key published at /v1/.well-known/jwks.json
  • Audit chain - SHA-256 over canonical JSON of each row including prev_entry_hash
  • Credential storage - AWS KMS envelope encryption, per-operator data-encryption key
  • Identity PII - separate KMS key from credentials; only a claim_ref travels in the passport
  • Drop-off payloads - AES-256-GCM per package, key wrapped by KMS, deleted on collect
  • Webhook signatures - HMAC-SHA256; header X-STACK-Signature-256 = hex(HMAC(body, destination_secret))

Revocation model

Revocation is authoritative state, not a cache miss. A revoked passport is rejected on the next verify call and every subsequent one. Two safeguards broaden the blast radius:

  • Parent → children cascade - revoking a parent walks parent_passport_id breadth-first and revokes every live descendant
  • Automatic cascades - disconnecting a service revokes every passport using it; revoking a team member revokes their active passports; identity claim expiry with auto_revoke on triggers a claim-scoped bulk revoke

A Redis pub/sub event on stack:revoked lets proxy workers invalidate their local caches eagerly. Full model: /docs/concepts/revocation.

Audit and tamper-evidence

Every decision lands in the append-only audit log with aprev_entry_hash pointer to the previous row'sentry_hash. STACK ships a verifier and a chain-head endpoint so you can anchor the head externally and prove later that no row was retroactively mutated.

  • GET /v1/audit/chain-head - anchor externally on a cadence you control
  • GET /v1/audit/verify-chain - re-walks the chain and reports the first break
  • Retention is tier-scoped (7d free, 30d developer, 90d studio, 365d enterprise)

What tamper-evidence catches: silent mutation of any row, insertion, deletion, or reordering. What it does NOT catch on its own: a full-control rewrite of the chain plus recomputation of every hash. The externally-anchored head is what protects against that - if the current head diverges from your anchored head, you know the chain was rewritten.

Sealed execution

Skills published in sealed mode run in an isolated Fly.io machine per invocation. Properties:

  • No filesystem, no raw network, no process spawn; only the provided helper APIs
  • Execution timeout of 30 seconds; memory cap of 128 MB
  • eval/Function constructors disabled
  • Encrypted system prompt and script decrypted inside the sandbox only
  • Credential proxy call lets the skill hit the buyer's or seller's connected services without ever seeing the raw credential
  • Every call is billed by metered compute plus 15% markup; full cost breakdown returned with the result

Key compromise playbook

Operator API key leaked

  • Regenerate from the dashboard (Account → API key). Old key is invalidated immediately
  • Rotate any downstream systems that held the old key
  • Review /v1/security-events for unfamiliar traffic before regeneration

Team member API key leaked

  • Revoke the member from the Team page - DELETE /v1/team/members/:id
  • All active passports issued under that key remain valid until their TTL expires; revoke-session or revoke-agent to kill them immediately

Passport leaked

  • Single-passport revoke: POST /v1/passports/revoke with { jti, reason } - cascades to any delegated children
  • If you don't know the jti but know the session: POST /v1/passports/revoke-session/:sessionId
  • If you cannot isolate the blast radius: POST /v1/passports/revoke-all with confirm: true

PII handling and GDPR

  • L2 identity claims carry a claim_ref in the passport; the underlying PII is KMS-encrypted in the identity_claims table
  • Service requirements declare requires_pii and lawful_basis at issue time; these are enforced at verify
  • Lawful basis values: consent | legitimate_interest | legal_obligation | vital_interest | public_task | contract
  • Identity claims are revocable. Revocation cascades to every passport carrying the claim if auto_revoke is enabled on the operator
  • Data-subject requests (export, deletion) reach via the operator account - STACK has no direct channel to end users behind agents

Rate limits and abuse prevention

  • Global default: 100 requests per minute per API key
  • Auth-sensitive endpoints (/v1/passports/issue, /v1/passports/verify, /v1/passports/revoke-all, /v1/identity/verify/initiate, /v1/webhooks/*): 20 requests per minute
  • Rate-limit responses return 429 with a Retry-After header; exceeded requests are not audited as failures
  • Proxy: tier-scoped monthly allowance, overage opens the wallet-billed path (see /docs/api/billing)
  • Credential burst detector fires at 10 retrievals in 60 seconds per passport

Reporting a vulnerability

Please email security@getstack.run with reproduction steps. Do not open a public issue or discuss details in Discord or Slack until we have confirmed the fix is shipped. We triage within one business day.

stack | docs