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.
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.
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.tsWhat 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.
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).
provider: "neon"
ciphertext: \xc30d04070302a1b2…
algo: "pgcrypto · aes256"
key_ref: "vault://enc/v3"
# plaintext: never storedHPKE 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.
import { fetchBundle } from "@stackstart/sdk";
const bundle = await fetchBundle({
env: "production",
encrypted: true, // opt into HPKE
});
// bundle.DATABASE_URL decrypts in your processsuite: DHKEM(X25519) · HKDF-SHA256 · ChaCha20-Poly1305 · RFC 9180
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.
Authorization arrives
Agent posts Authorization: Bearer ss_… (or our demo bearer). No Clerk session is required on this endpoint, by design.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- ss_ + base64url(24)
- bcrypt cost 12
- hash + prefix only
- prefix scan + verify
- is_active=false + cascade
- 192 bits of entropy from crypto.randomBytes
- Prefix-bucketed format so leaks don't narrow search
- Shown to the operator exactly once at generation
The raw key after the 201 response leaves the server
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.
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.
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.
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.
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.
audit_events {
workspace_id uuid
actor_user_id uuid
actor_role text
actor_ip inet
event_type text
metadata jsonb
created_at timestamptz
}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.
Where we are. Where we're going.
| standard | status |
|---|---|
| SOC 2 Type II | in progress · Q3 |
| HIPAA | enterprise plan |
| GDPR | data deletion + export on request |
| SCIM directory sync | enterprise plan |
Things we don't do.
We're a credential layer, not a runtime. Your code runs wherever it always ran.
Our database holds your provider credentials (encrypted), stack metadata, and audit events. Your application's data lives on YOUR Neon, YOUR Supabase, YOUR R2.
With HPKE opt-in, we encrypt and forget. The plaintext only exists in your process.
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.