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
ApplicationRecordmixin that enforces tenant scoping at the model layer - Postgres row-level security policies as a hard backstop
- A
TenantSafeconcern 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:
- A developer forgets to scope a query → data leak
- A background job runs without tenant context → unscoped query touches all tenants
- 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
# 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
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
# app/errors/tenant_not_set_error.rb
class TenantNotSetError < StandardError; end
class TenantNotFoundError < StandardError; end
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
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
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
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
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
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
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
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
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
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
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
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
# 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
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.
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
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
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
apartmentor roll your ownsearch_pathswitcher. The tradeoffs are well documented; schema-per-tenant is significantly more operationally complex. -
Tenant provisioning: Automating
CREATE POLICYfor 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_idindex cardinality affects query plans. ConsiderBRINindexes for time-series tenant data and composite indexes that puttenant_idfirst on any multi-column lookup. -
Audit logging: Every mutation should record
tenant_id,user_id, and the previous value. PaperTrail'scontroller_infohook 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)