Rowle security is a topic that I have briefly touched on in the past, but it's something that I think is worth talking about in detail as I'm starting to see more and more code being written by AI and not humans these days. How to securely access your data is something just about every app needs to carefully consider. It doesn't matter if you're using Firebase, Superbase, or Convex, you inevitably want to control how your data is accessed and distributed to your users. You don't want user A accessing user B's data, for example. But a mistake like that is all too easy to make if you're not very carefully managing your rowle security rules. So I think having good security guard rails that come out of the box that basically force you into the pit of success when using AI should not be underestimated. But anyways, if you are still with me at this point and don't know what I'm talking about, don't stress. Grab yourself a lovely cup of tea. Drop me a like and sub and we're going to dive into the details. [snorts] All right. So, I guess firstly it's going to be important to discuss what rowle security rules are first and then we can kind of talk about how it differs and convex. And just to be super clear up front here, [music] um I'm going to be discussing Firebase and Superbase here and how it compares to Convex, but it's not me intentionally dunking on those platforms. They're great for other reasons. I just think that the model that they employ in some of the parts of their feature where they encourage the database access from the client and thus necessitates rowle security is a fundamentally risky thing to do particularly in the age of AI. But anyway, with that disclaimer out of the way, let's go take a look at an example. So let's say we've got this to-do application here. Uh user A has got their set of to-dos and user B has got their own set of to-dos. Pretty basic, pretty easy to understand. Now let's have a look at the code. So with Firebase, we might have a React component that looks something like this. So here we're going to query for to-dos by the currently logged in user. And then we're going to use the on snapshot function to listen for changes and update the components local state. But the important thing to note here with this code is this where user ID equals user uid filter. This is passed from the client to the server. And this is not great because we really can't trust the client not to do malicious things. For example, user A could just come into the JavaScript that's running in the browser, change this to, you know, a known user ID for user B, and then they can just read the user B's to-dos. So, I guess naturally the question is, how do we prevent user A from accessing user B's to-dos? Well, in Firebase, this is handled with something called Firebase security rules. So these rules are a series of checks that are run before we return any data or write any data to the Firebase database. So for our to-dos collection, we can add something like this. So here we're basically saying is that if you try and access the to-do with this ID, you're going to have to first make sure that it actually belongs to this user. And then the same kind of idea of applying rules applies to the the create, update, and deletes via those allow rules. And this is good because now it means that we can perform these critical security, you know, authorization checks on the server rather than trusting the client and have these rules enforced by Firebase. But you will just noticed here that these rules are in some language that I don't know what it is. It's certainly not TypeScript. It's some sort of hybrid of JavaScript and something else which is also distinctly different and kept in a separate place from our actual application code. But let's just put a pin in that. We'll return back to that thought in a minute. Next, let's quickly just take a look at how Superbase does this because it is conceptually the same sort of thing. So basically instead of Firebase security rules, you would actually write Postgress rowle security policies because obviously Superbase runs on top of Postgress. So if we had a to-dos table, we might have a policy that looks something like this. So you see at the top here we are creating the table and then down here we're enabling the RLS and then we're we're writing this policy here to allow access to our own to-dos only if it's equal to the user ID. Then on the front end we might have something like this in Superbase. So the idea is kind of the same as Firebase. The component loads the loads the current to-dos and subscribes to changes on the to-dos table which then refreshes whenever something changes. And notice that we aren't actually filtering by user ID anywhere here on the client. Um, we don't actually need to do that because the RLS policy is going to prevent sending any data down to the client that doesn't match um, you know, your user ID. Again, this is nice because it means that our checks, our uh, authorization checks are being performed on the server, not on the client. And again with Superbase, we can do the same sort of thing we did with Firebase where we add this policies for updating and deleting to-dos as well. And again, this is not bad per se, but you will just notice that as with Firebase, we our policy logic is now separate from our application logic. And that means you need to get both parts right. You need to get your application logic right and your policies right. And you need to remember to update both if you change one of them or the other. And also although this is a simple to-dos app and we're able to express our authorization rules and RLS policies here, um I would argue that as your application grows and you add more tables and you add more rules, role based access and blah blah blah blah blah, I would say that it's going to get harder and harder to be able to express your authorization logic like this. All right, Mike. So we have our checks on the server now. This seems good. Why don't you like it? Well, we've seen time and time again through security incidents that this model is errorprone and very easy to make mistakes with for example you can just put this into your sec your Firebase security rules which would basically just totally bypass all of the rules and you might just be rolling your eyes at that. And Firebase's docs do very strongly recommend you don't do this, but this kind of practice can very easily slip in when you're building quickly or you're tired or your agent squirted it out and you just didn't look at it too closely. And Firebase is not alone because with superbase it is also very easy to make mistakes and you know there's like edge cases such as like views used to be able to bypass RLS entirely um on older versions of Postgress or there are other like subtleties in in superbase you know around the real time Postgress subscriptions and like deleting uh rows and things like that. I won't go into the details here but they are discussed in the superbase docs if you want to read about them. Again, none of this means that Superbase or Firebase are bad. It just means that this way of doing authorization. It just means you have to keep more things in your head and keep it in context while you're developing. All right. So, if the issue is that we need role level security because we're exposing our database on the client, what if we don't expose our database to the client? And this is what we actually used to do, right? We used to have our client call serverside functions you know lambdas or cloudflare workers or whatever and then those would then call into the database and then return the data which would then return back to the client. And in this model we no long we no longer need rowle security because we can just do those checks inside of the serverless function ourselves. For example we can say hey are you logged in as user A? Therefore you can't read those to-dos from user B. All right. So now we don't have any row level security issues. What's the catch? Well, now because we've moved our querying logic from the client to the server, we've unfortunately lost that nice real time updating characteristics that you get with Firebase and Superbase. I mean, we could potentially add that stuff back in again. You know, we could add websockets into the mix and notifications and cache invalidation and all that stuff, but trust me, we are entering a world of complexity and pain here. I've been there. It's a nightmare. Don't try and do this. So then I guess the question naturally becomes, is there way we can have the real-time data updates on the client and still keep our application code clean and not have to resort to rowle security? Well, you can probably guess where I'm going with this one. In convex, the only way to access your database is on the server via queries and mutations which are serverless functions much like AWS Lambda or Cloudflare workers. So in this example query here, we can see that we're going to grab the user's identity from the request. Then we're going to pull out the user's ID from that and then we're just going to call into the database using that user ID. Then on the client, we can use that query like so. And okay, yeah, this looks kind of similar from before and you might not be very impressed, but the key thing to realize here is that this use query call here is live. It's reactive. So basically, whenever any of the data in this query range here changes or any of the inputs change, this list of to-dos on the front end is also going to change and the component is going to rerender. And just to show you what I mean, you've probably seen me do this demo before, but if I pop open the Conx dashboard and I change one of these values in the to-dos table here, you will see that on the front end, we're going to it's going to instant in instantaneously update our app. And from a security perspective, we're kind of all good here because we're limiting our access to our database on the server via the query. The client can't directly access the database and thus we don't need any real level security. and user A is prevented from reading user B's to-dos because we're authenticated first. And the super nice thing about having your application logic and your authorization rules in the same language and all kept together is that we can wrap up this whole get user identity part into a function which we can then reuse throughout our application. So this is nice because it means that as our project grows, we can encapsulate more and more complex rules and whatnot in these Typescript functions. So for example, we can create a require own to-do function for when we want to create a to-do. And this is going to make sure that the to-do with the given ID belongs to the user before we toggle it or remove it. Again, this is not magic. You still have to write these checks. But having those checks in Typescript alongside your application code is much nicer and I personally find it much easier to express the rules, test, refactor, you know, all the extra stuff as opposed to, you know, the Firebase or the the rowle security rules from Superbase. Now, just to round this out, if you really really really want to do something that looks like rowle security rules on convex, well, you can also do that if you like. There's this old post uh from the convex stack from about three years ago that kind of shows this pattern. Basically the idea is that you can wrap the convex database with helper functions. So then on every query or mutation you kind of intercept the database and check to see whether the current user is able to access this row of the database or not. And then when you come to write your convex queries, you use this query with RLS or mutation with RLS mutations instead of the normal query or mutation. And I guess this could be useful if you got a big app and you really want a centralized all reads from this table must pass through this policy layer. But it's important to point out this doesn't fundamentally change how com the comics model. The database is still not directly exposed on the client. This is all serverside TypeScript. We're just movering the authorization check from the inline code, you know, inside of each of the queries and functions to be a more central place. Now, I personally wouldn't do it this way. I think if I felt like my authorization logic was getting spread out and split around the application too much, I would probably just start taking that authorization logic like I showed before and and turning it into just um standalone functions that are put inside a helper file or something. I think this is probably easier to understand and easier to follow as you look through your application code. So [snorts] the main thing I want you to take away from this video is that Firebase security rules and superbase RLS policies are not necessarily bad. They exist because if you are going to expose your database on the client, you need a way to be able to securely access that data. That is, you need to prevent user A from accessing user B's data. And it is a valid architecture but it does mean that you have to keep a set of security rules or policies in sync with your application logic and you also have to keep them tested uh as your application grows and comics takes a different approach by forcing you to access all of your data on the server via functions. You can colllocate your authorization logic with your application logic. Now Superbase and Firebase do have serverside functions as well. So you can do this but you would lose some of the benefits that you get with convex such as the real-time updates and automatic transactions. And for me to give me reassurance having good secure guardrails that encourages the piss of success in security gives me much more confidence when I'm vibe coding with AI. All right. All right. Well, I think we're just about finished for today, but if you want to find out more about transactions in comics and and how they play into all this, then you should definitely check out this video I did some time ago. But until next time, thanks for watching. Cheerio.
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 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:
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.
1createtableifnotexistspublic.todos (2 id uuid primarykeydefault gen_random_uuid(),3 user_id uuid notnullreferences auth.users(id)ondeletecascade,4texttextnotnull,5 completed booleannotnulldefaultfalse,6 created_at timestamptz notnulldefaultnow()7);89altertablepublic.todos enablerowlevel security;1011create policy "Users can view their own todos"12onpublic.todos
13forselect14to 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 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";56exportconst list =query({7 args:{},8handler:async(ctx)=>{9const identity =await ctx.auth.getUserIdentity();10if(identity ===null)thrownewConvexError("Not authenticated");1112const[userId]= identity.subject.split("|");13if(!userId)thrownewConvexError("Invalid user ID");1415returnawait 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
All gas, no breakages
Convex is the reactive backend platform that keeps up with you and your agents. Database, functions, workflow, sync, search, file storage, and more. All TypeScript, zero glue.