02Databases5 min

Why I removed RLS from Postgres both times I used it

Row Level Security looks like the correct default. With Drizzle, server actions and my own auth, the permission logic already lives somewhere else, and keeping both hurts.

By Salva Castells

Cover image: Paulo Humberto, CC BY-SA 3.0.
In this post

My profiles schema had a policy like this, written with Drizzle's helper for Neon:

crudPolicy({
  role: authenticatedRole,
  read: authUid(table.userId),
  modify: false,
})

Three lines. They say an authenticated user only sees their own rows and can't modify them. Hard to argue with that. A year and a half later I deleted those three lines, and the ones on the feedback table, in the same commit. Months later I repeated the deletion in another project that had inherited the same schema.

It wasn't a whim. It was that the policy and the code said the same thing, and when two places say the same thing one of them ends up lying.

The promise of RLS and why I bought it

The promise is good: the permission lives in the database, so it doesn't matter who queries. If tomorrow I write a maintenance script, or open a new endpoint, or someone forgets a where, the row doesn't come out. That's real defense in depth, not decoration.

I built everything around it. Two connections:

export const getRlsDb = async (user: StackUser): Promise<DrizzleDb> => {
  const authToken = (await user?.getAuthJson())?.accessToken;
  const url = process.env.NEXT_PUBLIC_DATABASE_AUTHENTICATED_URL;
  const sql = neon(url, { authToken });
  return drizzle(sql);
};

An "authenticated" connection carrying the user's JWT that respects the policies, and an admin one with the normal DATABASE_URL that skips them. I even got as far as caching a map of RLS connections keyed by user id, until I realized I was storing connections tied to a token that expires, and went back to creating a new one each time.

Where it started to grate with Drizzle and server actions

The problem isn't that RLS fails. It's that in an app with Next and server actions it's almost never the layer that decides.

Every server action already starts by resolving the session. You have the user before you touch the database. And then you write this:

const db = await getRlsDb(user);
return await db.select().from(feedback).where(eq(feedback.userId, user.id));

That where is redundant with the policy. But you can't remove it, because if you do the code is lying: it looks like it fetches all the feedback and depends on an invisible policy living in another file. So you end up with the filter in the code and in the database, and no idea which of the two is actually doing the work.

The second thing is the day-to-day friction:

  • Two connections. Every new function starts with the question of whether it goes through RLS or through admin. And anything administrative, payment webhooks, crons or migrations goes through admin. That is: through the path that checks nothing.
  • The errors explain nothing. When the policy blocks you it doesn't say "you're missing the claim"; it returns zero rows. Debugging a select that returns an empty array when the row exists costs more than it sounds like.
  • The token travels where you'd rather it didn't. The authenticated URL ends up in a variable with the NEXT_PUBLIC_ prefix, and that alone makes you think twice about what you're exposing.
  • Policies live in the snapshot. Changing a policy means generating a migration and applying it. Changing a check in code means changing one line.

In fact, at one point I skipped Drizzle entirely and moved feedback and profiles to talk through PostgREST, precisely so I could really lean on RLS. That's where the price became obvious: I lost typing, I had to write column names by hand in snake_case (user_id, created_at) and every call ended with the same ritual.

const { data, error } = await pg.from('feedback').select('*').eq('user_id', user.id);
if (error) throw new Error(error.message);
return data as Feedback[];

An as Feedback[] at the end. That is, trusting the database returns what I claim. That went back to Drizzle two weeks later.

The same ending in the second project

When I started the next project from the same boilerplate, the policies came with it. They lasted about as long as it took me to touch the feedback table seriously. The commit where I removed them did another thing at the same time: rename feedback.description to feedback.content. It's a silly detail but it tells the whole story: I was shaping the data model, and the policies were what got in the way of moving it. In that commit the drizzle-orm/neon import and the four crudPolicy calls disappeared. The tables kept the only thing that actually gets used every day:

(table) => [index("idx_feedback_user_id").on(table.userId)]

An index on user_id. That one Postgres does consult on every request.

Where my permission logic lives today

In the middleware and in the server action layer. When I dropped managed auth and wrote my own session middleware, the place where who-sees-what gets decided collapsed into a single point: resolve the session, check the role, and from there every query is written with the userId already in hand. One connection, the admin one, and no parallel privileged path.

It's less secure on paper. A query that forgets its where returns someone else's rows. I offset that with the fact that those queries all live in an actions directory, not scattered around the app, and that the function signature forces you to pass the user.

When I would use RLS again

When the database has more than one client. If, besides my application, there's a public PostgREST, a client connecting directly, or analysts with read-only access, the policy is the only place where the rule holds for everyone. There the cost of maintaining two truths pays for itself.

Also in hard multi-tenant, where the failure isn't "you see someone else's feedback" but "you see another company's data". There I want the net underneath even if the code already filters.

For a single-client application, with server actions and the user resolved before the first query, RLS is a second copy of a rule you already wrote. And a rule written twice isn't twice as safe: it's twice as easy to leave out of date.