Rails Middleware Stack Demystified

How Rack middleware actually runs inside Rails, and the request-ID, IP allowlist, and tenant-header middlewares I shipped at the creator platform when controllers were the wrong layer.

Tuesday morning at the creator-economy platform I was on, Aurora reader lag tripped. Community feeds went from a 120 ms p99 to past 8 seconds inside four minutes. I joined the war room and the first thing I asked for was a Datadog query filtered by request ID for one of the slow reads. Nobody had it. Our request IDs were generated by the controller, after Rack had already chewed through five other middlewares. Half the latency I cared about happened before my code ran.

That morning is why I’m opinionated about the Rails middleware stack. Honestly, the controller is too late for most cross-cutting work. Rack is the right place. Rails ships a solid set of middlewares and yet, in my experience, most engineers I’ve worked with, on Rails monoliths anywhere, have no idea what’s actually in Rails.application.middleware or in what order.

What Rack actually runs

Every request that hits a Rails app walks through an ordered list of Rack apps before it ever gets near ActionDispatch::Routing::RouteSet. You can see the exact order with bin/rails middleware. On a stock Rails 7 monolith you get something close to this near the top:

use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use Rack::MethodOverride
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use ActionDispatch::DebugExceptions
use ActionDispatch::Callbacks
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag

Each one is a class that responds to call(env) and returns [status, headers, body]. They wrap the next middleware like Russian dolls. By the time your controller action runs, request ID has been set, exceptions have a handler, the session has been parsed, cookies are decoded, and the response body has an ETag plan waiting. The order is not decorative. If you put your tenant-resolution middleware after ActionDispatch::Cookies but you also need session data, that works. If you put it before, you get a NoMethodError on request.session.

The other thing worth remembering is that the middleware stack is the same Rack stack Sinatra uses. You can use any Rack-compatible gem. You’re not in Rails-only land.

When middleware beats a controller filter

I default to a middleware over a before_action filter when any of three things is true. One, the work needs to run before routing decides which controller fires. Two, it touches every request including assets, health checks, and error responses. Three, it needs to short-circuit the response without rendering a view.

Three real cases from the creator platform, in order of how often the pattern paid off.

Request IDs that survive a fan-out

Rails ships ActionDispatch::RequestId. It accepts an inbound X-Request-Id header and falls back to generating one. Our problem: the edge layer was Cloudflare and we wanted to keep the Cloudflare cf-ray correlation alongside our internal ID, plus inject both into every Sidekiq job spawned from the request, plus tag Datadog APM spans with both. The default isn’t enough, and a controller can’t fix it because by the time the controller runs, Datadog has already opened the request span.

# config/initializers/middleware.rb
Rails.application.config.middleware.insert_before(
  Rails::Rack::Logger,
  "Platform::CorrelationIds"
)
# lib/platform/correlation_ids.rb
module Platform
  class CorrelationIds
    HEADER_REQUEST_ID = "HTTP_X_REQUEST_ID"
    HEADER_CF_RAY     = "HTTP_CF_RAY"

    def initialize(app)
      @app = app
    end

    def call(env)
      request_id = env[HEADER_REQUEST_ID].presence || SecureRandom.uuid
      cf_ray     = env[HEADER_CF_RAY]

      env["action_dispatch.request_id"] = request_id
      env["platform.cf_ray"] = cf_ray

      Datadog::Tracing.active_span&.set_tag("request.id", request_id)
      Datadog::Tracing.active_span&.set_tag("cf.ray", cf_ray) if cf_ray

      Sidekiq::Context.add(request_id: request_id, cf_ray: cf_ray)

      status, headers, body = @app.call(env)
      headers["X-Request-Id"] = request_id
      [status, headers, body]
    end
  end
end

Two days after this shipped, the reader-lag thing came back as an unrelated alert. This time I pasted one request ID into the Datadog log explorer and watched the whole story unfold across Rails, Sidekiq, and the maintenance cron that had run ANALYZE on community_posts and starved WAL emission. The runbook now opens with a sentence about checking pg_stat_activity on the writer before touching reader scaling. That sentence is in there because of me, yeah. But the request ID middleware is the reason we found the cause in six minutes instead of twenty.

IP allowlisting that doesn’t crowd the controller

Internal admin endpoints behind a stable CIDR. You can do it with a before_action, but then every controller has to remember. Worse, you’ve already paid for routing, cookies, session, and any expensive callbacks before you decide to 403.

# lib/platform/admin_ip_guard.rb
module Platform
  class AdminIpGuard
    ALLOWED = ENV.fetch("ADMIN_CIDRS", "10.0.0.0/8").split(",").map { |c| IPAddr.new(c) }

    def initialize(app, path_prefix:)
      @app = app
      @prefix = path_prefix
    end

    def call(env)
      return @app.call(env) unless env["PATH_INFO"].start_with?(@prefix)

      ip = IPAddr.new(env["action_dispatch.remote_ip"].to_s)
      return @app.call(env) if ALLOWED.any? { |net| net.include?(ip) }

      [403, { "Content-Type" => "text/plain" }, ["forbidden"]]
    rescue IPAddr::InvalidAddressError
      [400, { "Content-Type" => "text/plain" }, ["bad ip"]]
    end
  end
end
Rails.application.config.middleware.insert_after(
  ActionDispatch::RemoteIp,
  Platform::AdminIpGuard,
  path_prefix: "/admin"
)

insert_after(ActionDispatch::RemoteIp, ...) is the load-bearing line. RemoteIp is what unspoofs X-Forwarded-For against trusted proxies. Insert before it and you’re trusting whatever the last hop wrote into the header. I have watched a junior engineer learn this the hard way in a code review. They didn’t ship it.

Tenant header extraction at the edge

At the combat-sports tournament platform I CTO’d in London, we ran a federations product with many tenants, public-facing rankings, member portals, all that. Tenant lived in a subdomain and sometimes in a header for our API clients. We put the resolution in middleware so that by the time any controller, any Sidekiq context propagation, any audit log fired, the tenant was already in env.

def call(env)
  tenant_key = extract_tenant(env)
  return [404, { "Content-Type" => "text/plain" }, ["unknown tenant"]] unless tenant_key

  Current.tenant = Tenant.cache_lookup(tenant_key)
  Sidekiq::Context.add(tenant: tenant_key)
  @app.call(env)
ensure
  Current.tenant = nil
end

Current is ActiveSupport::CurrentAttributes. The ensure block matters. Forget it and the next request on a reused thread inherits the last tenant. We learned that in staging. Didn’t ship to production. Close though.

The war story that wrote the idempotency middleware

Native billing for the branded-mobile-app pipeline at the creator platform. Apple’s SubscriptionRenewal server-to-server notification has a 30 second deadline. Past that, Apple retries hard. Our webhook handler did receipt validation and the creator_subscriptions insert inline. Sometimes it took 31 seconds. Apple retried, the retry hit a different worker, and the handler had no idempotency check. A bunch of customers across dozens of branded apps got charged twice that month. The first fix was a frontend “show only the latest subscription” patch. It hid the row. Apple had already moved real money.

The real fix had two parts. A Sidekiq job split out so the controller returned 200 inside 5 seconds. And a Rack middleware that deduped retries on (apple_original_transaction_id, notification_uuid) before the request even reached the controller.

# lib/platform/idempotent_webhooks.rb
module Platform
  class IdempotentWebhooks
    PATHS = ["/webhooks/apple/subscription_renewal"].freeze

    def call(env)
      return @app.call(env) unless PATHS.include?(env["PATH_INFO"])

      key = env["HTTP_X_IDEMPOTENCY_KEY"] || derive_key(env)
      return cached_response(key) if Rails.cache.exist?("webhook:#{key}")

      status, headers, body = @app.call(env)
      Rails.cache.write("webhook:#{key}", { status: status }, expires_in: 24.hours) if status < 400
      [status, headers, body]
    end
  end
end

The unique database constraint stayed. Defense in depth. But the middleware caught the retries before they spent any controller cycles, and the queue drained.

Takeaways

  • bin/rails middleware is the first command to run when you join a Rails repo. Know what’s in the stack.
  • Reach for middleware when work runs before routing, touches every request, or short-circuits a response.
  • Insert position is load-bearing. insert_before and insert_after are not interchangeable.
  • Correlation IDs belong in middleware. Anything that wants them, gets them, including Sidekiq and the APM.
  • Idempotency for retrying upstreams (Apple, Stripe, Google) is a middleware job. Controllers are too late.
  • Always ensure cleanup on Current attributes. Threads get reused.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.