Building multi-tenant applications requires developers to ensure correct queries at every step along the way. Typically, we’d fall back on using WHERE clauses and pass along necessary constraints. Having to redo the same pattern over and over is error-prone, and even a single missed check can leak data to other users.
Multi-Tenancy and Access Control
When building SaaS or any multi-tenant application, you always face the same nightmare: ensuring the right tenant only sees their own data. Usually, the solution is sprinkling WHERE customer_id = ? all over your queries like parmesan on pasta. Forget it once? Congrats, you just leaked another customer’s invoices.
This approach is brittle, repetitive, and frankly beneath us as professionals. PostgreSQL has a better way: Row-Level Security (RLS). With RLS, Postgres itself enforces those row filters. Think of it as having a paranoid DBA strapped to your queries, yelling: “No, Alice, you’re not allowed to peek at Bob’s data!”
However, only few developers are aware of row-level security (or RLS for short). A big mistake!

What is Row-Level Security?
Row-Level Security (RLS) is Postgres’ built-in mechanism for restricting which rows a given role can SELECT, INSERT, UPDATE, or DELETE.
- Regular table grants decide what commands you can run (GRANT SELECT).
- RLS policies decide which rows you can run them on.
- CLS (Column-Level Security, yes, it’s a thing) policies decide which columns you can see.
If you enable RLS on a table without defining any policies, nobody except superusers will be able to access the table. They are deny-by-default. Nobody sees anything. Not even Gandalf. Be careful!
Policies are written as SQL expressions:
- USING defines which rows are visible for SELECT, UPDATE, DELETE.
- WITH CHECK defines which rows may be written on INSERT or UPDATE.
Multiple policies can be combined. More on this later.
Invoicing Database Tables
Let’s start easy for now and build the application. By the way, the whole source code is available on GitHub.
For demonstration, we’ll build a small service. To keep it simple, I’ll be focusing on the invoicing part of an online store. By definition, an online shop must be multi-tenant if we assume a customer to be a tenant. I know, I know. Not a perfect association, but bear with me.
Our world’s tiniest invoicing service will need three basic tables:
- Customers (our tenants)
- Invoices (belonging to customers)
- Invoice positions (invoice line items)
Customers
That said, let’s quickly define a few basic database tables. First of all, the most important one. We need our “tenant,” the customer. The customer table will be most important for our row-level security approach.
Invoices
Now that we have our “tenants,” we need the actual tables for invoicing. Again, for ease of understanding, we go with one of the most simplistic implementations possible. We will only create two tables, one for the invoices themselves and another for the positions on the invoice.
As we can see, invoices and invoice_positions reference customers so that we can enforce tenant boundaries through policies.
Invoicing Service Architecture
For building the service itself, I’ll go with Go(lang), pgx for the Postgres connection (due to its enhanced support for additional data types, such as UUID), and goyave for the simplified implementation of the REST API URL mappings.
The service itself will be designed with a single set of URLs for customers, account managers, and administrators. While, for a real-world implementation, it would make sense to add some additional layer of access control, for the sake of the demo, we’ll only handle permissions in the database using row-level access control.
This may be controversial but elegant: fewer moving parts, fewer bugs.
I’ll keep it brief here. As mentioned, you’ll find the complete source code on GitHub.
Router Mapping
As mentioned before, we’ll only be using one set of URLs for all users. So far, we’ve only defined customers, but others will be added as we go.
Implementing Tenant-Awareness via Middleware
Middleware in Go HTTP frameworks typically handles aspects shared across multiple URL mappings. That sounds like the perfect solution for our tenant-awareness implementation. So, let’s see how we can use it.
What we can see, every request immediately starts a database transaction and stores it within the request context.
Afterwards, some “magic” happens in applyRowLevelSecurity (that we’ll look at in a few seconds), then runs the actual request handler (next), before coming back. Eventually, depending on the status code, we’ll either commit or rollback the transaction. Fully automatically.
Applying Row-Level Security
Now we need to get the real magic implemented. For this, we use two levels of access permissions. Here, we already have the code for customers, account managers, and administrators. We’ll be adding other types of users as we go. In this case we already prepare those two additional user roles. Our code can stay the same, and all later changes are happening in the database only.
The above code looks at a potentially existing header X-User-Role to determine if the user is an administrator or account manager. If not available, it must be a customer.
As mentioned before, this should be handled using an actual authorization system and a JWT token.
If the user is a customer, we apply a second level of access control with the following code.
This code fragment looks at a second header X-Customer-ID to find the customer id of the requester. Again, please use JWT tokens in the real world.
RLS Transaction Setup
Both access control levels set specific elements as part of the transaction setup. In applyRowLevelSecurity we set the user’s role, which is either a customer, an account manager, or an administrator. For account managers, we actually use their user role directly (after checking it).
In applyTenantPermission we set the customer id into the transaction if the user is a customer.
We’ll see the details on how this will work in a few moments. However, there is one more thing to look at first.
Retrieving Customers
When retrieving a customer or a customer’s invoices, we don’t have to remember to add where clauses to the query. We can leave the query as simple as it gets.
For simplicity of the blog post, here is one example of how the customer list is retrieved. All other implementations will look similar, not caring one bit about which customers are visible or not. It’ll all be handled by the database itself.
As we can see, we retrieve the existing transaction from the request context and run the query. All the rest is basic goyave and pgx stuff to read the database data and return the result to the user.
Get Some Test Data
Before we go to the actual RLS implementation, let’s quickly add some demo data. In this case, we’ll create two companies you might have heard about before, and one invoice. See for yourself.
Magic Tenant-Awareness (Row-Level Security)
Alright, looks like we’re ready to do the crazy stuff.
Enabling Row-Level Security
To make row-level security a thing, we need a few steps. We’ve seen above that we’ll have three user roles that need to be created. So let’s do this first.
Afterwards, we can enable row-level security on the tables.
Important: Remember that you need to have superuser permissions from this point onwards. If RLS is enabled but your user cannot bypass it, you’ll be stuck.
Done. RLS is enabled. Easy eh?!
Access Policies for Customers
Now that we have enabled RLS, we need to ensure that customers can see their own records. To do this, row-level security uses policies as explained above.
For customers, the policies are easy enough. We kept it simple. Every table that a customer has to access contains a customer_id field. Our policy tests the app.current_customer_id value against that field’s value. If it matches, the row will be returned. We limit this policy to the customer role, though.
The policies themselves are only defined for select queries, meaning customers cannot add, update, or delete records. Not even their own.
Testing Customer Access
Ok, time to test our access control implementation for the first time. Let’s try it on the database level first.
In psql we can create the transaction, set our role, the customer id, and query the data, before rolling back the transaction.
As we can see, each customer only sees their own record. RLS helped us filter the customer records.
That said, the RLS policy automatically adds the additional where clause to the query. Not just figuratively, but in practice. Executing the query and asking for the query plan, we can see that Postgres added the filter to the actual query.
Testing the REST API
And since we already have our REST API, let’s quickly give it a test, too. Using CURL we can execute requests against it and pass along the required context.
Just as with the database query, we see that our filtering was applied automatically.
Merging Access Policies
Now that our customers are working, it is time to go deeper. We already know that we also want to have account managers and administrators.
To enable them, we need more policies. Luckily, RLS supports multiple policies out of the box. However, with multiple policies, PostgreSQL then has to decide: how do these rules combine?
By default, Postgres behaves permissively. Multiple policies for the same command are merged with an OR. Meaning, if any policy allows a row, the row is visible.
Sometimes, however, an OR isn’t what you want. What if you want to apply additional filters that stack together? That’s where AS RESTRICTIVE comes in.
Restrictive policies are combined with AND instead of OR.
As an example, we could say that customers can only see active invoices:
Enabling Account Managers
Now let’s get back to work. We want account managers who are assigned to one or more customer accounts. Therefore, we’ll add two more tables to define account managers and assign their customers.
That db_role field is important: it links real Postgres roles to logical managers, so we can SET ROLE and have policies filter correctly.
Let’s also add two account managers while we’re at it.
Creating Account Manager Permissions
First, we need the roles and general grants on the necessary tables. For the roles, it is vital to prevent them from bypassing RLS policies.
And the actual policies. For the sake of the blog post, here is just one of them. But like the others before, they’re pretty much identical, looking up if the requested customer (of the row) is inside the account manager’s portfolio. If not, it’s filtered out. Unfortunately, due to the difference of USING and WITH CHECK (for the different SQL operations), we need to duplicate the query.
Testing Account Managers
Ok then, let’s also give this a quick test. In the case of account managers, they all have their own user role. Hence, we only have to set the role and no customer id. Assigned customers are automatically applied by our policy.
And again, the same is true for our REST API.
Application Administrators Can See Everyone
Last but not least, our service will have administrators. Those are different from database superusers. However, they have access to all customers. To implement that, we have two options.
First, we can just bypass RLS altogether for administrators. That is easy and practical, if there are no other tables that even administrators shouldn’t be able to see (like another application).
Or we go the long route, which is very similar to before. We create the role, add the general grants (I left them out above, but they would also apply there).
And create the policies. Again, for simplicity, only one example of the policy. The other ones are similar, differing only in the policy name and the table they’re applied to.
The critical bit on this policy is that USING and WITH CHECK just “return” true, and basically, voids all checks.
Multi-Tenancy Doesn’t Have To Be Hard
Sounds too easy? Try it for yourself the full source code is available at GitHub. Feel free to play with it, and adjust it. See how row-level security can simplify your application code, moving the aspect of “visibility” to the database.
I strongly believe that moving the accessibility check into RLS policies can significantly improve the readability of your application codebase while enhancing security. No more “oops, missed the filtering.”
It’s also very extensible. If you want, you’re able to build a full-blown RBAC (role-based access control) system using a few tables and policies.
However, there is a lot of boilerplating that has to be done here. That’s why tools like Vela provide a more straightforward approach to RLS using an easy-to-use user interface and API.
Vela enables you to define role templates, easily apply them across different databases, and synchronize users. You want to learn more on how Vela can help you? Book your call now.
Either way, implementing multi-tenancy should use row-level security. In Postgres. Always.