Skip to content
runlot

RLS and user identity

Turn on RLS and every env.db statement runs as whoever entered your code. Signed-in requests run as that user, so row level security policies with runlot_auth.uid() filter without any identity in your code.

The user table is yours

The user table lives in the runlot_auth schema of your project database. You can join against it like any other table.

select p.id, p.title, u.email
from posts p
join runlot_auth.users u on u.id = p.owner_id
order by p.id desc
limit 20;

Foreign keys from your migrations

A migration may reference runlot_auth.users directly, even on a fresh project whose app has never served a request:

-- migrations/0002_posts.sql
create table posts (
  id bigserial primary key,
  owner_id uuid not null references runlot_auth.users(id) on delete cascade,
  title text not null
);

When a pending migration mentions runlot_auth, runlot pg migrate and runlot deploy install the schema first, over the same connection, and print auth schema ensured. Login has to be on for the project; if it is off, the migration stops and says so.

Turn on RLS

RLS is off by default, and then env.db runs as the project owner everywhere. Turn it on in runlot.json, which runlot deploy applies:

{
  "auth": { "rls": true }
}

Or from the CLI, or with the RLS switch on the dashboard's App login tab:

runlot auth set rls on
runlot auth status   # shows RLS On

Who a statement runs as

With RLS on, the platform picks the database role from how your code was entered. Your code never names an identity, so it cannot claim to be someone else.

Entryenv.db runs as
fetch with a signed-in env.auth session (cookie or Bearer)that user (runlot_authenticated); runlot_auth.uid() is the user's id
fetch with no session, or an invalid oneanonymous (runlot_anon)
scheduled (cron)owner
queueowner
Durable Object alarmowner
email (inbound mail)anonymous (runlot_anon)
ctx.waitUntil and streamed bodiesthe role of the request that started them
Migrations, runlot pg shell, runlot pg executeowner

Closed until you open it

With RLS on, a table app users can reach is one you opened on purpose:

TableWhat a signed-in or anonymous user gets
No row level security42501 permission denied
Row level security, no policyno rows; writes are refused
Row level security and a policywhat the policy allows

After migrations, runlot pg migrate and runlot deploy sync the grants and name every table that is still closed:

  RLS grants synced: 1 table open to app users
  drafts has no row level security — closed to app users

runlot_auth itself is never open to app users. Read users through env.auth.

A migration with policies

-- migrations/0003_posts_rls.sql
create table posts (
  id bigserial primary key,
  owner_id uuid not null default runlot_auth.uid() references runlot_auth.users(id) on delete cascade,
  title text not null,
  created_at timestamptz not null default now()
);
alter table posts enable row level security;

-- Each user reads and writes only their own posts.
create policy own_posts on posts
  using (owner_id = runlot_auth.uid())
  with check (owner_id = runlot_auth.uid());

-- Signed-out visitors may read every post. Leave this out to keep posts private.
create policy public_read on posts for select to runlot_anon using (true);

default runlot_auth.uid() fills in the author, so an insert does not pass it.

export default {
  async fetch(request, env) {
    if (request.method === "POST") {
      const { title } = await request.json();
      await env.db.exec("insert into posts (title) values ($1)", [title]);
    }
    return Response.json(await env.db.exec("select id, title from posts order by id desc"));
  },
  async scheduled(event, env) {
    // Runs as owner: sees every row.
    await env.db.exec("delete from posts where created_at < now() - interval '1 year'");
  },
};

No user id appears in the code. The policy filters every statement from fetch.

The function is defined as:

create or replace function runlot_auth.uid() returns uuid language sql stable as $$
  select (nullif(current_setting('request.jwt.claims', true), '')::json->>'sub')::uuid
$$;

request.jwt.claims is the same setting PostgREST and Supabase use, so policies written for them work unchanged.

On this page