DEV Community

Cover image for Row-Level Multitenancy in Rails: Building a Bulletproof Tenant Isolation Layer from Scratch
Temitope
Temitope

Posted on

Row-Level Multitenancy in Rails: Building a Bulletproof Tenant Isolation Layer from Scratch

If you've reached for acts_as_tenant or apartment without really understanding what's happening underneath, this tutorial is the correction. We're building a row-level multitenant system from first principles — the kind you can actually reason about when something goes wrong at 2am.

By the end, you'll have:

  • Middleware that resolves the current tenant from a subdomain or JWT claim
  • A Current-based tenant context that threads safely through the request lifecycle
  • An ApplicationRecord mixin that enforces tenant scoping at the model layer
  • Postgres row-level security policies as a hard backstop
  • A TenantSafe concern for background jobs that re-establishes context correctly
  • A test helper that won't let you accidentally write cross-tenant specs

No gems. Just Rails, Postgres, and honest code.


The Mental Model

Row-level multitenancy means every tenant's data lives in the same tables, separated by a tenant_id foreign key. The application layer is responsible for filtering. The database can optionally enforce this too (and should, as defense-in-depth).

The failure modes are well-known:

  1. A developer forgets to scope a query → data leak
  2. A background job runs without tenant context → unscoped query touches all tenants
  3. An association crosses tenant boundaries silently

We'll build guardrails against all three.


Step 1: The Tenant Model and Migration

First, the tenant itself. Keep it simple — tenants own a subdomain and that's the primary resolution mechanism.

# db/migrate/20240901000001_create_tenants.rb
class CreateTenants < ActiveRecord::Migration[7.2]
  def change
    create_table :tenants do |t|
      t.string  :name,      null: false
      t.string  :subdomain, null: false
      t.string  :status,    null: false, default: "active"
      t.jsonb   :settings,  null: false, default: {}
      t.timestamps
    end

    add_index :tenants, :subdomain, unique: true
    add_index :tenants, :status
  end
end
Enter fullscreen mode Exit fullscreen mode
# db/migrate/20240901000002_create_accounts.rb
# A representative tenant-scoped resource
class CreateAccounts < ActiveRecord::Migration[7.2]
  def change
    create_table :accounts do |t|
      t.references :tenant, null: false, foreign_key: true, index: true
      t.string :name,  null: false
      t.string :email, null: false
      t.timestamps
    end

    # Composite index: tenant lookups are always tenant-first
    add_index :accounts, [:tenant_id, :email], unique: true
  end
end
Enter fullscreen mode Exit fullscreen mode

The tenant_id column goes on every tenant-scoped table. No exceptions. Make this a convention enforced in code review, or better — a custom RuboCop rule that checks migrations.


Step 2: Thread-Local Tenant Context via Current

Rails 5.2 shipped ActiveSupport::CurrentAttributes. It gives you a request-scoped (or thread-scoped) object that's automatically reset between requests. This is the right place for tenant context.

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :tenant

  # Convenience predicate used throughout the app
  def tenant?
    tenant.present?
  end

  # Hard assertion — use in contexts where a missing tenant is a bug
  def tenant!
    tenant || raise(TenantNotSetError, "Current.tenant is not set")
  end
end
Enter fullscreen mode Exit fullscreen mode
# app/errors/tenant_not_set_error.rb
class TenantNotSetError < StandardError; end
class TenantNotFoundError < StandardError; end
Enter fullscreen mode Exit fullscreen mode

CurrentAttributes resets between requests automatically because Rails calls reset on it at the end of each request via the executor. You get thread safety for free. Don't roll your own thread-local here — this is one case where the Rails abstraction is genuinely better.


Step 3: Middleware for Tenant Resolution

Subdomain-based resolution is the most common pattern. The middleware runs before your controllers and sets Current.tenant for the entire request.

# app/middleware/tenant_resolver.rb
class TenantResolver
  EXCLUDED_SUBDOMAINS = %w[www api admin].freeze

  def initialize(app)
    @app = app
  end

  def call(env)
    request  = ActionDispatch::Request.new(env)
    subdomain = extract_subdomain(request)

    if subdomain && EXCLUDED_SUBDOMAINS.exclude?(subdomain)
      tenant = Tenant.active.find_by(subdomain: subdomain)

      unless tenant
        return [
          302,
          { "Location" => root_url(request), "Content-Type" => "text/html" },
          ["Tenant not found"]
        ]
      end

      Current.tenant = tenant
    end

    @app.call(env)
  ensure
    # CurrentAttributes resets itself, but be explicit for clarity
    Current.reset
  end

  private

  def extract_subdomain(request)
    # Handles localhost (single part) and production (multi-part) hosts
    parts = request.host.split(".")
    return nil if parts.length <= (Rails.env.production? ? 2 : 1)
    parts.first.downcase.presence
  end

  def root_url(request)
    "#{request.protocol}#{request.host_with_port}/"
  end
end
Enter fullscreen mode Exit fullscreen mode

Register it in the stack, just after session middleware so cookies are available if you need them:

# config/application.rb
config.middleware.insert_after ActionDispatch::Session::CookieStore, TenantResolver
Enter fullscreen mode Exit fullscreen mode

If you're using API mode with JWT instead of subdomains, the shape is the same — just parse the tenant claim from the Authorization header and resolve accordingly:

# Inside TenantResolver#call, for API mode
def resolve_from_jwt(request)
  token = request.headers["Authorization"]&.delete_prefix("Bearer ")
  return unless token

  payload = JwtService.decode(token)  # your existing JWT layer
  Tenant.active.find_by(id: payload["tenant_id"])
rescue JWT::DecodeError
  nil
end
Enter fullscreen mode Exit fullscreen mode

Step 4: Enforcing Tenant Scope at the Model Layer

This is the core of the system. Every model that belongs to a tenant should be impossible to query without a scope.

# app/models/concerns/tenant_scoped.rb
module TenantScoped
  extend ActiveSupport::Concern

  included do
    belongs_to :tenant

    # Default scope: every query automatically filters by current tenant
    default_scope { where(tenant: Current.tenant) if Current.tenant? }

    # Validate that the record's tenant matches the current tenant
    validates :tenant_id, presence: true
    validate  :tenant_matches_current_context, on: :create

    before_create :assign_current_tenant
  end

  class_methods do
    # Escape hatch for internal/admin queries — use sparingly and explicitly
    def unscoped_for_tenant(tenant)
      unscoped.where(tenant: tenant)
    end

    # For cross-tenant admin operations only
    def all_tenants
      raise TenantNotSetError, "Use unscoped explicitly" unless Current.tenant.nil?
      unscoped
    end
  end

  private

  def assign_current_tenant
    self.tenant ||= Current.tenant
  end

  def tenant_matches_current_context
    return unless Current.tenant?
    return if tenant_id == Current.tenant.id

    errors.add(:tenant_id, "does not match current tenant context")
  end
end
Enter fullscreen mode Exit fullscreen mode

Include it in your models:

# app/models/account.rb
class Account < ApplicationRecord
  include TenantScoped

  validates :name,  presence: true
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
end
Enter fullscreen mode Exit fullscreen mode

Now Account.all automatically returns only the current tenant's accounts. Account.create!(name: "Acme") automatically assigns tenant_id. You can't accidentally cross tenant boundaries in normal operation.

A Note on default_scope

default_scope is controversial in the Rails community, and the criticism is fair: it makes behaviour non-obvious, and it can cause surprising joins. Here's the rule: use it only for tenant scoping, never for anything else (no order, no where deleted_at IS NULL). Tenant scoping is the one case where you actually want it to be invisible — the whole point is that forgetting the scope is the bug.


Step 5: Postgres Row-Level Security as a Hard Backstop

The application layer is the first line of defense. RLS is the second. Even if a bug slips through your default scope, Postgres won't return data that doesn't belong to the current tenant.

-- db/migrate/20240901000010_enable_rls_on_accounts.rb
class EnableRlsOnAccounts < ActiveRecord::Migration[7.2]
  def up
    # Create an application-level DB user that isn't a superuser
    execute <<~SQL
      ALTER TABLE accounts ENABLE ROW LEVEL SECURITY;
      ALTER TABLE accounts FORCE ROW LEVEL SECURITY;

      -- Policy: SELECT/INSERT/UPDATE/DELETE only see rows for the current tenant
      CREATE POLICY tenant_isolation ON accounts
        USING (tenant_id = current_setting('app.current_tenant_id')::bigint)
        WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::bigint);
    SQL
  end

  def down
    execute <<~SQL
      DROP POLICY IF EXISTS tenant_isolation ON accounts;
      ALTER TABLE accounts DISABLE ROW LEVEL SECURITY;
    SQL
  end
end
Enter fullscreen mode Exit fullscreen mode

Now wire the Postgres session variable from Rails. The cleanest place is an around_action in ApplicationController, after the middleware has already set Current.tenant:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  around_action :set_rls_tenant_context

  private

  def set_rls_tenant_context
    return yield unless Current.tenant?

    # Set the session-level variable Postgres uses in the RLS policy
    ActiveRecord::Base.connection.execute(
      ActiveRecord::Base.sanitize_sql(
        ["SELECT set_config('app.current_tenant_id', ?, false)",
         Current.tenant.id.to_s]
      )
    )
    yield
  rescue ActiveRecord::StatementInvalid => e
    # Don't let RLS config failure silently succeed
    Rails.logger.error("RLS context error: #{e.message}")
    raise
  end
Enter fullscreen mode Exit fullscreen mode

The false argument to set_config means the setting is transaction-scoped, not session-scoped. This is correct — you want each request/transaction to set it explicitly, not inherit it from a pooled connection's previous request.

Connection pool caveat: Because you're using a connection pool (via PgBouncer or Rails' own pool), you must set the Postgres variable on every transaction, not assume it persists. The around_action above handles this correctly because it runs on every request.


Step 6: Background Jobs Without Foot Guns

Background jobs are where multitenant systems most commonly break. A job enqueued in a tenant context runs later in a worker process that has no HTTP request — so Current.tenant is nil, and your default scopes stop working.

The pattern: always serialize the tenant_id with the job, and restore Current.tenant before the job body runs.

# app/jobs/concerns/tenant_aware.rb
module TenantAware
  extend ActiveSupport::Concern

  included do
    before_enqueue  :capture_tenant_context
    before_perform  :restore_tenant_context
    after_perform   :clear_tenant_context
    around_perform  :with_rls_context
  end

  private

  def capture_tenant_context
    # Store tenant_id in the job's arguments at enqueue time
    raise TenantNotSetError, "#{self.class} enqueued outside tenant context" unless Current.tenant?

    # We use a job-level instance variable; Sidekiq serializes via #serialize
    @tenant_id_for_job = Current.tenant.id
  end

  def restore_tenant_context
    tenant_id = arguments.last.is_a?(Hash) ? arguments.last[:_tenant_id] : nil
    tenant_id ||= @tenant_id_for_job

    raise TenantNotSetError, "No tenant_id found in job arguments" unless tenant_id

    Current.tenant = Tenant.find(tenant_id)
  end

  def clear_tenant_context
    Current.reset
  end

  def with_rls_context
    if Current.tenant?
      ActiveRecord::Base.connection.execute(
        ActiveRecord::Base.sanitize_sql(
          ["SELECT set_config('app.current_tenant_id', ?, false)",
           Current.tenant.id.to_s]
        )
      )
    end
    yield
  end
end
Enter fullscreen mode Exit fullscreen mode

For Sidekiq specifically, the cleanest approach is a middleware that injects and restores tenant context automatically, so you don't have to remember to include the concern on every job:

# config/initializers/sidekiq.rb

# Client middleware: inject tenant_id when the job is pushed to Redis
class SidekiqTenantClientMiddleware
  def call(_worker_class, job, _queue, _redis_pool)
    job["tenant_id"] = Current.tenant&.id
    yield
  end
end

# Server middleware: restore Current.tenant before the worker runs
class SidekiqTenantServerMiddleware
  def call(worker, job, queue)
    tenant_id = job["tenant_id"]

    if tenant_id
      Current.tenant = Tenant.find(tenant_id)

      ActiveRecord::Base.connection.execute(
        ActiveRecord::Base.sanitize_sql(
          ["SELECT set_config('app.current_tenant_id', ?, false)", tenant_id.to_s]
        )
      )
    end

    yield
  ensure
    Current.reset
  end
end

Sidekiq.configure_client do |config|
  config.client_middleware do |chain|
    chain.add SidekiqTenantClientMiddleware
  end
end

Sidekiq.configure_server do |config|
  config.client_middleware do |chain|
    chain.add SidekiqTenantClientMiddleware
  end

  config.server_middleware do |chain|
    chain.add SidekiqTenantServerMiddleware
  end
end
Enter fullscreen mode Exit fullscreen mode

Now every job automatically carries its tenant context. Workers restore it without any per-job boilerplate.


Step 7: Test Helpers That Enforce the Rules

If your test suite doesn't force tenant context, you'll write tests that pass but miss real bugs. Here's a helper module that makes tenant context explicit and prevents accidental unscoped queries in specs.

# spec/support/tenant_helpers.rb
module TenantHelpers
  # Sets Current.tenant for the duration of a block
  def with_tenant(tenant, &block)
    previous = Current.tenant
    Current.tenant = tenant
    block.call
  ensure
    Current.tenant = previous
  end

  # Creates a tenant and sets it as current for the example
  def acting_as_tenant(tenant = nil)
    tenant ||= create(:tenant)
    Current.tenant = tenant
    tenant
  end

  # Assert that a block does NOT execute any unscoped cross-tenant queries
  def expect_tenant_safe(&block)
    queries = []
    subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |_, _, _, _, payload|
      queries << payload[:sql] if payload[:sql].match?(/FROM "accounts"|FROM "orders"/)
    end

    block.call

    unscoped = queries.reject { |q| q.include?("tenant_id") }
    expect(unscoped).to be_empty,
      "Found #{unscoped.count} unscoped queries:\n#{unscoped.join("\n")}"
  ensure
    ActiveSupport::Notifications.unsubscribe(subscriber)
  end
end

RSpec.configure do |config|
  config.include TenantHelpers

  # Auto-reset Current between examples
  config.around(:each) do |example|
    Current.reset
    example.run
    Current.reset
  end
end
Enter fullscreen mode Exit fullscreen mode

Usage in specs:

# spec/models/account_spec.rb
RSpec.describe Account, type: :model do
  let(:tenant_a) { create(:tenant, subdomain: "alpha") }
  let(:tenant_b) { create(:tenant, subdomain: "beta") }

  describe "tenant isolation" do
    before do
      with_tenant(tenant_a) { create_list(:account, 3) }
      with_tenant(tenant_b) { create_list(:account, 2) }
    end

    it "only returns the current tenant's accounts" do
      with_tenant(tenant_a) do
        expect(Account.count).to eq(3)
      end
    end

    it "does not leak tenant_b records into tenant_a context" do
      with_tenant(tenant_a) do
        tenant_b_account = Account.unscoped.where(tenant: tenant_b).first
        expect { Account.find(tenant_b_account.id) }.to raise_error(ActiveRecord::RecordNotFound)
      end
    end

    it "raises when tenant context is absent" do
      Current.reset
      expect { Account.all.load }.to raise_error(TenantNotSetError)
    end
  end

  describe "cross-tenant query safety" do
    it "scopes all queries to the current tenant" do
      acting_as_tenant(tenant_a) do
        expect_tenant_safe { Account.where(name: "anything").to_a }
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Step 8: The Admin Escape Hatch

You'll need internal tooling — Sidekiq Web, an admin dashboard, a Rails console task — that operates without a tenant context. Do this explicitly, never by accident.

# app/models/concerns/tenant_scoped.rb (add to class_methods block)
def for_tenant(tenant)
  unscoped.where(tenant: tenant)
end

def across_all_tenants
  # Explicitly documents intent; cannot be called by accident
  raise ArgumentError, "You must acknowledge cross-tenant access" unless block_given?
  unscoped { yield }
end
Enter fullscreen mode Exit fullscreen mode
# Usage in a Rake task or admin controller
namespace :tenants do
  desc "Backfill a field across all tenants"
  task backfill_account_status: :environment do
    Tenant.find_each do |tenant|
      Account.for_tenant(tenant).find_each do |account|
        account.update_columns(status: "active") if account.status.nil?
      end
      puts "Done: #{tenant.subdomain}"
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Note the pattern: even in cross-tenant admin tasks, we still loop through tenants explicitly rather than doing a single Account.update_all. This keeps queries tenant-anchored, which is better for Postgres query plans on large datasets anyway.


Common Pitfalls and How to Catch Them

Joins That Escape the Scope

default_scope applies to the base model but not to eagerly-loaded associations by default. Test this explicitly:

# This is safe — tenant scoped on Account
Account.includes(:orders).where(name: "Acme")

# But what about Order? Does it have TenantScoped too?
# If yes, the included records are separately scoped. Good.
# If no, you're loading all orders for the matching accounts across all tenants. Bad.
Enter fullscreen mode Exit fullscreen mode

Every model in a has_many or belongs_to relationship should include TenantScoped if it's tenant data. Don't let associations be the escape valve.

Cached Queries in Mailers

ActionMailer runs outside a request context in production (delivered via background job). Make sure your mailers go through the same Sidekiq middleware path and have Current.tenant set before rendering:

class AccountMailer < ApplicationMailer
  def welcome(account_id)
    # Don't pass the object — pass the ID and reload in the mailer
    # This forces the query to run inside the correct tenant context
    @account = Account.find(account_id)
    mail(to: @account.email, subject: "Welcome")
  end
end
Enter fullscreen mode Exit fullscreen mode

Fixtures and Seeds With tenant_id

If you use db/seeds.rb or fixtures, they run outside tenant context. Either set Current.tenant explicitly in the seed file, or use Account.unscoped.create! with explicit tenant_id:

# db/seeds.rb
alpha = Tenant.create!(name: "Alpha Corp", subdomain: "alpha")
beta  = Tenant.create!(name: "Beta LLC",   subdomain: "beta")

Current.tenant = alpha
Account.create!(name: "Alice", email: "alice@alpha.com")

Current.tenant = beta
Account.create!(name: "Bob", email: "bob@beta.com")

Current.reset
Enter fullscreen mode Exit fullscreen mode

What You Now Have

Let's audit the threat model:

Threat Mitigation
Developer forgets to scope a query default_scope on every model via TenantScoped
Scope is bypassed via unscoped RLS policy in Postgres catches it
Background job runs without context Sidekiq middleware serializes and restores tenant_id
Cross-tenant association load Every associated model includes TenantScoped
Test suite masks real bugs expect_tenant_safe helper + Current.reset around each spec
Admin task accidentally touches all tenants Explicit for_tenant / across_all_tenants API

This isn't magic — it's conventions, enforced at multiple layers. The value is that no single layer needs to be perfect. If the application scope slips, RLS catches it. If RLS is misconfigured for a table, the model validation catches it on write. Defense-in-depth, each layer honest about what it does.


Where to Go Next

  • Schema-per-tenant: If your tenants need true schema isolation (compliance reasons, not just data volume), look at apartment or roll your own search_path switcher. The tradeoffs are well documented; schema-per-tenant is significantly more operationally complex.
  • Tenant provisioning: Automating CREATE POLICY for new tables as your schema evolves. A custom migration generator that adds the RLS boilerplate helps.
  • Query performance at scale: At 10k+ tenants, your tenant_id index cardinality affects query plans. Consider BRIN indexes for time-series tenant data and composite indexes that put tenant_id first on any multi-column lookup.
  • Audit logging: Every mutation should record tenant_id, user_id, and the previous value. PaperTrail's controller_info hook is the cleanest place to attach tenant context to the audit trail.

The system above will hold through 99% of what production throws at it. The other 1% is where you earn your scars — and now at least you'll know exactly which layer to look at first.

Top comments (0)