PRODSovereign European BaaS platformOpen Dashboard →

Engineering · 10 min read

RLS vs a dedicated database per project

Affane Daylami · Fondateur · July 27, 2026

Back to blog

Search for “RLS multi-tenant postgres” and you come across the same schema almost everywhere: a shared database, a tenant_idcolumn, a policy that filters the rows. This is not the model that Aurabase uses to isolate its projects from each other. Each project receives its own Postgres 16 database, never shared with another client — the RLS remains there, but on another floor: in your database, for your own users.

This English text was generated automatically from the French original and has not been reviewed yet.

“RLS” and “multi-tenant” are found side by side in almost all of the content already published on the subject – a legitimate choice for many SaaS architectures, but which is not Aurabase’s choice to separate its own clients from each other. This post explains the difference with the real provisioning engine and the RLS policies actually applied, not a simplified marketing description. For an overview of what Aurabase's Managed Postgres engine covers beyond isolation, see the databasedocumentation.

The essentials

  • Between projects, Aurabase isolates by dedicated Postgres base, never by RLS alone — each project has its own physical base, on a dedicated CNPG cluster (company level) or on the CNPG cluster of its own organization (free/pro/team), never shared with another organization.
  • The RLS (auth.uid(), auth.role(), auth.jwt()) remains active and recommended inside your base, to isolate your own users — same convention as Supabase.
  • service_role and project-based administration roles bypass RLS by design (BYPASSRLS): an assumed architectural choice for server operations, not a flaw.
  • A regression already corrected in this repository - PUBLIC rights granted in error on old shared schemas - concretely illustrates why a border at the base level resists better than a purely application border.
#
The default model

The shortcut that most multi-tenant RLS guides take

The most documented pattern for multi-tenant Postgres consists of three lines: a single base, a tenant_id column on each table, an RLS policy which compares this column to a value extracted from the JWT. It's economical - a pool of connections, a diagram, a single instance to run - and it works well when the tenants are numerous, small, and of low individual stake.

The compromise is real: the boundary between two clients becomes a SQL expression, evaluated table by table. A policy forgotten on a new table, a connection running with a superuser role, a debug script launched live — each of these incidents, however trivial in operation, can silently expose the lines of all the tenants at the same time. The security boundary and the technical boundary (the basis) are then exactly the same thing.

Info

This isn't a bad choice in itself — it's the right compromise for many products. The point of this post is elsewhere: this is not the compromise that Aurabase has made to separate its own clients (entire projects, potentially with different compliance requirements) from each other.

#
In the code

Two architectures, never a base shared between projects

Since a recent consolidation of the provisioner (marked in the code as "Task 12"), an active Postgres project at Aurabase falls under exactly two architectures - the old models with a base actually shared between several projects have been removed from the provisioning path.

provisioning/instances.rsrust
pub enum ProjectInstanceKind {
    /// CNPG cluster dedicated to THIS ONLY project (company level).
    FullyDedicated,

    /// Database on the CNPG cluster of the project ORGANIZATION —
    /// shared with OTHER projects of the SAME organization,
    /// never with a third party organization.
    SharedClusterDedicated,
}

The project level decides which of the two applies — and it is the code that decides, not a box checked in a dashboard:

provisioning/rules.rsrust
fn should_provision_dedicated(engine: &str, db_url_encrypted: &Option<String>, plan: &str) -> bool {
    matches!(engine, "postgres" | "postgresql")
        && plan == "enterprise"
        && !non_empty(db_url_encrypted)
}

// should_route_to_fleet(): any non-company tier (free/pro/team)
// route to the CNPG cluster of YOUR OWN organization, never the one
// from another — see migration 073, consolidation to 2 architectures.
DimensionsFullyDedicated (company)SharedClusterDedicated (free/pro/team)
CNPG clusterDedicated to this one projectShared, but never between two organizations
Postgres databaseapp, only project on itproject_<uuid>, one per project on the cluster
PostgreSQL LoginA single project on the cluster: no risk of cross-membershipLogin per-project (F-013), member of its only roles tenant_<uuid>

On both architectures, the base or cluster never hosts two different organizations — so the question is not “is your data isolated” but “does your project have compute CloudNativePG all to itself, or does it share it with other projects in the same organization.”

This consolidation of two architectures is recent: the code previously carried two additional paths – a “shared master” where several projects coexisted in the same database, isolated only by diagram, and a postgrest_dedicated_shared_dbvariant. A dedicated migration removed them and tightened the constraint of the projects table to only the two remaining values, precisely because the shared schema model was the source of the bug described below.

#
Security boundary

Why a dedicated base beats an EPIRB shared between customers

A separate Postgres database is a connection-level boundary, not a row-level boundary. An application role connected to the database of project A simply cannot query the tables of project B — it does not have a session open to it. This property holds even if an RLS policy is poorly written, missing from a table, or bypassed by a high role: the worst case remains confined within a single database.

This deposit also bears the trace of a real bug which illustrates the opposite risk. Under the old shared schema model (since withdrawn), provision_postgres_schema mistakenly granted GRANT ALL ... TO PUBLIC rights to each project schema - PUBLIC applying to all roles in the database without membership conditions, a login isolated per project could read and write in the schema of another. A corrective migration (066) removed these rights from the existing one.

The lesson learned

The patch didn't add one more RLS policy to plug the leak — it removed the very possibility of two projects sharing a database. On the two current architectures, a comment from provisioning.rs documents it in black and white: “each project already has its own physical Postgres database”. A base-level boundary makes an entire class of such bugs simply unreachable, rather than relying on every policy always being written correctly.

A patch from August 23, 2026 goes in the same direction: an REVOKE ALL ON SCHEMA public placed unconditionally by the provisioner was made conditional on the topology, because it only provided real isolation on the old shared database model — on the two current architectures, it blocked without benefit the import of SQL dumps which explicitly reference public.<table>.

#
RLS in practice

EPIRB stays there — in your database, for your users

None of the above makes EPIRB useless — it just changes floors. Once in your project database, Aurabase exposes exactly the PostgREST convention taken up by Supabase: three SQL functions which read the JWT claims set by the gateway in request.jwt.claims.

libs/aura-migrations/golden/auth_global.sqlsql
create function auth.uid() returns uuid
    language sql stable security definer
    set search_path to 'pg_catalog', 'pg_temp'
    as $$
    select nullif(nullif(current_setting('request.jwt.claims', true), '')::json->>'sub', '')::uuid
$$;

These helpers are used in the actual policies of Aurabase itself — not just documented for yours. Here is the policy that protects storage_objects, as it is placed in the repository (reformatted on several lines for reading):

libs/aura-migrations/golden/storage.sqlsql
alter table storage_objects enable row level security;

create policy storage_objects_select on storage_objects
  for select using (
    (owner_id = auth.uid())
    or (
      exists (
        select 1 from storage_buckets b
        where (b.name = storage_objects.bucket_name and b.public)
      )
    )
  );

In your own project_<uuid>schema, the one that carries your application tables, Aurabase deliberately does not place any policies on your behalf — the code documents it as a “Supabase model”: the RLS of your tables remains your responsibility, with the same functions, the same syntax.

#
BYPASSRLS assumed

service_role bypasses RLS — by design, not accident

Postgres natively offers a role attribute, BYPASSRLS, which ignores all policies. Aurabase voluntarily uses it on two families of roles: aura_service_role (the server role, never exposed on the browser side) and the administration role specific to each project, used during DDL operations like ALTER SCHEMA ... OWNER TO.

provisioning/roles.sqlsql
create role aura_service_role nologin bypassrls;
-- Server role: never exposed on the browser side, never a member
-- roles from another project.

The role that serves your requests anon/authenticated — tenant_<uuid> — does not have any BYPASSRLS: the RLS applies to it normally, without exception. As a bonus, the system diagrams specific to each project (_auth, _storage, _platform) receive an activated RLS without policy — therefore a total refusal by default for any non-bypass role, a defense in depth in case an application path accesses it one day by mistake.

Info

Bypassing the RLS with an elevated server role is not unique to Aurabase — it is the same construct as service_role on the Supabase side. The point is not to avoid BYPASSRLS, it is to never grant it to a role accessible from a client, and to confine it to a single project.

This server role is part of a broader posture — predefined roles, custom RBAC, audit logs — detailed on the Security & RBACpage.

#
Defense in depth

On a shared cluster, the database does not do all the work alone

On the SharedClusterDedicatedlevel, several projects from the same organization coexist on a single CNPG cluster. The physical database already separates the projects from each other, but the PostgreSQL roles — they — are global objects to the cluster, not to the database. Aurabase therefore adds a layer: a separate PostgreSQL login per project.

libs/aura-core/src/lib.rsrust
pub fn project_authenticator_role(project_id: &str) -> String {
    format!("project_{}_authenticator", project_id.replace('-', '_'))
}

// Active by default (F013_PER_PROJECT_AUTHENTICATOR), deactivatable
// explicitly as an emergency escape.

Each project connects with its own login, member only of its own tenant_<uuid> / tenant_<uuid>_admin roles — never those of another project in the same cluster. The database already isolates the data; This login per project also isolates the identity that connects to it, so that an incident on a project does not give its login any membership to inherit to another.

#
For your architecture

RLS alone or dedicated base: how to decide for your own SaaS

The choice of Aurabase is not a universal rule — it is a compromise for a specific case: isolating clients from each other, potentially with different compliance requirements, on a platform that they do not control. If you are building your own SaaS, the same question arises for you, on a different scale.

  • RLS with tenant_id in a shared base — relevant when your tenants are numerous, individually of low stake, and the cost of a base per tenant would be disproportionate. Test each policy with pg_prove, on each table, without exception.
  • Dedicated base or diagram — relevant whenever a tenant has its own compliance issue (health, HR, public sector), a volume that justifies performance isolation, or the cost of a leak between two specific clients would be disproportionate to the cost of additional infrastructure.

The Aurabase pricing level applies this same arbitration to its own clients: shared base by organization by default, dedicated cluster when the challenge of the project justifies it. For RLS patterns inside your own database — ownership, multi-tenant by organization, role hierarchy — the RLS guide in production details the three cases with pgTAPtests. And if the auto-generated API on your diagram interests you beyond REST, the comparison on pg_graphql versus Hasura and PostGraphile covers the other half of the Postgres surface exposed by Aurabase.

#
Frequently Asked Questions

FAQs

Is RLS alone sufficient to isolate tenants in a single Postgres database?+
Technically yes, if each table carries a correct policy and no connection escapes the non-bypass role. This is exactly the operational risk that Aurabase chose not to take between different projects — each policy error would remain confined to a single base. Within a single project, for your own tenants, RLS + tenant_id remains a legitimate and widely used choice.
Why don't free projects have a dedicated Postgres cluster like the enterprise tier?+
A dedicated CloudNativePG cluster per project, including for free projects, would multiply the reserved compute without any relation to the actual use of the majority of them. Aurabase instead gives each non-enterprise project its own physical Postgres database on its own organization's CNPG cluster — never shared with another organization — rather than a schema in a common database among several clients unknown to each other.
How does auth.uid() know who I am, technically?+
The Aurabase gateway verifies your JWT then places its claims in the Postgres session parameter request.jwt.claims, for the duration of the transaction. auth.uid() does nothing more than extract the sub field from this JSON and cast it to uuid — without claims made (anonymous request, or direct connection outside a gateway), current_setting() returns NULL and auth.uid() therefore returns NULL, which closes access to a policy using (owner_id = auth.uid()).

READY TO DEPLOY?

Your backend in five minutes.

No credit card required · 500 MB free · 50,000 MAU