Using SurrealDB Table Permissions as the Authorization Boundary in a Multi-Tenant App
SurrealDB table permissions can genuinely serve as the tenant-isolation boundary — if you model tenancy as an indexed field, authenticate clients via record access, keep admin credentials off clients, and probe allow/deny behavior directly.
02 Jul 2025, 18:44 UTC

If you are building a multi-tenant application on SurrealDB, you face a concrete decision: enforce tenant isolation in application code, or push it down into the database with table-level PERMISSIONS clauses. The practical takeaway is that SurrealDB's permission system is genuinely capable of being the enforcement boundary for a straightforward tenant model — but only if you understand which connections it governs, which read paths it may not cover, and which credentials silently bypass it.
This note walks through the requirements, the smallest viable design, where the trust boundary actually sits, how to verify it, and the conditions that should push you toward a different architecture. Syntax examples assume a recent SurrealDB 2.x release; SurrealQL has changed materially across major versions, so pin your server version and check every statement against the documentation for that exact version before deploying.
Requirements worth stating out loud
The design below fits a specific profile. Check yours against it:
- Every record belongs to exactly one tenant, and tenants rarely or never share records.
- Clients authenticate directly to SurrealDB (or through a thin API) using per-user record credentials, not a shared service account.
- Authorization rules are simple comparisons: \"this user may touch records belonging to their tenant,\" possibly plus role checks.
- You can accept per-row predicate evaluation cost on reads and writes.
If several of these are false — cross-tenant analytics, shared records, policy that must be shared with non-SurrealDB services — skip to the last section; the in-database approach will fight you.
The smallest suitable design
Five pieces, no more:
- One SCHEMAFULL table per entity. SCHEMAFULL enforces field types and constraints, which matters because a missing or mistyped tenant field can quietly break a permission predicate.
- A tenant field on every tenant-owned table. Modelling tenancy as a plain field (
tenant: record<tenant>) is simpler than modelling it as a graph edge viaRELATE. An edge-based model makes some relationship queries elegant, but it complicates permission predicates and index choices. Start with the field; move to edges only if traversal queries dominate. - An index on the tenant field. Without it, permission-filtered queries degrade into full scans as data grows.
- One record access definition. This defines how end users sign up and sign in, and mints a token whose claims become session variables available inside permission expressions. The exact statement name differs across major versions (
DEFINE ACCESS ... TYPE RECORDin 2.x versus older scope syntax), so verify against your pinned version. - PERMISSIONS clauses per operation. Each table declares boolean expressions for
select,create,update, anddelete, evaluated against the record and the authenticated session.
A representative sketch (2.x-style syntax — treat as illustrative, not copy-paste verified):
DEFINE TABLE account SCHEMAFULL;\nDEFINE FIELD tenant ON account TYPE record<tenant>;\nDEFINE FIELD email ON account TYPE string;\nDEFINE INDEX account_tenant ON account FIELDS tenant;\n\nDEFINE ACCESS user ON DATABASE TYPE RECORD\n SIGNIN ( SELECT * FROM account WHERE email = $email AND crypto::argon2::compare(pass, $pass) );\n\nDEFINE TABLE document SCHEMAFULL\n PERMISSIONS\n FOR select, update, delete WHERE tenant = $auth.tenant\n FOR create WHERE tenant = $auth.tenant;\nDEFINE FIELD tenant ON document TYPE record<tenant>;\nDEFINE INDEX document_tenant ON document FIELDS tenant;The idea: when a user signs in through the record access definition, $auth carries their record's fields, including tenant. Every operation on document is then checked against that claim. Note the asymmetry to verify on your version: on create, the predicate evaluates against the incoming data, so you are asserting the client cannot write a record into another tenant.
Where the trust boundary actually sits
This is the part teams get wrong. Table permissions are enforced only for connections authenticated through the record-access path. Root, namespace, and database-level credentials bypass them entirely. Consequences:
- Administrative credentials must never ship to clients, appear in front-end bundles, or live in environment variables on user-facing services. If your API layer connects as a database user, the database is no longer enforcing isolation — your API is, and this whole design collapses into the traditional \"authorize in application code\" model.
- Results observed over an admin connection prove nothing about client isolation. A query that \"looks correctly filtered\" in your admin console may simply be unfiltered.
- An embedded SurrealDB instance running inside the client process is not a security boundary at all. Treat it as local storage.
The boundary, drawn plainly: the SurrealDB server process, reached over the network with record-level credentials, is the enforcement point. Everything on the client side of that connection is untrusted.
Operational checks
Because permission predicates fail closed-but-silently (a false predicate typically yields empty results, not an error), you cannot rely on error messages to tell you the rules work. Build a probe suite instead:
- Run a pinned-version local instance (official container image or binary), apply the schema, and seed two tenants with a few records each.
- Connect twice as two different record users. Attempt cross-tenant selects, creates with a foreign tenant field, updates, deletes, and any edge traversals your schema uses. Record allow/deny for each. Expected result: every cross-tenant operation returns nothing or is refused, and same-tenant operations succeed.
- Repeat the same probes over an administrative connection and document exactly which operations bypass permissions. Operators need this list in writing so nobody mistakes admin behavior for client behavior.
- Inspect deployed definitions with
INFO FOR DBandINFO FOR TABLEto confirm permissions, field types, and indexes actually match the intended design — schema drift here is invisible otherwise. - On representative data volumes, check query plans or timings to confirm permission-filtered queries hit the tenant index rather than scanning.
- Re-run the whole suite after every server upgrade. Permission and access syntax is version-sensitive, and behavior changes have shipped across major releases.
Failure modes to plan for
- Missing PERMISSIONS defaults. A table declared without explicit permissions may be more permissive than you intend, depending on version and access level. Declare permissions on every table, even if the clause is a blanket deny.
- Silent false predicates. A typo in a predicate (wrong field name, wrong session variable) does not error; it returns empty results. Symptom: users report \"data disappeared.\" The probe suite above is the detection mechanism.
- SCHEMALESS drift. A schemaless table lets a record exist without the tenant field; a predicate comparing a missing field may not behave as you expect. Prefer SCHEMAFULL for tenant-owned tables.
- Unindexed predicates. Permission filters referencing unindexed fields degrade badly at scale. Measure; do not assume.
- Uncovered read paths. Do not assume table permissions govern every way data leaves the server. Live queries, multi-hop graph traversals, and server-side functions each need their own review against your version's documented behavior.
Conditions that would change the design
Move authorization out of the database (into an API layer or a dedicated policy service) when any of these become true:
- Cross-tenant workloads. Analytics, support tooling, or admin dashboards that legitimately span tenants force you to mint broader credentials, which erodes the boundary. Better to give those workloads a separate, clearly privileged path and keep record access for end users.
- Policy shared across services. If the same rules must apply to a search index, a queue consumer, or another database, expressing them in SurrealQL fragments guarantees drift. Centralize policy where all consumers can evaluate it.
- Complex identity. External identity providers with nested group hierarchies and attribute-based rules outgrow what per-row boolean predicates express readably.
- Evaluation cost. If profiling shows per-row predicate evaluation is a latency or throughput bottleneck at your scale, that is a real reason to filter earlier — but get the measurement first.
Until one of those triggers fires, the five-piece design above is hard to beat on operational simplicity: the rules live next to the data, are inspectable with INFO FOR TABLE, and are testable with two connections and a script.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.