Mike Cann's avatar
Mike Cann
2 months ago

Row-Level Security is a Ticking Timebomb

Row-level security is something I've touched on before, but it's worth going through in detail now. I'm seeing more and more code get written by AI instead of humans. However you're building, Firebase, Supabase, or Convex, you eventually have to control how your data gets accessed and handed to your users. You don't want user A reading user B's data. That mistake is easy to make if you're not carefully managing your row level security rules. The guardrails that come out of the box with a platform matter more than ever when an AI is the one writing the code.

If you don't know what row level security is yet, don't worry. Grab a cup of tea and let's get into it.

Who Can Read WhatWho Can Read What

Firebase Security Rules Explained

Firebase and Supabase both encourage a pattern where the client talks to the database directly. That pattern is what makes row level security necessary in the first place. I'm not dunking on either platform here. They're great for plenty of reasons. But the model where the client queries the database directly and you bolt security rules on top to compensate is a risky thing to do in the age of AI.

Here's a simple example. Say I've got a to-do app. User A has their own set of to-dos, user B has theirs, and I don't want either one seeing the other's list.

With Firebase, a React component to load someone's to-dos might look roughly like this:

1const q = query(
2  collection(db, "todos"),
3  where("userId", "==", user.uid),
4);
5
6onSnapshot(q, (snapshot) => {
7  setTodos(snapshot.docs.map((doc) => doc.data()));
8});
9

The important part is the where userId == user.uid filter, because that filter is passed from the client to the server, and the client can't be trusted not to do something malicious with it. User A can open dev tools, change that filter to a known ID belonging to user B, and read user B's to-dos.

Firebase's answer to this is a set of checks that run before the database returns or writes any data. For a to-dos collection, a rule might say that a to-do has to belong to the requesting user before you can read or write it. You'd write the same idea again for create, update, and delete. This is a real improvement. It moves the authorization check to the server, where the client can't tamper with it and Firebase enforces it. But notice two things about this rules language. It's written in its own hybrid syntax rather than TypeScript, and it lives in a completely separate place from the application code it's protecting. I'll come back to why that separation matters.

Supabase and Postgres Row-Level Security

Supabase's version of the same idea is conceptually identical, just implemented in Postgres instead of Firebase's own rules engine (Supabase runs on top of Postgres). You'd create the to-dos table and enable row level security on it. Then you'd write a policy that only allows access to rows where the user ID matches the authenticated user.

On the front end, the component loads the current to-dos and subscribes to changes on the table, refreshing whenever something changes.

1create table if not exists public.todos (
2  id uuid primary key default gen_random_uuid(),
3  user_id uuid not null references auth.users(id) on delete cascade,
4  text text not null,
5  completed boolean not null default false,
6  created_at timestamptz not null default now()
7);
8
9alter table public.todos enable row level security;
10
11create policy "Users can view their own todos"
12on public.todos
13for select
14to authenticated
15using (auth.uid() = user_id);
16

The client-side query in Supabase doesn't filter by user ID at all, because it doesn't need to. The RLS policy on the server won't send back any row that doesn't belong to the requesting user. The authorization check happens entirely on the server, not the client. You'd write similar policies for updates and deletes.

This isn't bad. But exactly as with Firebase, the policy logic lives apart from the application logic. You have to get both right, and remember to update both when either one changes. For a simple to-do app, expressing the authorization rules as RLS policies is manageable. As the app grows and adds tables, rules, and role-based access, expressing all of that as a stack of RLS policies gets harder and harder.

The Risks of Client-Side Database Access

Public Rule MistakePublic Rule Mistake

So we have our checks running on the server now. That sounds like it should be enough, but it isn't. We've seen, repeatedly, through real security incidents, that this model is error-prone and easy to get wrong. You can misconfigure Firebase security rules in a way that bypasses all of them. Firebase's own docs strongly recommend against that. But it's exactly the kind of thing that slips in when you're building fast, tired, or an AI agent generated the rule and nobody looked closely at it. Supabase has the same problem from a different angle: edge cases, views that bypass RLS entirely on older Postgres versions, and subtleties around real-time subscriptions and row deletion that the Supabase docs go into.

None of this makes Supabase or Firebase bad products. It means this style of authorization asks you to hold more state in your head, and in your context window, while you're building.

Moving Queries to the Server

The root problem is that we're exposing the database to the client, then patching that exposure with row-level rules. The alternative is to just not expose the database to the client. This used to be the standard approach: the client calls a server-side function, a Lambda or a Cloudflare Worker. That function calls the database and hands back the result. In this model, row-level security stops being necessary, because the authorization check can live inside the function itself: is this user logged in as user A? If not, they don't get user B's to-dos.

The catch is that moving query logic off the client this way costs you the real-time updating that Firebase and Supabase give you for free. You could rebuild that yourself with websockets, notifications, and cache invalidation, but I've done that before and it's a nightmare. I wouldn't go there unless you genuinely need to.

The Convex Approach to Real-Time Data

So is there a way to get real-time updates on the client and clean application code, without needing row-level security to hold it together? This is where Convex's model is different. The only way to touch the database is on the server, through queries and mutations. Those are themselves serverless functions, comparable to a Lambda or a Cloudflare Worker.

A Convex query pulls the caller's identity off the request, reads out the user ID, and uses that to scope the database call. On the client, you call that query with useQuery. The important part is that the call is live and reactive. Whenever the underlying data changes, or the query's inputs change, the component re-renders with the new result automatically. If I go into the Convex dashboard and change a value in the to-dos table directly, the front end updates instantly, with no manual subscription plumbing. Convex's React client handles that over a single WebSocket connection, which also keeps every useQuery call on the page consistent with the same database state.

1import { MutationCtx, mutation, query } from "./_generated/server";
2import { ConvexError, v } from "convex/values";
3import { requireUserId } from "./auth.js";
4import { Id } from "./_generated/dataModel";
5
6export const list = query({
7  args: {},
8  handler: async (ctx) => {
9    const identity = await ctx.auth.getUserIdentity();
10    if (identity === null) throw new ConvexError("Not authenticated");
11
12    const [userId] = identity.subject.split("|");
13    if (!userId) throw new ConvexError("Invalid user ID");
14
15    return await ctx.db
16      .query("todos")
17      .withIndex("by_user", (q) => q.eq("userId", userId as Id<"users">))
18      .order("desc")
19      .take(100);
20  },
21});
22

From a security standpoint, this closes the hole by construction. The client can't reach the database directly, so there's no row-level filter to misconfigure. User A can't read user B's to-dos, because every read has to go through a server function that authenticates the caller first.

Co-Locating Authorization and Application Logic

What I like about this model is that application logic and authorization logic end up in the same language, in the same place. The piece that pulls the user's identity off the request can be wrapped into a single function and reused everywhere a query or mutation needs it. As the app grows, more complex rules can live in the same style of TypeScript function. For example, something like a requireOwnTodo helper. Before you toggle or remove a to-do, it checks that the to-do with the given ID belongs to the calling user.

That's not magic. You still have to write the check. But writing it as a plain TypeScript function that sits next to the rest of your application code is a lot nicer than a separate Firebase rules file or a stack of Postgres policies. It's easier to express, test, and refactor.

Convex has written about this pattern in more depth in Authorization In Practice. That post covers how to push authorization all the way down to the endpoint boundary, rather than scattering it across middleware layers. If you want the general technique for wrapping query and mutation to inject reusable logic like this, that's covered in Customizing serverless functions without middleware.

Simulating RLS Patterns in Convex

If you want something that looks like row-level security in Convex, you can build that too. Convex published a post on this row-level security pattern a few years back. You wrap the database with helper functions, so every query or mutation intercepts each row and checks whether the current user is allowed to touch it. Instead of the normal query and mutation builders, you use something like queryWithRLS and mutationWithRLS.

This is genuinely useful if you have a large app and want one centralized policy layer that every read from a given table has to pass through. But it doesn't change the underlying model. Access still runs entirely through server-side JavaScript inside Convex functions, with the database hidden from the client exactly as before. What changes is where the authorization check lives: instead of inline in each function, it's centralized in one wrapper that every function passes through.

I personally wouldn't reach for it by default. If I noticed my authorization logic getting scattered around the app, I'd pull it into standalone functions in a shared helper file rather than wrapping the whole database. That's easier to follow when you're reading through the application code later, and easier for an AI coding tool to find and reuse correctly.

Architectural Trade-Offs and Summary

Firebase security rules and Supabase RLS policies aren't inherently bad. They exist for a reason: if you're going to expose your database to the client, you need some mechanism to keep user A out of user B's data. Both platforms give you a legitimate way to do that. But it's an architecture that requires keeping a set of rules or policies in sync with your application logic, and testing that sync as the app grows.

Convex takes a different approach. Because all data access has to go through a server function, authorization logic and application logic can live in the same place, in the same language. Firebase and Supabase both have server-side functions too, so you could adopt a similar pattern on either platform. But you'd give up some of what Convex gives you for free along the way, including real-time reactivity and automatic transactions on every mutation.

For me, the win is security guardrails that push you toward the correct pattern by default, instead of making you remember to add them. That buys a lot more confidence when I'm live-coding with AI.9

Build in minutes, scale forever.

Convex is the backend platform with everything you need to build your full-stack AI project. Cloud functions, a database, file storage, scheduling, workflow, vector search, and realtime updates fit together seamlessly.

Get started