Skip to content

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

ControlStateNote
API keys stored as SHA-256 hash + non-secret prefixLive—
Constant-time key comparison; distinct revoked/expired handlingLive—
Scoped keys; absence of a scope is a denialLive—
Row-level security on tenant tables in PostgresLiveRepaired 2026-09-11
Write RPCs revoked from PUBLIC; SECURITY DEFINER search_path pinnedLive—
Encryption at restLiveSupabase Postgres
Encryption in transitLiveTLS for web and API traffic
Per-request trace + priced ledger row, including failuresLive—
Audit log of org changes; CSV and JSON Lines export for a SIEMLiveAgency and Scale plans
No customer compute: nothing of yours runs between requestsLive—
SOC 2 Type II auditRoadmapNo audit has been performed
Bring-your-own-cloud (VPC) deploymentRoadmap—
Customer-managed encryption keysRoadmap—

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:

StoredValueSecret?
prefixlsk_live_a1b2c3d4 — the environment marker and selectorNo. Safe to print in a UI, a log or an audit entry.
key_hashSHA-256 of the entire keyYes, 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.

Losing a key means replacing itThe full key is returned once, at creation. There is no recovery path, by construction. Revoke and mint a new one from Console → API Keys.

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.

select count(*) from org_members, as a signed-in user
ERROR 42P17: infinite recursion detected in policy for relation "org_members"

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.

FunctionWhat it writesNow
lobstack_record_spendThe spend ledger and the savings creditservice_role only
lobstack_credit_topupPaid top-up dollars on an allowanceservice_role only
resolve_userCreates or merges a user rowservice_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

ControlImplementationState
Tenant computeNone. The Gateway is a stateless API routeLive
Database isolationRow-level security on tenant tables, through a SECURITY DEFINER membership lookup rather than a self-referential subquery; service role confined to server-side routesLive
Key scopingOrg-owned keys; a key bound to a legacy agent row is refused if that row belongs to another orgLive

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.

SourceContentsState
gateway_requestsPer-request trace: models, provider, status, error class, latency, costLive
token_usagePriced ledger: tokens, prices applied, mode, baseline, routed flagLive
Application logsVercel function logs: deploys, unhandled errors, route timingsLive

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.

ControlStateNote
HashiCorp Vault HA (Raft, Transit, templated RBAC)Defined, not runningIaC defined
Kubernetes secret encryption (AES-256-CBC in etcd)Defined, not runningIaC defined
gVisor (runsc) sandbox per agent podDefined, not runningIaC defined
Istio service mesh, STRICT mutual TLSDefined, not runningIaC defined
NetworkPolicies blocking inter-agent trafficDefined, not runningIaC defined
Falco runtime detection rulesDefined, not runningIaC defined
OPA Gatekeeper admission policiesDefined, not runningIaC defined
Kubernetes and Vault audit loggingDefined, not runningIaC defined
Do not quote this table to a security reviewerNothing in it is enforcing anything in production. If a questionnaire asks whether you run mTLS between services or a kernel sandbox per tenant, the honest answer today is no, and the artefact to show is the manifest, not a running cluster.

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.

Lobstack

An AI team that asks before it acts, and an API with a receipt on every call.

© LobstackXLinkedInGitHub