Platform
Security & compliance
What protects your keys and data in production today, and what does not exist yet. Live and roadmap are kept apart on purpose.
The running platform is Vercel for the application and the API routes, and Supabase for Postgres and auth. That is all of it. The Gateway is a stateless API route, so nothing of yours runs on our infrastructure between requests and there is no customer compute to secure.
The table below lists only controls that are live or on the roadmap. Infrastructure that is written down but not deployed is kept in its own section, Not deployed, because it protects nothing today.
This is the reference. The app, phone approvals, prompt injection and what is not done yet are summarised on Security.
Controls, by state
| Control | State | Note |
|---|---|---|
| API keys stored as SHA-256 hash + non-secret prefix | Live | — |
| Constant-time key comparison; distinct revoked/expired handling | Live | — |
| Scoped keys; absence of a scope is a denial | Live | — |
| Row-level security on tenant tables in Postgres | Live | Repaired 2026-09-11 |
| Write RPCs revoked from PUBLIC; SECURITY DEFINER search_path pinned | Live | — |
| Encryption at rest | Live | Supabase Postgres |
| Encryption in transit | Live | TLS for web and API traffic |
| Per-request trace + priced ledger row, including failures | Live | — |
| Audit log of org changes; CSV and JSON Lines export for a SIEM | Live | Agency and Scale plans |
| No customer compute: nothing of yours runs between requests | Live | — |
| SOC 2 Type II audit | Roadmap | No audit has been performed |
| Bring-your-own-cloud (VPC) deployment | Roadmap | — |
| Customer-managed encryption keys | Roadmap | — |
How API keys are stored
A key is lsk_<env>_<8 hex selector><48 hex secret>. The secret is twenty-four bytes from a CSPRNG, 192 bits. Two values are written to the database and the key itself is not one of them:
| Stored | Value | Secret? |
|---|---|---|
prefix | lsk_live_a1b2c3d4 — the environment marker and selector | No. Safe to print in a UI, a log or an audit entry. |
key_hash | SHA-256 of the entire key | Yes, but not reversible to the key. |
The selector exists so verification is one indexed read rather than a scan. SHA-256 rather than bcrypt or argon2 is deliberate: a slow key-derivation function is the right answer for low-entropy human-chosen passwords, and this is 192 random bits, so there is nothing to brute-force and a slow hash would only add latency to the inference path.
Comparison is constant-time. Order matters too: the hash is compared before the key's lifecycle is checked, so a wrong secret cannot learn whether a given selector names a revoked key or no key at all. Revoked and expired are reported distinctly from unknown in the error message, because that saves an hour of debugging, but all three are a 401.
Provider keys
Managed mode
In managed mode the Gateway uses Lobstack's own provider keys. They are resolved by a Vault client that reads from a Vault server when one is reachable and falls back to environment variables when it is not. In the current deployment that fallback is the path that runs: the keys are Vercel environment secrets, and the Vault half of that code is waiting on a cluster that is not deployed. Your prompts and completions are sent to the provider that serves the selected model, and to no other.
Your own provider keys
On a paid plan, an owner or admin can add the organization's own key for a provider in Settings › Provider keys. The key is encrypted with AES-256-GCM before it is stored, using a key held outside the database, and the ciphertext is bound to your organization and that provider. It is decrypted only to call the provider that issued it, and it is never shown again — only its last four characters are.
When the router picks, or you name, a model on a provider you hold a key for, the call runs on your key and is sent to that provider and no other. Calls to other providers run on Lobstack's keys. Either way the Gateway routes only among providers a key exists for.
Row-level security, and what it was worth before 11 September
The status block above lists row-level security as live. Until 11 September 2026 that was worth nothing, because every policy on the tenant tables failed.
The policy on org_members subqueried org_members. Evaluating it required evaluating it, Postgres detected the cycle and aborted, and every other table whose policy asked “is this user a member of that org” inherited the failure, because that inner read is itself subject to the recursive policy. Seven tables returned an error instead of rows: org_members, orgs, api_keys, audit_log, org_invites, gateway_requests and token_usage.
It was not a regression. The original policy had the same self-referential shape and had never worked, and nothing noticed because every route in the application reads with the service-role key, which bypasses row-level security entirely. Nothing ever exercised them — they were decoration over a door nobody tried. That is the part worth taking from this: a control that is never exercised is a control whose state you do not know.
The fix is user_org_ids(), a SECURITY DEFINER function that reads the membership table with the definer's rights, so the policy does not re-enter while it is being evaluated. It discloses nothing: it returns only the calling user's own org ids, filtered on auth.uid(), which the caller cannot influence. Every policy above was rewritten as org_id in (select public.user_org_ids()), and the result was verified by setting a real member's JWT claims and reading every one of those tables as authenticated: no recursion, and that user saw one organization where the service role sees every one.
The write RPCs are off the public API
PostgREST exposes every function in the public schema at /rest/v1/rpc/<name>. Three of ours were reachable by anon — anybody holding the publishable key, which ships in the browser bundle — and all three are SECURITY DEFINER, so row-level security would not have stopped them either.
| Function | What it writes | Now |
|---|---|---|
lobstack_record_spend | The spend ledger and the savings credit | service_role only |
lobstack_credit_topup | Paid top-up dollars on an allowance | service_role only |
resolve_user | Creates or merges a user row | service_role only |
The revoke has to name PUBLIC. Postgres grants EXECUTE to PUBLIC by default and both anon and authenticated are members of it, so revoking from those roles by name takes away a privilege they never held directly and changes nothing. The first attempt did exactly that and the linter correctly went on reporting all three.
Two functions deliberately keep public execute: current_user_id() and user_agent_ids(), alongside user_org_ids(). Policies are evaluated as the querying role, so revoking these would make every signed-in read of the tables above return nothing. None of them returns anything the caller does not already own, and each carries a comment on the function saying so, because the obvious way to quiet that lint is the wrong one. Every remaining SECURITY DEFINER function also has a fixed search_path, so a caller cannot shadow an identifier the body names and have it run with the definer's rights.
Three tables run with row-level security enabled and no policy at all — spend_allowances, credit_purchases and the pay_* family. That denies every role except the service role, which is the correct and tightest configuration for a table only ever written by a server route holding the service key. It is also reported by the linter, and the obvious “fix” is to add a permissive policy, which would open them; each table carries a comment saying not to.
Tenant isolation
| Control | Implementation | State |
|---|---|---|
| Tenant compute | None. The Gateway is a stateless API route | Live |
| Database isolation | Row-level security on tenant tables, through a SECURITY DEFINER membership lookup rather than a self-referential subquery; service role confined to server-side routes | Live |
| Key scoping | Org-owned keys; a key bound to a legacy agent row is refused if that row belongs to another org | Live |
There is no kernel sandbox, network policy or service mesh between tenants, because there is no tenant compute to put them around. The designs for them are under Not deployed.
What is logged
Every Gateway request writes a trace row and a usage row, and both survive failure. The trace carries the request id, the org, the API key id, the requested and served models, the provider, the status code, an error class, latency, time-to-first-token for streams, token counts and the priced cost. Error messages are truncated to a thousand characters, because the column exists to recognise a failure rather than archive a provider's response body.
Client identity comes from a header and is stored as such. It answers "how much of this traffic is which client" and is never allowed to influence authorization. Key usage is tracked with a one-minute resolution on last_used_at, which answers "is anything still using this key before I revoke it" without putting a row update on every request.
| Source | Contents | State |
|---|---|---|
| gateway_requests | Per-request trace: models, provider, status, error class, latency, cost | Live |
| token_usage | Priced ledger: tokens, prices applied, mode, baseline, routed flag | Live |
| Application logs | Vercel function logs: deploys, unhandled errors, route timings | Live |
Not deployed
There is a Kubernetes design in this repository: Istio with strict mTLS, gVisor sandboxes, HashiCorp Vault on Raft, Falco rules, OPA Gatekeeper, encrypted etcd. It is real code, it lives under infra/kubernetes/ and infra/terraform/, and none of it is what serves your requests. "Defined, not running" means the manifests exist and have not been applied to a cluster carrying production traffic. Read it as a plan, not as a control.
| Control | State | Note |
|---|---|---|
| HashiCorp Vault HA (Raft, Transit, templated RBAC) | Defined, not running | IaC defined |
| Kubernetes secret encryption (AES-256-CBC in etcd) | Defined, not running | IaC defined |
| gVisor (runsc) sandbox per agent pod | Defined, not running | IaC defined |
| Istio service mesh, STRICT mutual TLS | Defined, not running | IaC defined |
| NetworkPolicies blocking inter-agent traffic | Defined, not running | IaC defined |
| Falco runtime detection rules | Defined, not running | IaC defined |
| OPA Gatekeeper admission policies | Defined, not running | IaC defined |
| Kubernetes and Vault audit logging | Defined, not running | IaC defined |
SOC 2 and questionnaires
No SOC 2 audit has been performed and there is no report to send you. What exists is a controls mapping to the AICPA Trust Services Criteria, kept at infra/docs/soc2-compliance.md, written against the Kubernetes design rather than against the platform in production. It is useful as a statement of intent and as a checklist for the migration. It is not evidence.
If you are evaluating Lobstack for something with a compliance bar, ask us directly rather than working from this page, and say what the bar is. An answer of "not yet, here is the plan and the date" is one we would rather give than have you discover.
Reporting a vulnerability
Report it privately on GitHub, with enough detail to reproduce. If the issue involves a specific Gateway request, include its x-lobstack-request-id. Do not open a public issue for anything exploitable. Scope, safe harbour and the disclosure window are in the disclosure policy.
Legacy
Lobstack used to run agents in hosted VMs. That product was retired on 11 September 2026; these notes stay for anyone who used it. Each agent had its own machine on a Hetzner fleet, and the fleet was removed with the runtime. The agent-pod controls under Not deployed were designed for that runtime and never carried its traffic either.