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 OnWho 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.
| Entry | env.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 one | anonymous (runlot_anon) |
scheduled (cron) | owner |
queue | owner |
| Durable Object alarm | owner |
email (inbound mail) | anonymous (runlot_anon) |
ctx.waitUntil and streamed bodies | the role of the request that started them |
Migrations, runlot pg shell, runlot pg execute | owner |
Closed until you open it
With RLS on, a table app users can reach is one you opened on purpose:
| Table | What a signed-in or anonymous user gets |
|---|---|
| No row level security | 42501 permission denied |
| Row level security, no policy | no rows; writes are refused |
| Row level security and a policy | what 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 usersrunlot_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.