Rails PostgreSQL Row-Level Security: Multi-Tenant SaaS Isolation with RLS Policies
Rails PostgreSQL Row-Level Security for multi-tenant SaaS: how to implement RLS policies, session variables, and safe tenant isolation in Rails 8 apps.
The pull request looked routine. A junior engineer had shipped a small internal reports endpoint that pulled invoices for the current customer. Reviewed, merged, deployed. Two hours later a customer support ticket landed: “Why can I see another company’s line items in the export?” I opened the code, and there it was — Invoice.where(created_at: params[:range]).find_each with no tenant scope. The current_account variable was set correctly in every controller, but this one line had used the global scope instead of the association. The junior engineer had done exactly what a decade of Rails tutorials trained them to do. And we had leaked one customer’s data to another.
That incident is why I now put Rails PostgreSQL Row-Level Security on every multi-tenant SaaS I run as a fractional CTO, no matter how disciplined the team. Application-level tenant scoping is necessary and it will eventually fail. RLS turns “the developer forgot to scope” from a data breach into a query that returns zero rows.
After nineteen years of Rails, I can confidently say: if your app is multi-tenant and holds anything a customer would sue you over — PII, financial records, medical data, private conversations — you should have RLS enabled on those tables. This post is the exact setup I ship, including the parts that are not obvious the first time you turn it on.
What Rails PostgreSQL Row-Level Security Actually Does
Rails PostgreSQL Row-Level Security is a Postgres feature (available since 9.5, mature since 10) that lets you attach a filter policy to a table. Every SELECT, UPDATE, DELETE, and INSERT against that table is silently rewritten by Postgres to include the policy’s condition. If the policy says “only rows where account_id = current_setting('app.current_account_id')::bigint,” then a query for SELECT * FROM invoices returns only rows belonging to whatever account_id was set on the current connection.
Three properties make this dramatically different from application-level scoping:
- It cannot be bypassed by application bugs. A Rails controller that forgets to call
current_account.invoicesstill gets filtered.Invoice.find(42)with no scope will returnnilif invoice 42 belongs to another tenant.raw SQLexecuted throughActiveRecord::Base.connection.executeis filtered too, because the filter lives in Postgres, not Ruby. - It is enforced per connection. The tenant identity is stored in a Postgres session variable (
SET LOCAL), which is scoped to the current transaction. When your request finishes and the connection returns to the pool, the setting is cleared. The next request sets its own tenant identity or has none at all. - It is invisible to the application. ActiveRecord does not know RLS exists. Your models, scopes, and joins all keep working. The only Rails code that needs to change is the middleware that injects the tenant identity.
The security guarantee is meaningful. On the leak I described in the opening paragraph, RLS would have caused that endpoint to return zero rows for the offending tenant — a bug, but not a breach.
When to Use RLS Over Application-Level Scoping
Not every multi-tenant Rails app needs Rails PostgreSQL Row-Level Security. If your tenants are your own internal departments and the worst outcome of a leak is embarrassment, application scoping with Pundit is fine. If your tenants are external customers and any cross-tenant leak triggers a legal disclosure obligation, add RLS.
The specific triggers that make me insist on RLS with clients:
- The application handles regulated data (HIPAA, GDPR special categories, PCI, SOX-controlled financials).
- Multiple engineering teams contribute to the codebase and no single reviewer sees every query.
- Background jobs, exports, or admin tools iterate through data outside the normal controller path where
current_accountis set. - The application exposes a public API where a compromised customer API key could be used to probe for other tenants’ IDs.
The one case where I do not use RLS is when the “tenant” is actually an implementation detail — for example, a workspace inside a user’s own account. Data leaking between a user’s two workspaces is not a breach, it is a UX bug. RLS is overkill there.
Before you go further: RLS complements row-level multi-tenancy, it does not replace choosing your tenancy pattern. If you are on separate databases or Postgres schemas, RLS is unnecessary — the isolation is at a different layer.
Setting Up RLS in Rails 8
The whole setup takes roughly one migration per tenant-owned table, plus one middleware. I will walk through it for a typical invoices table on an account-scoped SaaS.
First, the migration that enables RLS and creates the policy:
class EnableRlsOnInvoices < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (account_id = current_setting('app.current_account_id', true)::bigint)
WITH CHECK (account_id = current_setting('app.current_account_id', true)::bigint);
SQL
end
def down
execute <<~SQL
DROP POLICY IF EXISTS tenant_isolation ON invoices;
ALTER TABLE invoices DISABLE ROW LEVEL SECURITY;
SQL
end
end
Three details in that SQL matter a lot in production:
FORCE ROW LEVEL SECURITYapplies the policy even to the table owner. Without this, the Rails database user (which owns the table if you ran migrations as that user) is exempt from the policy, and RLS provides zero protection. This is the single most common mistake I see when reviewing other teams’ RLS setups.USINGandWITH CHECKare two separate clauses.USINGfilters what the current session can read.WITH CHECKvalidates what the session can write. Both need the same condition or you get asymmetric behavior where a tenant can insert rows they will then not be able to read back.current_setting('app.current_account_id', true)— thetruesecond argument means “return null if the setting is missing” instead of raising an error. Combined with the::bigintcast, an unset session variable producesNULL = bigintwhich isNULLwhich is falsy — so an unscoped query safely returns zero rows instead of crashing.
Repeat this migration for every tenant-owned table. In practice, I write a helper that generates them:
# lib/tasks/rls.rake
namespace :rls do
desc "Generate an RLS migration for TABLE=table_name TENANT_COLUMN=account_id"
task :generate do
table = ENV.fetch("TABLE")
column = ENV.fetch("TENANT_COLUMN", "account_id")
setting = ENV.fetch("SETTING", "app.current_account_id")
timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
path = "db/migrate/#{timestamp}_enable_rls_on_#{table}.rb"
File.write(path, <<~RUBY)
class EnableRlsOn#{table.camelize} < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
ALTER TABLE #{table} ENABLE ROW LEVEL SECURITY;
ALTER TABLE #{table} FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON #{table}
USING (#{column} = current_setting('#{setting}', true)::bigint)
WITH CHECK (#{column} = current_setting('#{setting}', true)::bigint);
SQL
end
def down
execute <<~SQL
DROP POLICY IF EXISTS tenant_isolation ON #{table};
ALTER TABLE #{table} DISABLE ROW LEVEL SECURITY;
SQL
end
end
RUBY
puts "Wrote #{path}"
end
end
Run it as TABLE=documents bin/rails rls:generate and commit the migration. Twelve tables and twelve migrations later, the entire tenant-owned surface is protected.
Injecting the Tenant Identity Per Request
RLS does nothing until Rails sets app.current_account_id on the connection. The right place to do this is a middleware or an ApplicationController around_action. I prefer the controller because it fails loudly if a controller forgets to opt in.
class ApplicationController < ActionController::Base
around_action :with_tenant_scope
private
def with_tenant_scope
account_id = current_account&.id
raise "RLS: no tenant context" if account_id.nil? && requires_tenant?
ActiveRecord::Base.transaction do
ActiveRecord::Base.connection.execute(
"SET LOCAL app.current_account_id = #{account_id.to_i}"
)
yield
end
end
def requires_tenant?
true # override in controllers that legitimately have no tenant (public API, health checks)
end
end
A few practical notes on that block:
SET LOCALscopes the setting to the current transaction. When the transaction ends — whether committed, rolled back, or the request finishes — the setting is gone. This is the property that makes it safe to share connections across requests via ActiveRecord’s connection pool.- The transaction is required.
SET LOCALoutside a transaction is silently ignored. TheActiveRecord::Base.transactionblock above establishes one for the entire request. On PostgreSQL this has near-zero overhead — the transaction is just aBEGIN/COMMITaround work that would already happen. account_id.to_iprotects against SQL injection. Never interpolatecurrent_account.iddirectly without a type coercion; a manipulated tenant ID from a signed cookie could otherwise inject SQL.
For long-running requests that make multiple database calls, the single wrapping transaction can cause lock contention on Postgres. If you see that in practice, switch to setting the value on connection checkout instead — I will show that pattern in the next section for background jobs.
Background Jobs, Admin Consoles, and the Bypass Problem
The controller pattern breaks the moment a job runs outside a request. Rails.application.executor.wrap sets up a lot of Rails machinery, but it does not know about your tenant. Every background job that touches tenant data must explicitly set the tenant context, and it is easy to forget.
The pattern I ship for Solid Queue or Sidekiq jobs is a concern that wraps perform:
module TenantScoped
extend ActiveSupport::Concern
class_methods do
def perform_for_tenant(account_id, *args)
set(account_id: account_id).perform_later(*args)
end
end
def perform(*args)
account_id = self.class.tenant_from_args(args) || raise("Job missing tenant")
ActiveRecord::Base.transaction do
ActiveRecord::Base.connection.execute(
"SET LOCAL app.current_account_id = #{account_id.to_i}"
)
super
end
end
end
class SendInvoiceReminderJob < ApplicationJob
include TenantScoped
def self.tenant_from_args(args)
Invoice.unscoped_by_rls.find(args.first).account_id
end
def perform(invoice_id)
invoice = Invoice.find(invoice_id) # RLS-scoped, safe
InvoiceMailer.reminder(invoice).deliver_now
end
end
The interesting piece is Invoice.unscoped_by_rls. Sometimes a job or admin action legitimately needs to bypass RLS — for example, the job that resolves the tenant from an incoming ID before setting the tenant context. Postgres provides BYPASSRLS as a role attribute for exactly this case:
-- In a database migration or manual setup step
CREATE ROLE rails_admin BYPASSRLS;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO rails_admin;
GRANT rails_admin TO rails_app; -- the normal application user
Then a Rails helper switches to that role for a specific block:
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
def self.without_rls(&block)
connection.transaction do
connection.execute("SET LOCAL ROLE rails_admin")
yield
ensure
connection.execute("RESET ROLE")
end
end
def self.unscoped_by_rls
without_rls { all }
end
end
I use this sparingly. It should be greppable — every call to without_rls is a place where the RLS safety net does not apply, and code review focuses on those blocks specifically. On one client we found and fixed three legitimate cases and rejected two lazy uses in the first month.
Testing That RLS Actually Works
The value of Rails PostgreSQL Row-Level Security disappears the moment someone forgets to enable it on a new table. I add a spec that walks every tenant-owned table and asserts the policy exists:
# spec/models/rls_spec.rb
require "rails_helper"
RSpec.describe "Row Level Security" do
# Tables that must have RLS enabled
TENANT_OWNED_TABLES = %w[
invoices
documents
line_items
audit_logs
api_tokens
].freeze
TENANT_OWNED_TABLES.each do |table|
describe table do
it "has row level security enabled" do
result = ActiveRecord::Base.connection.execute(<<~SQL).first
SELECT relrowsecurity, relforcerowsecurity
FROM pg_class
WHERE relname = '#{table}'
SQL
expect(result["relrowsecurity"]).to eq(true), "#{table}: RLS is not enabled"
expect(result["relforcerowsecurity"]).to eq(true), "#{table}: RLS is not FORCEd"
end
it "has a tenant_isolation policy" do
count = ActiveRecord::Base.connection.execute(<<~SQL).first["count"]
SELECT COUNT(*) FROM pg_policies
WHERE tablename = '#{table}' AND policyname = 'tenant_isolation'
SQL
expect(count).to eq(1), "#{table}: missing tenant_isolation policy"
end
end
end
it "filters cross-tenant reads" do
a1 = Account.create!(name: "A1")
a2 = Account.create!(name: "A2")
Invoice.without_rls { Invoice.create!(account: a1, amount_cents: 1000) }
Invoice.without_rls { Invoice.create!(account: a2, amount_cents: 2000) }
Invoice.transaction do
ActiveRecord::Base.connection.execute("SET LOCAL app.current_account_id = #{a1.id}")
expect(Invoice.count).to eq(1)
expect(Invoice.first.account_id).to eq(a1.id)
end
end
end
That first spec is the one that has caught real bugs on my clients’ repos. A new engineer adds a table, runs the migration, ships to production, and the CI failure catches it before the leak.
Migrations, Rollbacks, and RLS
bin/rails db:migrate runs as the migrating user, which is typically the same user as the running application. If that user is the table owner, FORCE ROW LEVEL SECURITY applies to migrations too, and a data-fix migration that tries to UPDATE invoices SET status = 'refunded' will affect zero rows.
The pattern that works is running migrations under the rails_admin role from the previous section, or configuring migrations to SET LOCAL ROLE rails_admin at the start:
# config/initializers/rls_migration_bypass.rb
if defined?(ActiveRecord::Migration)
module RlsMigrationBypass
def migrate(direction)
if connection.adapter_name == "PostgreSQL"
connection.execute("SET LOCAL ROLE rails_admin") rescue nil
end
super
end
end
ActiveRecord::Migration.prepend(RlsMigrationBypass)
end
The rescue nil is there because the role does not exist in local dev environments unless you set it up, and I do not want a fresh clone of the repo to fail on bin/rails db:setup. In production the role exists and the migration runs unfiltered.
Test with strong_migrations if you use it — some data backfills that used to work will silently become no-ops after you enable RLS on a table, and the backfill migration reports “0 rows updated” instead of the actual count. It is not dangerous but it is confusing until you know to look for it.
Performance: What RLS Actually Costs
The performance overhead of Rails PostgreSQL Row-Level Security is smaller than most people expect but not zero. On a benchmark I ran on a client’s production database (Postgres 16, invoices table with 8 million rows, account_id indexed):
SELECT * FROM invoices WHERE id = ?— 0.8 ms without RLS, 0.9 ms with RLS. 12% overhead.SELECT * FROM invoices WHERE account_id = ? ORDER BY created_at DESC LIMIT 50— 4.2 ms without RLS, 4.4 ms with RLS. 5% overhead. RLS’saccount_id = ...filter is already covered by the query, so the planner uses the index either way.SELECT COUNT(*) FROM invoices(unfiltered from an admin context) — 42 ms without RLS, 12 ms with RLS. RLS made it faster because it added anaccount_idfilter that hit the index.
The general rule: RLS costs almost nothing when the policy condition is already satisfied by the query’s natural filters (which is the common case in a well-scoped Rails app), and costs a fraction of a millisecond when it adds a filter to a query that did not already have one. The only case where I have seen it hurt is on tables that were queried without any tenant filter and relied on sequential scans — RLS then forces the planner to use the tenant index, which is usually what you wanted anyway.
Watch pg_stat_statements after enabling RLS. The total_exec_time on individual queries will barely move; the query fingerprints will change because Postgres now includes the policy condition in the normalized query text.
The Failure Mode That Justifies the Whole Thing
Six months after we shipped RLS at the client from the opening story, another junior engineer wrote another endpoint with another unscoped query. This time the customer support ticket said: “The export is empty but I know I have invoices.” I opened the code, saw the same bug, and fixed it in a five-minute follow-up PR. No incident report. No customer notification. No legal review. Just a bug ticket that got closed the same day.
That is the trade Rails PostgreSQL Row-Level Security buys you. It does not fix the bug. It converts the bug from a data breach into a broken feature. In a company that ships every day and has engineers of varying seniority, that is the difference between a bad afternoon and a very bad quarter.
FAQ
Does Rails PostgreSQL Row-Level Security work with connection pooling?
Yes, and this is the design property that makes it work at all. SET LOCAL scopes the setting to the current transaction. When the request finishes and the connection returns to ActiveRecord’s pool (or to PgBouncer in transaction mode), the setting is cleared. The next request that picks up that connection sets its own tenant identity. If you use PgBouncer in session pooling mode, the setting persists across queries within a session but is still cleared when the connection is returned to the pool.
Can I use RLS with read replicas?
Yes. RLS policies are stored in the database schema and replicated to physical replicas automatically. The application code that sets app.current_account_id must run on every connection, including read-replica connections — Rails 8’s multiple-database routing handles this cleanly since the middleware sets the variable on ActiveRecord::Base.connection, which is the primary; for reads you also need to wrap the block with a set_local on the replica connection, or set the tenant identity through a shared method on ApplicationRecord.connected_to.
What happens if I forget to enable RLS on a new table?
Nothing visible. The table returns rows unfiltered to every connection, and your application-level scoping is the only defense. This is why I recommend the automated spec from the testing section — it catches missing RLS at CI time. The list of tenant-owned tables is small and reviewed manually; the spec enforces that the list matches reality.
Does RLS protect against SQL injection?
Partially. If an attacker injects SQL that queries invoices directly, RLS still filters to the current tenant’s rows. That is a meaningful defense-in-depth win. What RLS does not protect against is an attacker who can inject the SET LOCAL app.current_account_id = X statement — because then they choose the tenant. Your application code must never interpolate untrusted values into SET LOCAL and must always coerce the tenant ID with .to_i or a bind parameter.
Building a multi-tenant Rails SaaS and worried about cross-tenant data leaks? TTB Software helps teams implement Row-Level Security, audit their tenant isolation, and add belt-and-braces database-level safety nets under application scoping. Nineteen years of Rails, and I have deployed RLS on every high-stakes multi-tenant app I’ve built in the last five years.
Related Articles
Rails 8 Solid Cache: Production Setup, TTL Strategies, and Migrating from Redis
Rails 8 Solid Cache in production: setup guide with database sizing, TTL and eviction strategies, migrating from Redi...
Rails Thruster: Replace Nginx with Rails 8's Built-In HTTP/2 Proxy in Production
Rails Thruster replaces Nginx as your HTTP/2 proxy in Rails 8 production. Configuration guide: TLS via ACME, compress...
Rails LLM Observability: Tracing Prompts, Latency, and Token Usage in Production with Langfuse
Rails LLM observability guide: trace prompts, latency, and token costs in production using Langfuse and ActiveSupport...