Application-level tenant isolation depends on every developer remembering every WHERE clause. PostgreSQL Row-Level Security enforces isolation at the database itself. Here is how to implement it correctly in Go and what it catches that application code misses.
Most Go SaaS backends isolate tenants by filtering every query with WHERE tenant_id = $1. This works until it does not. A single missing clause in a query handler, a poorly reviewed pull request, or a junior developer who did not know the pattern returns data across tenant boundaries. Row-Level Security (RLS) in PostgreSQL enforces isolation at the database itself, independently of what the application sends. Used correctly, it is the difference between a single bug causing a data breach and a single bug causing a query error.
Why application-level tenant isolation has a failure mode
In a typical Go SaaS backend, tenant isolation looks like this in every query:
rows, err := db.QueryContext(ctx,
`SELECT id, name, amount FROM orders WHERE tenant_id = $1 AND status = $2`,
tenantID, status,
)
The pattern is explicit and easy to audit. The failure mode is exhaustion. A backend with 50 tables and 200 query functions requires every developer on every pull request to remember the tenant_id filter on every query. One oversight in a rarely touched reporting query, one background job that bypasses middleware, one migration script that runs without tenant context produces a cross-tenant data leak.
In Lebanon and across the MENA region, SaaS products handling financial data, customer PII, or operational data for competing businesses in the same market face real legal and reputational consequences if tenant boundaries leak. Defense in depth is worth the implementation effort.
What PostgreSQL RLS actually does
Row-Level Security is a PostgreSQL feature that attaches security policies to individual tables. Once enabled, the database evaluates a predicate expression for every row access, on every query, regardless of what the application sends.
-- Enable RLS on the orders table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Create a policy: users can only see rows for their current tenant
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
When this policy is active, even a full table scan query returns nothing for the wrong tenant:
-- This returns rows only for tenant 42, never for other tenants
SET app.current_tenant_id = '42';
SELECT * FROM orders; -- Safe: filtered by RLS policy automatically
The application still sets the tenant context, but now the database enforces it independently. A missing WHERE clause becomes a filtered result rather than a data leak.
Setting up RLS policies for a multi-tenant schema
The typical multi-tenant Go SaaS schema uses a tenant_id foreign key on every tenant-scoped table. Adding RLS to this schema:
-- Create a dedicated application role that cannot bypass RLS
CREATE ROLE app_user;
GRANT CONNECT ON DATABASE mydb TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
-- Superuser and the table owner bypass RLS by default
-- The app_user role does not have BYPASSRLS privilege, so policies apply
For each tenant-scoped table:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY; -- Applies even to table owner
CREATE POLICY tenant_isolation ON orders
AS PERMISSIVE
FOR ALL
TO app_user
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::bigint);
The WITH CHECK clause applies to INSERT and UPDATE operations. Without it, an application bug could insert a row with the wrong tenant_id, and RLS would not catch it.
Propagating tenant context from Go through the connection pool
The challenge with RLS is that the current_setting value is session-scoped. With a connection pool like pgBouncer or the Go standard library sql.DB, connections are reused between requests. Setting the tenant context on one request and not resetting it before the connection is returned to the pool leaks tenant context to the next request.
The correct approach is to set and reset tenant context within a transaction:
func (r *OrderRepository) FindByID(ctx context.Context, tenantID int64, orderID int64) (*Order, error) {
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
if err != nil {
return nil, err
}
defer tx.Rollback()
// Set tenant context for this transaction only
_, err = tx.ExecContext(ctx, "SELECT set_config('app.current_tenant_id', $1, true)", strconv.FormatInt(tenantID, 10))
if err != nil {
return nil, err
}
// RLS now enforces tenant_id = tenantID on all queries within this transaction
var order Order
err = tx.QueryRowContext(ctx,
`SELECT id, tenant_id, customer_id, total, status FROM orders WHERE id = $1`,
orderID,
).Scan(&order.ID, &order.TenantID, &order.CustomerID, &order.Total, &order.Status)
if err != nil {
return nil, err
}
return &order, tx.Commit()
}
The third argument to set_config controls whether the setting is transaction-local. Passing true means the setting resets automatically when the transaction ends, making it safe to use with connection pools.
For non-transactional reads, use a local transaction that rolls back after the read. The overhead of beginning and rolling back a read-only transaction is minimal.
Performance considerations: when RLS adds overhead
RLS adds a predicate to every query plan. For simple indexed lookups this is negligible. For large table scans, the predicate is pushed down and helps PostgreSQL narrow the result set early. The main performance concern is that PostgreSQL must evaluate the policy expression for each candidate row.
In practice, the overhead on well-indexed tenant-scoped tables is under 1 millisecond per query. We have measured this on production Go SaaS backends serving Lebanese and MENA clients handling 500 to 2000 requests per second on ECS Fargate, and RLS has never appeared as a bottleneck in our OpenTelemetry traces.
One case where RLS does add meaningful overhead: analytics queries doing full table scans across millions of rows where the RLS predicate is not indexed. For these cases, either add a composite index that includes tenant_id, or run analytics on a read replica where RLS is disabled for a dedicated analytics role.
What RLS does not protect against
RLS is not a replacement for application-level tenant validation. It is a second layer of defense.
RLS does not protect against:
- Bugs in the RLS policy expression itself
- Superuser connections that bypass RLS (always restrict superuser access from application connections)
- Shared tables that intentionally have no tenant_id (global configuration, reference data)
- Application-level operations that happen before data reaches the database (API responses from cache, for example)
Use RLS together with application-level tenant context propagation, not instead of it.
Production lessons from MENA SaaS deployments
When we rolled out RLS on a Lebanese restaurant SaaS backend, the first thing we discovered was that several background jobs used a connection with no tenant context set. These jobs aggregated data across all tenants, which was intentional, but they needed a dedicated database role that bypassed RLS rather than a missing set_config call.
The second thing we discovered was that integration tests that created test data without setting tenant context now failed silently. Rows inserted without a matching tenant context violate the WITH CHECK policy and return an error. This caught several test setup bugs that had been silently inserting unconstrained data.
Both of these are good failures to have early.
Key lessons from production
PostgreSQL RLS adds a database-level enforcement layer that catches the class of tenant isolation bugs that application code misses. The performance overhead on indexed queries is negligible. The main implementation cost is correctly propagating tenant context through the connection pool using transaction-local settings. Always pair RLS with a dedicated application database role that has no BYPASSRLS privilege.
Enjoying this article?
Enter your email and get a clean, formatted PDF of this article - free, no spam.
Not sure where to start?
If your Go SaaS backend needs proper multi-tenant isolation that does not depend on every developer remembering every WHERE clause, Voxire builds and audits Go SaaS infrastructure for teams in Lebanon and across MENA. Reach out at https://voxire.com/get-a-quote/



