ruby 104 lines · 4 tabs

Multi-Tenant Subdomain Isolation with Rack Middleware and Thread-Local Scoping

Shared by codesnips Sep 2026
4 tabs
class TenantResolver
  RESERVED = %w[www app admin].freeze

  def initialize(app)
    @app = app
  end

  def call(env)
    request = Rack::Request.new(env)
    sub = subdomain_from(request.host)

    if sub.blank? || RESERVED.include?(sub)
      return not_found
    end

    tenant = Tenant.find_by(subdomain: sub)
    return not_found unless tenant

    begin
      Tenant.current = tenant
      @app.call(env)
    ensure
      Tenant.current = nil
    end
  end

  private

  def subdomain_from(host)
    return if host.blank?
    parts = host.split(".")
    parts.length > 2 ? parts.first : nil
  end

  def not_found
    [404, { "Content-Type" => "text/plain" }, ["Tenant not found"]]
  end
end
4 files · ruby Explain with highlit

This snippet shows a classic SaaS pattern: isolating tenants by subdomain so that every query is automatically scoped to the current account without threading a tenant_id through every call site. The work is split across a Rack middleware that resolves the tenant, a thread-local Current holder, and a model concern that reads that holder inside a default scope.

In TenantResolver middleware, the request is intercepted before it reaches Rails' router. The middleware pulls the host from the Rack env, extracts the leading subdomain via subdomain_from, and looks up the matching Tenant. Because middleware runs on the request-serving thread, the tenant is stashed in a thread-local through Tenant.current=, and crucially reset in an ensure block so a leaked value can never bleed into the next request served by that same pooled thread — the most dangerous bug in this whole pattern. Unknown or reserved subdomains (www, app) short-circuit with a 404 rather than silently serving another tenant's data.

Current tenant store wraps ActiveSupport::CurrentAttributes, which gives a per-request, per-thread store that Rails automatically clears between requests as a second line of defense. The Tenant class exposes current and with helpers; with is handy for background jobs or tests where no HTTP request established the context.

TenantScoped concern is where the payoff lands. Any model that includes it gains a default_scope that filters by Tenant.current.id, and a before_validation hook that stamps tenant_id on new records. This means ordinary calls like Invoice.all or Invoice.create! are transparently confined to the active tenant.

The trade-off worth understanding: default_scope is convenient but sticky — it applies to every relation including associations, so admin tooling that must cross tenants has to call unscoped explicitly. The require_tenant! guard raises when no tenant is set, turning a silent global query into a loud failure. This design keeps tenant logic in one place, but demands discipline around jobs, seeds, and any code path that runs outside a request.


Related snips

Share this code

Here's the card — post it anywhere.

Multi-Tenant Subdomain Isolation with Rack Middleware and Thread-Local Scoping — share card
Link copied