Multi-Tenant Data Model

How one organization's data is separated from another's: row-level security, code-level scoping on API paths, and what is keyed per organization.

Last reviewed September 16, 2026Fresh

Overview

PasskeyBridge is multi-tenant: an organization (a shield_tenants row, called a tenant in the schema and an organization in the dashboard) owns every API key, playbook, event, credential and audit entry under it. Every organization-scoped table carries a tenant_id column.

Two mechanisms hold the boundary, and which one applies depends on how the request arrived.

  • A dashboard request reaches the database through PostgREST carrying the user's own JWT. Row-level security policies then restrict every row to organizations that user belongs to. The database refuses cross-organization reads regardless of what the client asks for.
  • An API request is served by an edge function holding the service-role key, which bypasses row-level security by design. There the boundary is the code: the organization id is taken from the path or the x-pb-tenant-id header, every query filters on it, and the API key is matched together with it.

Both paths are real and neither substitutes for the other. A new write path in a function is reviewed by hand, because the policy layer is not what is guarding it.

Row-level security architecture

Row-level security is enabled on the shield_* tables, and there are now more than a hundred of them.

How a user resolves to an organization. The user authenticates and receives a Supabase JWT. shield_tenant_members maps the user id to organization ids and a role (admin or member). Policies call the helper functions is_tenant_member, is_tenant_admin and get_user_tenant_ids rather than repeating the join, so the membership rule lives in one place.

Policy types. Most policies are permissive, so several policies on one table combine with OR. A few tables also carry a restrictive policy that denies client writes outright regardless of any permissive policy, which is how user_roles and comparable tables are kept read-only from the browser.

Tables the browser cannot write. Several tables have no client-facing policy at all, so only the service role reaches them. shield_client_errors is one: the browser error reporter posts to the shield-client-errors function, which writes the row server-side after its own rate limit and size cap. shield_dpop_shadow_log is another: organization admins may select from it, and nothing but the service role may insert.

Where this stops being a guarantee. API-key traffic never passes through a policy, as described above. Treat row-level security as the guard on the dashboard surface and the code-level tenant_id filter as the guard on the API surface.

Cryptographic key separation

Cryptographic material is separated per organization where a per-organization key exists. It does not exist everywhere, and the difference matters.

MaterialIsolationPer organization
API keysSHA-256 digest stored with the organization id; the lookup matches digest, organization and active flag togetherYes, by row
Credential signing keysECDSA key pair in shield_tenant_keys, P-256 (ES256) by default or P-384 (ES384) on the ML-DSA-87 tier, private key AES-256-GCM encrypted. Rotation keeps the previous key valid for 24 hoursYes
Post-quantum signing keysML-DSA-65 or ML-DSA-87 key pairs derived deterministically from the platform key through domain-separated HMAC-SHA-256 seed derivation, with the organization id inside the separatorYes, derived
Webhook signing secretscallback_signing_secret, encrypted at rest, with the previous value accepted for 24 hours after a rotationYes
BLAST session keysEphemeral X25519 pair per session, on a row carrying the organization idPer session
Encrypted stored values: PII vault fields, shadow proxy values, Slack webhook URLs, provider API keys, raw inbound SETsAES-256-GCM under one platform key, SHIELD_ENCRYPTION_KEYNo

What this means in practice. A credential issued by one organization is cryptographically unverifiable under another's key, and its signing key is useless without the platform key that encrypts it. The encrypted payload columns are a different case: they all sit under the single platform key, so their separation is the tenant_id on the row and the access checks above it, not a distinct key per organization. There is no per-organization cryptographic erasure today: deleting one organization's key material would not, by itself, make only that organization's encrypted values unrecoverable.

The platform key is a base64-encoded 32-byte value held in the platform secret store. A missing or malformed key makes every encrypt and decrypt call throw rather than fall back to plaintext.

Data boundary guarantees

Organization-scoped. Signal events and their playbook results, playbooks, API keys, webhook deliveries, audit entries, agent delegates and A2A negotiations, credentials and status lists, passkey credentials and cross-references, shadow identities, cached proofs, BLAST sessions, spatial bindings, alert rules and metrics, intelligence assessments, PII vault entries and decryption logs, SSE and CAEP streams and events, posture scans, supply-chain attestations.

Not organization-scoped. shield_client_errors (browser error reports, written by the edge function, no organization data), newsletter_subscribers (marketing, with an optional organization reference), and profiles (per user, attached to the auth user rather than to an organization).

Audit isolation. Each organization's audit log is visible only to that organization's admins, and a cross-organization audit query is not expressible through the policy layer.

Retention. The plans advertise 7-day, 90-day and 2-year event retention. No job deletes old events today, so that is an entitlement rather than an enforced lifetime. The scheduled deletion that does run touches the audit log only: the IP and user-agent digests on a row are nulled 90 days after it is written, by a daily job. Anything else is deleted on request through the DSAR pipeline, which is described in DSAR workflows.

Related from the blog