RUBY ON RAILS · 21 MIN READ ·

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.

Rails PostgreSQL Row-Level Security: Multi-Tenant SaaS Isolation with RLS Policies

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.invoices still gets filtered. Invoice.find(42) with no scope will return nil if invoice 42 belongs to another tenant. raw SQL executed through ActiveRecord::Base.connection.execute is 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_account is 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 SECURITY applies 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.
  • USING and WITH CHECK are two separate clauses. USING filters what the current session can read. WITH CHECK validates 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) — the true second argument means “return null if the setting is missing” instead of raising an error. Combined with the ::bigint cast, an unset session variable produces NULL = bigint which is NULL which 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 LOCAL scopes 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 LOCAL outside a transaction is silently ignored. The ActiveRecord::Base.transaction block above establishes one for the entire request. On PostgreSQL this has near-zero overhead — the transaction is just a BEGIN/COMMIT around work that would already happen.
  • account_id.to_i protects against SQL injection. Never interpolate current_account.id directly 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’s account_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 an account_id filter 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.

#rails-postgresql-row-level-security #rails-rls-multi-tenant #postgres-rls-rails-saas #rails-set-config-tenant #rails-rls-policies-production #postgres-row-security-rails-8

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software