security model

Encrypted at rest.
End-to-end in flight.
Bypassable by no one. Including us.

Stack start was designed with the assumption that we'd be compromised. Every guarantee on this page is what's true even if our database is breached.

00 / defense in depth

An attacker would have to cross five layers to reach plaintext.

Each ring below is a control. They compose: a failure on one still leaves the next four. Click a ring or label to see what implements it.

PLAINTEXTthe targetPerimeterDestructive gateDB isolationEncrypted in flightEncrypted at rest
Layer · 5 of 5

Rate limit + IP audit

Sliding two-bucket weighted counter on /api/keys/resolve. New-IP detection emits a separate key.resolved_new_ip audit event when a key is hit from an IP that hasn't seen it in the last 7 days.

app/api/keys/resolve/route.ts
01 / threat model

What we see. What a breach would expose.

What stack start sees: the credentials you connect via OAuth or paste at setup, the metadata of your stack (which providers, which envs, which roles), and the audit events that flow through every action. We never see your application's data. That lives on your providers, not ours.

A database compromise without the HPKE key would expose pgcrypto-encrypted credential ciphertext (useless without ENCRYPTION_KEY), stack metadata, and audit log rows. No raw bearer tokens. Those are bcrypt-hashed.

A compromise withHPKE opt-in would expose effectively nothing useful. Resolve responses are encrypted to your client's ephemeral X25519 keypair before they leave our process. Our database holds the ciphertext for the duration of the request, then drops it. There is no plaintext anywhere on disk.

02 / at rest

pgcrypto-encrypted credentials.

Every provider credential (your OAuth tokens, your pasted API keys, your refresh tokens) is encrypted with pgcryptosymmetric encryption using a server-side master key from the host's secret manager, not our database.

Plaintext credentials never sit in any table column, log line, or API response body. Only at the precise moment of provisioning (when we call the provider's API on your behalf) and resolve (when we hand them back to your authorized agent).

credentials · row
provider:    "neon"
ciphertext:  \xc30d04070302a1b2…
algo:        "pgcrypto · aes256"
key_ref:     "vault://enc/v3"
# plaintext: never stored
03 / in flight

HPKE optional E2E encryption.

Opt into HPKE on the resolve endpoint and the encrypted bundle is sent to your client's ephemeral X25519 keypair. The keypair lives only in your CI process or editor session, so we never see it. We compute the ciphertext, hand it back, and forget. A leaked response cache or HTTP log doesn't decrypt to anything useful without your private key.

sdk · ts
import { fetchBundle } from "@stackstart/sdk";

const bundle = await fetchBundle({
  env: "production",
  encrypted: true,   // opt into HPKE
});
// bundle.DATABASE_URL decrypts in your process

suite: DHKEM(X25519) · HKDF-SHA256 · ChaCha20-Poly1305 · RFC 9180

03a / resolve pipeline

What happens between Bearer ss_… and the resolved bundle.

Every step is reachable from one entry point: GET /api/keys/resolve. The order below is the order it actually runs.

01

Authorization arrives

Agent posts Authorization: Bearer ss_… (or our demo bearer). No Clerk session is required on this endpoint, by design.

bearer auth contract · stable since F-1
02

Demo fast-path check

If the bearer matches the public demo constant, we skip the DB scan entirely and return the curated demo bundle with an IP-scoped rate-limit.

lib/keys/demo.ts · 30 / hour / IP
03

Prefix-bucket scan

Lookup hits stack_keys filtered by the 8-char prefix plus is_active=true. The bucket size is tiny in practice and never widens with key count.

indexed scan on (key_prefix, is_active)
04

Constant-time bcrypt verify

Each candidate row is verified with bcryptjs.compareSync, which calls safeStringCompare under the hood. No early exit on the first byte mismatch.

bcrypt cost 12 · safeStringCompare
05

Sliding rate limit + new-IP check

Two-bucket weighted SUM of the current + previous hour's usage. If the request arrives from an IP unseen for 7 days, we emit a key.resolved_new_ip audit event in parallel.

key_usage_counters · audit_events
06

RLS-scoped decrypt

Provider connections decrypted via pgcrypto inside a transaction whose Postgres role is app_authenticated with auth.user_id() set to the bearer's owner. Cross-workspace rows are refused at the DB.

withAuth() · pgp_sym_decrypt
07

HPKE encrypt (if client pubkey)

When the request carries the X25519 ephemeral pubkey header, the response body is encrypted DHKEM / HKDF-SHA256 / ChaCha20-Poly1305 to that key. We compute the ciphertext and forget.

lib/resolve/hpke.ts · RFC 9180
08

Audit row + bundle returned

A key.resolved row is written with bearer hash prefix, IP, user agent, env, services resolved, and timestamp. Never the bearer itself.

audit_events insert
04 / key handling

Shown once. Never replayable.

Your master key is shown exactly once, at generation. We store only a bcrypt hash. The raw key is not recoverable by anyone, including us. The same applies to derived keys. Minted, shown, gone.

Lost a key? Rotate it. The underlying provider credentials don't change. Only the resolver token does. The old key returns 401 the instant the new one is issued, with no overlap window.

04a / master key lifecycle

From mint to revoke, every state we keep is intentional.

The diagram below auto-cycles every 3.2 seconds. Click any stage to pin it. The lower panel calls out exactly what stays and what we discard at each step.

What stays at Mint
  • 192 bits of entropy from crypto.randomBytes
  • Prefix-bucketed format so leaks don't narrow search
  • Shown to the operator exactly once at generation
What we don't keep

The raw key after the 201 response leaves the server

This is the rule even if our database is breached. The state listed here doesn't exist anywhere to be exfiltrated.
04b / derived keys

The master mints scoped children. Children don't.

A derived key is a sub-bearer minted from the master with an env scope, a provider exclusion list, and a TTL. Revoking the master cascades to every child in the same transaction. Derived keys cannot mint derived keys, by design.

master keyss_4f9c·a02e·b711·…derivedssk_prod_8c2eCI / GitHub Actionsenv: prod · ttl: 60 sno exclusionsderivedssk_dev_a17bEngineer · Miraenv: dev, staging · ttl: 8 hexcludes: clerkderivedssk_dev_f0d4Contractor · 24h passenv: dev · ttl: 24 hexcludes: clerk · stripe · sentry
↳ rule

Chains banned

A derived key can never be the parent of another derived key. The mint path checks parent_key_id IS NULL and refuses otherwise. Compromise stays bounded to a single sub-key with a known TTL.

↳ rule

Master revoke cascades

Revoking the master flips is_active=false on every child where parent_key_id = master.id, in the same transaction. The resolve endpoint refuses both immediately.

↳ rule

Policies tighten, never loosen

Workspace key policies merge restrictively: intersect allowed envs, min TTL, union excluded providers, OR require_reason. They cannot grant prod access to a role that role-gating already denied.

05 / attribution

Every action attributable to a person.

Every event the system writes lands in audit_events with the actor, the network identity, the structured payload, and the workspace scope. Five questions, one row: who, from where, what, when, and which workspace.

schema
audit_events {
  workspace_id   uuid
  actor_user_id  uuid
  actor_role     text
  actor_ip       inet
  event_type     text
  metadata       jsonb
  created_at     timestamptz
}
06 / isolation

Postgres row-level security on every table.

Cross-workspace data leakage is not blocked by application code. It's blocked at the database layer. Stack start runs application queries as a non-owner Postgres role with RLS policies that enforce workspace_id = current_setting(...) on every workspace-scoped table. The application could have a bug that forgets a WHERE clause; the database refuses the row anyway.

07 / compliance

Where we are. Where we're going.

standardstatus
SOC 2 Type IIin progress · Q3
HIPAAenterprise plan
GDPRdata deletion + export on request
SCIM directory syncenterprise plan
08 / scope limits

Things we don't do.

We don't run your code.

We're a credential layer, not a runtime. Your code runs wherever it always ran.

We don't store your data.

Our database holds your provider credentials (encrypted), stack metadata, and audit events. Your application's data lives on YOUR Neon, YOUR Supabase, YOUR R2.

We don't see your resolved env vars.

With HPKE opt-in, we encrypt and forget. The plaintext only exists in your process.

09 / responsible disclosure

Found a vulnerability?

Email security@stackstart.co with a description of the issue, reproduction steps, and an email we can reach you at. We acknowledge within 24 hours and fix-confirm within 7 days for issues affecting credential confidentiality or audit-log integrity. We don't run a paid bounty yet, but we credit responsible reporters in the security changelog with consent.