12. Row-Level Security (RLS)

Alex needs each law firm employee to only see their own cases.

Row-Level Security lets you control which rows each user can read, insert, update, or delete. Policies are written in SQL and enforced by PostgreSQL itself — no application code needed.

Auth helper functions

Eurobase provides built-in functions you can use in RLS policies to access the current user's identity. Both the native (no-dot) and Supabase-style (auth.*) forms are equivalent — pick whichever reads better. is_service_role() is true for service-key calls and lets you write a single policy that admits both end-users and trusted server-side code.

FunctionReturnsDescription
auth_uid() / auth.uid()uuidCurrent user's ID, NULL when no end-user context
auth_email() / auth.email()textCurrent user's email
auth_role() / auth.role()text'service_role', 'authenticated', or 'anon'
is_service_role()booleanTrue for calls made with the secret key. Policies must include it for the secret key to pass (the console presets do)
auth.jwt()jsonb{ sub, email, role } — for policies that read JWT claims

Heads up — do not redefine auth.uid() in your migrations. The Supabase boilerplate that reads request.jwt.claims won't work here — Eurobase uses a different session GUC. The built-in auth.uid() already does the right thing.

Common RLS patterns

Users can only read their own rows

CREATE POLICY "read own" ON todos FOR SELECT USING (user_id = auth_uid());

Users can insert with their own ID

CREATE POLICY "insert own" ON todos FOR INSERT WITH CHECK (user_id = auth_uid());

Users can update only their own rows

CREATE POLICY "update own" ON todos FOR UPDATE USING (user_id = auth_uid());

Public read, authenticated write

CREATE POLICY "public read" ON posts FOR SELECT USING (true);
CREATE POLICY "auth insert" ON posts FOR INSERT WITH CHECK (auth_role() = 'authenticated');

Admin access by email

CREATE POLICY "admin all" ON users FOR ALL USING (auth_email() = 'admin@company.eu');

Views

A view must apply the RLS policies of its tables to whoever reads it, so create views with security_invoker:

CREATE VIEW open_tasks WITH (security_invoker = true) AS SELECT * FROM tasks WHERE done = false;

Without it, a view reads its tables with its owner's rights and skips their policies, so the SQL editor, migrations and MCP refuse to create one (or to switch security_invoker off). Materialized views are refused too: they store their rows and can't apply RLS. For precomputed data, use a view or a table you refresh from a cron job. Repeat WITH (security_invoker = true) whenever you replace or re-create a view: CREATE OR REPLACE VIEW without it switches the option off. For the same reason, SECURITY DEFINER functions and rules are refused: both run with the owner's rights. Use plain functions with RLS policies, and triggers instead of rules. On the Team plan, SQL you run over a direct database connection isn't checked: create views with security_invoker there too.

Group and team access

A common need: each user sees their own rows, and a coach or manager sees everyone in the groups (teams, clubs, workspaces) they manage. On other platforms this is often solved with a SECURITY DEFINER helper such as is_member_of(team_id). Eurobase refuses those, and you don’t need one. If a policy on team_memberships reads team_memberships, Postgres reports infinite recursion. The fix is to keep the “who manages which group” rows in their own table, whose policy never reads the membership table:

-- Who coaches which team. Each user sees only their own rows.
create table team_coaches (
  team_id uuid not null references teams(id) on delete cascade,
  user_id uuid not null,
  primary key (team_id, user_id)
);
create index on team_coaches (user_id, team_id);
alter table team_coaches enable row level security;
create policy "read own coach rows" on team_coaches
  for select using (user_id = auth_uid());
-- Only server-side code (secret key, edge functions) assigns coaches.
create policy "service manages coaches" on team_coaches
  for all using (is_service_role()) with check (is_service_role());

-- Players see their own memberships; coaches see their teams' rosters.
create index on team_memberships (user_id);
alter table team_memberships enable row level security;
create policy "read memberships" on team_memberships
  for select using (
    is_service_role()
    or user_id = auth_uid()
    or team_id in (select team_id from team_coaches where user_id = auth_uid())
  );
create policy "service manages memberships" on team_memberships
  for all using (is_service_role()) with check (is_service_role());

-- Coaches add and remove players in their own teams only.
create policy "coaches add members" on team_memberships
  for insert with check (
    team_id in (select team_id from team_coaches where user_id = auth_uid())
  );
create policy "coaches remove members" on team_memberships
  for delete using (
    team_id in (select team_id from team_coaches where user_id = auth_uid())
  );

-- Profiles (and any per-player data): own row, or a player in a team I coach.
alter table app_profiles enable row level security;
create policy "read profiles" on app_profiles
  for select using (
    is_service_role()
    or user_id = auth_uid()
    or exists (
      select 1 from team_memberships m
      join team_coaches c on c.team_id = m.team_id
      where m.user_id = app_profiles.user_id
        and c.user_id = auth_uid()
    )
  );
  • No recursion: the policies only point one way (profiles → memberships → coaches), and team_coaches reads nothing else. This handles several coaches per team and players in several teams.
  • Players don’t see teammates. A policy on team_memberships that reads team_memberships (“rows of teams I’m in”) is exactly the recursion Postgres refuses. If players need a roster, serve it from an edge function that checks membership first.
  • Don’t let app users write team_coaches: only the service role can, so SDK and REST callers can’t make themselves a coach. Assign coaches from the console or from an edge function that checks the caller first. Edge functions and the secret key run under RLS through the is_service_role() branches, so keep those branches in every policy they need to pass.
  • Coaches adding members: the “coaches add members” policy only checks the team, so a coach can add any user and then read that user’s profile. If that’s too open, drop the policy and add members from an edge function instead (for example when a player accepts an invitation).
  • Private fields: a policy grants whole rows. If coaches shouldn’t see some profile fields, put those in a separate table that only the owner can read.
  • More levels: for clubs or organisations above teams, add a club_admins table with the same “read own rows” policy and reference it the same way. Index the columns your policies filter on.
  • Not a trigger-maintained copy: a trigger runs as the user who made the change, so its writes are checked against that user’s policies. It can’t keep a shared roster table up to date for everyone; read through policies like the ones above instead.

This works the same on every plan.

Full example: secure a tasks table

Follow these steps to create a table where each user can only see and manage their own rows.

Step 1: Create the table with a user_id column

CREATE TABLE tasks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id),
  title TEXT NOT NULL,
  completed BOOLEAN DEFAULT false,
  created_at TIMESTAMPTZ DEFAULT now()
);

Step 2: RLS is enabled automatically

Tables created via the Eurobase console or API have RLS enabled by default. You'll see a green RLS badge on protected tables. If a table shows "RLS OFF" in the sidebar, enable it with:

ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;

Step 3: Add policies for each operation

-- Users can read only their own tasks
CREATE POLICY "select own" ON tasks FOR SELECT
  USING (user_id = auth_uid());
-- Users can insert tasks with their own user_id
CREATE POLICY "insert own" ON tasks FOR INSERT
  WITH CHECK (user_id = auth_uid());
-- Users can update only their own tasks
CREATE POLICY "update own" ON tasks FOR UPDATE
  USING (user_id = auth_uid());
-- Users can delete only their own tasks
CREATE POLICY "delete own" ON tasks FOR DELETE
  USING (user_id = auth_uid());

Step 4: Test it from the SDK

// Sign in as a user
await eb.auth.signIn({ email: 'alice@example.com', password: '...' })
// Insert a task — user_id is automatically checked by RLS
await eb.db.from('tasks').insert({ user_id: session.user.id, title: 'Buy milk' })
// Query — only Alice's tasks are returned
const { data } = await eb.db.from('tasks').select('*')
// data = [{ title: "Buy milk", ... }] — Bob's tasks are invisible

RLS is on by default for tables created via the console. But without policies, no rows are visible. Add at least a SELECT policy so users can read data. Tables showing "RLS OFF" in the sidebar need to be secured with ALTER TABLE ... ENABLE ROW LEVEL SECURITY;

Supabase compatibility: Eurobase's auth_uid(), auth_role(), and auth_email() follow the same pattern as Supabase's GoTrue. RLS policies written for Supabase work in Eurobase with minimal changes.

Secret API key (eb_sk_) passes every policy that includes is_service_role(). The console presets add it; a policy you write yourself needs it too, or the secret key is refused like any other caller. Use it for server-side access only, never in client code.

Common gotcha: row-level security policy denied this operation on INSERT, even with WITH CHECK (true).

The SDK executes every insert as INSERT ... RETURNING * so it can return the created row to your client. On RETURNING, Postgres applies RLS twice: the INSERT policy's WITH CHECK when writing the row, then the SELECT policy's USING when reading it back. If you have an INSERT policy but no matching SELECT policy, the write rolls back with the same error text as a real INSERT failure — misleading, but that is the wire format.

Fix: add a SELECT policy that permits the authenticated user to read the row they just wrote — usually the same predicate as the INSERT policy. If your INSERT policy is WITH CHECK (user_id = auth_uid()), add FOR SELECT USING (user_id = auth_uid()). The Table Editor bypasses this because it runs through the elevated is_service_role() branch, which is why the same insert succeeds there and fails from the SDK.

Inspecting your schema, policies and functions

You can read your full schema without raw SQL, at two access levels. With viewer: the Database → Schema diagram (foreign keys drawn between tables), GET /platform/projects/<id>/schema (columns, foreign keys, primary/unique keys, indexes, and whether RLS is on), GET .../schema/rls-audit (which tables have RLS and how many policies), and GET .../schema/objects (views and whether they use security_invoker, materialized views, sequences and types). With developer: view definitions in that same listing, the full policy expressions (USING + WITH CHECK) at GET .../schema/tables/<table>/policies, and a function’s definition at GET .../schema/functions/<name> — your RPC functions, your trigger functions, and the platform helpers auth_uid(), auth_role() and auth_email() (returned with "platform": true; they’re managed by the platform and the same in every project — don’t change them from SQL, your policies depend on them) (or the MCP tools describeTable / getDbFunction / listSchemaObjects). Catalog queries in your own SQL (pg_policies, pg_get_expr, pg_get_functiondef) are refused by a tenant-isolation guard — these endpoints run that introspection for you. CHECK-constraint expressions aren’t returned by the JSON endpoints; read those from your migration files (or, on Team, a direct pg_dump --schema-only).

Two helpers your policies use live in the platform’s shared schema, not yours, so the function lookup doesn’t return them. They’re plain (SECURITY INVOKER) STABLE functions, managed by the platform and the same in every project (don’t redefine them), and read values the platform sets for each request:

public.is_service_role()     -- select current_setting('app.end_user_role', true) = 'service'
public.current_end_user_id() -- select nullif(current_setting('app.end_user_id', true), '')::uuid

GET .../schema lists tables, not views, and nothing lists which functions use a given table: search your function definitions and migration files. New and changed views must be security_invoker, so they apply RLS for whoever reads them.

The platform-managed tables in your schema (users, user_identities, refresh_tokens, email_tokens, storage_objects, storage_shared_prefixes, vault_secrets, user_passkey_credentials, webauthn_challenges) come with their own policies, which the platform depends on. You can read them, except refresh_tokens, email_tokens and vault_secrets, which hold credentials and are reached only by the platform (manage secrets on the Vault page or with the vault API). The console and the schema API refuse to change these tables: RLS presets, policies, RLS on/off, columns, constraints, indexes and triggers, and creating, dropping or renaming them, are all refused (403). You can still point your own foreign keys at them (REFERENCES users(id)). On shared projects, SQL that tries to alter, drop or add policies or triggers to them fails with must be owner; on Team, leave them unchanged in your own SQL as well. If one of them looks wrong in your schema, contact support.

Who can call your functions

Roles and grants are managed by the platform (GRANT / REVOKE are refused). Your app’s data requests — REST, SQL and RPC, with the public key (with or without a user token) or the secret key — run as a role that doesn’t own the tables you create, so the policies on every table with RLS enabled always apply; the secret key passes them through their is_service_role() branch. Owner rights, which skip RLS, are limited to the console and the MCP/CLI tools (SQL editor, table editor, schema changes), migrations, schema changes made with the secret key, and project exports. Any function in your schema can be called through POST /v1/db/rpc/<name> by anyone with your public key, because who may execute it can’t be restricted: a function meant only for your server must check public.is_service_role() itself (and raise otherwise), and one for signed-in users should check auth_uid().

Verifying a session’s identity

Your policies act on the end-user the request carries: app.end_user_id (the UUID, read by auth_uid() / current_end_user_id()) and app.end_user_role (read by auth_role() / is_service_role()), both set by the platform per request. To see what a given session resolves to, call GET /v1/auth/user with that user’s access token — it returns the user record and claims. How your app maps that UUID onward (to a profile row and its relationships) is your own schema design; read it with the introspection methods above.

Making a column immutable

RLS decides who may update which rows, but a policy only sees the new row, so it can’t stop one column from changing (there’s no old row to compare against). To freeze a column such as id or created_at while still allowing other updates over the normal API, add a BEFORE UPDATE trigger in a migration:

create or replace function enforce_clubs_immutable()
returns trigger
language plpgsql
as $$
begin
  if new.id is distinct from old.id
     or new.created_at is distinct from old.created_at then
    raise exception 'clubs.id and clubs.created_at are immutable';
  end if;
  return new;
end;
$$;

create trigger clubs_immutable
  before update on clubs
  for each row
  execute function enforce_clubs_immutable();

Keep the function plain (not SECURITY DEFINER, which self-serve surfaces refuse). It fires on every update — SDK, REST and SQL editor alike — so any attempt to change id or created_at is rejected while other columns update normally. Pair it with your RLS policy, which restricts which rows a given role may update.

Keeping a change history

A plain trigger can write an append-only history of corrections, with no SECURITY DEFINER and no extra grants. It runs inside the request that made the change, as the same caller, so auth_uid() is the user making the correction and its insert into the history table is checked against that table’s policies:

create table assessment_history (
  id            uuid primary key default gen_random_uuid(),
  assessment_id uuid not null references assessments(id),
  changed_by    uuid default auth_uid(),
  changed_at    timestamptz not null default now(),
  old_row       jsonb not null,
  new_row       jsonb not null
);
alter table assessment_history enable row level security;

-- Insert only in your own name; no UPDATE or DELETE policy,
-- so the app can never change or remove history rows.
create policy history_insert on assessment_history for insert
  with check (changed_by = auth_uid() or public.is_service_role());
-- add a SELECT policy for whoever may read the history

create or replace function record_assessment_change()
returns trigger
language plpgsql
as $$
begin
  insert into assessment_history (assessment_id, changed_by, old_row, new_row)
  values (old.id, auth_uid(), to_jsonb(old), to_jsonb(new));
  return new;
end;
$$;

create trigger assessments_history
  after update on assessments
  for each row when (old.* is distinct from new.*)
  execute function record_assessment_change();

auth_uid() is NULL without a signed-in user. With the policy above, an anonymous update is refused (its history row doesn’t pass the check), and changes made with the secret key are recorded with changed_by NULL unless you write the ID yourself. The SQL editor and migrations run as the table owner, where RLS doesn’t apply: to keep history rows unchangeable there too, add a BEFORE UPDATE OR DELETE trigger on the history table that raises an exception (removing it takes owner-level SQL in the editor or a migration, and both appear in your SQL log). Because history rows reference the assessment, an assessment with history can’t be deleted; mark it as withdrawn instead, or decide whether its history should go with it (on delete cascade). A signed-in user allowed to insert history rows can also add one directly, though only in their own name. If history must come only from the trigger, make the history table service-only and make corrections through your server.

Several writes in one transaction

Apps can’t open a transaction of their own, but every API request runs in one, so related writes are atomic when they happen in one request:

  • Triggers: an insert, update or delete through the API (SDK or REST) and every trigger it fires commit or roll back together. If the trigger fails, the write is undone.
  • RPC functions: POST /v1/db/rpc/<function> (SDK: eb.db.rpc('name', { ... })) runs the whole function in one transaction. If any statement fails or the function raises an exception, nothing is kept. RPC functions run with the caller’s rights, so your policies apply to every statement inside.

Use a trigger for what must happen on every change, such as the history above: it fires however the row is changed. Use an RPC function when one operation needs inputs or checks beyond a single row update; its writes still fire your triggers in the same transaction.

Every request runs at PostgreSQL’s default isolation level, READ COMMITTED: API and RPC requests as well as SQL from the console, the CLI and MCP. Locks are released when the request’s transaction ends, and a transaction can’t stay open across two requests. To test how concurrent transactions behave (lock waits, what a trigger sees after one), use two sessions on a local PostgreSQL 16, the same major version we run. See the PostgreSQL docs on Read Committed and row-level locks.