Rails 8 Middleware: Custom Stacks Guide

Every Rails request passes through a stack of middleware before reaching the application. This architecture provides an elegant way to handle cross-cutting concerns—logging, authentication headers, request timing, and response modification—without cluttering controllers.

Understanding the Middleware Stack

Middleware wraps around the Rails application like layers of an onion. Each piece can inspect or modify the request on the way in and the response on the way out. Rails 8 ships with approximately 20 middleware components handling everything from cookies to caching.

To see the current middleware stack:

# Terminal
bin/rails middleware

# Output (abbreviated):
# use ActionDispatch::HostAuthorization
# use Rack::Sendfile
# use ActionDispatch::Executor
# use ActionDispatch::Cookies
# use ActionDispatch::Session::CookieStore
# run MyApp::Application.routes

The stack executes top-to-bottom for requests and bottom-to-top for responses. This bidirectional flow enables powerful patterns like request timing that starts a clock before processing and logs the duration after.

Building a Request Timer Middleware

Performance monitoring often requires timing every request. Rather than adding instrumentation to every controller, middleware handles this in one place:

# lib/middleware/request_timer.rb
module Middleware
  class RequestTimer
    def initialize(app)
      @app = app
    end

    def call(env)
      start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      
      status, headers, response = @app.call(env)
      
      duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
      duration_ms = (duration * 1000).round(2)
      
      headers["X-Request-Time-Ms"] = duration_ms.to_s
      
      Rails.logger.info "[RequestTimer] #{env['REQUEST_METHOD']} #{env['PATH_INFO']} completed in #{duration_ms}ms"
      
      [status, headers, response]
    end
  end
end

The middleware receives the next application in the stack via initialize. The call method must return a Rack-compliant triplet: status code, headers hash, and response body. This pattern enables wrapping the downstream call with timing logic.

Register the middleware in the application configuration:

# config/application.rb
require_relative "../lib/middleware/request_timer"

module MyApp
  class Application < Rails::Application
    config.load_defaults 8.0
    
    config.middleware.use Middleware::RequestTimer
  end
end

API Key Authentication Middleware

API authentication often needs to happen before any controller code runs. Middleware provides an efficient place to reject unauthorized requests early:

# lib/middleware/api_key_authenticator.rb
module Middleware
  class ApiKeyAuthenticator
    PROTECTED_PATHS = %r{\A/api/}
    
    def initialize(app)
      @app = app
    end

    def call(env)
      request = Rack::Request.new(env)
      
      return @app.call(env) unless protected_path?(request.path)
      
      api_key = extract_api_key(env)
      
      if api_key.blank?
        return unauthorized_response("Missing API key")
      end
      
      account = Account.find_by(api_key: api_key)
      
      if account.nil?
        return unauthorized_response("Invalid API key")
      end
      
      if account.api_access_suspended?
        return forbidden_response("API access suspended")
      end
      
      env["api.current_account"] = account
      
      @app.call(env)
    end

    private

    def protected_path?(path)
      path.match?(PROTECTED_PATHS)
    end

    def extract_api_key(env)
      env["HTTP_X_API_KEY"] || Rack::Request.new(env).params["api_key"]
    end

    def unauthorized_response(message)
      body = { error: message }.to_json
      [
        401,
        { "Content-Type" => "application/json", "Content-Length" => body.bytesize.to_s },
        [body]
      ]
    end

    def forbidden_response(message)
      body = { error: message }.to_json
      [
        403,
        { "Content-Type" => "application/json", "Content-Length" => body.bytesize.to_s },
        [body]
      ]
    end
  end
end

Setting values in the env hash makes them available to controllers via request.env["api.current_account"]. This pattern separates authentication concerns from business logic entirely.

Response Compression for JSON APIs

Large JSON responses benefit from compression. While Rails includes Rack::Deflater, a custom middleware provides fine-grained control over what gets compressed:

# lib/middleware/json_compressor.rb
require "zlib"

module Middleware
  class JsonCompressor
    MIN_COMPRESS_SIZE = 1024 # Only compress responses larger than 1KB
    
    def initialize(app)
      @app = app
    end

    def call(env)
      status, headers, response = @app.call(env)
      
      return [status, headers, response] unless should_compress?(env, headers, response)
      
      body = extract_body(response)
      compressed = Zlib::Deflate.deflate(body, Zlib::BEST_COMPRESSION)
      
      headers["Content-Encoding"] = "deflate"
      headers["Content-Length"] = compressed.bytesize.to_s
      headers["Vary"] = "Accept-Encoding"
      
      [status, headers, [compressed]]
    end

    private

    def should_compress?(env, headers, response)
      return false unless env["HTTP_ACCEPT_ENCODING"]&.include?("deflate")
      return false unless headers["Content-Type"]&.include?("application/json")
      
      body = extract_body(response)
      body.bytesize >= MIN_COMPRESS_SIZE
    end

    def extract_body(response)
      body = ""
      response.each { |chunk| body << chunk }
      body
    end
  end
end

Controlling Middleware Order

Middleware order matters. Authentication should run before logging captures user context. Insert middleware at specific positions using these methods:

# config/application.rb

# Add after a specific middleware
config.middleware.insert_after ActionDispatch::Cookies, Middleware::ApiKeyAuthenticator

# Add before a specific middleware  
config.middleware.insert_before Rack::Head, Middleware::RequestTimer

# Add at the end of the stack
config.middleware.use Middleware::JsonCompressor

# Remove middleware entirely
config.middleware.delete ActionDispatch::HostAuthorization

# Swap one middleware for another
config.middleware.swap Rack::Runtime, Middleware::RequestTimer

Environment-Specific Middleware

Some middleware belongs only in certain environments. Development might need verbose logging while production needs compression:

# config/environments/development.rb
Rails.application.configure do
  config.middleware.use Middleware::VerboseRequestLogger
end

# config/environments/production.rb
Rails.application.configure do
  config.middleware.use Middleware::JsonCompressor
  config.middleware.insert_before Rack::Head, Middleware::RequestTimer
end

Testing Middleware

Middleware deserves dedicated tests. Test the middleware in isolation by creating a simple Rack app as the downstream:

# spec/middleware/api_key_authenticator_spec.rb
require "rails_helper"
require "middleware/api_key_authenticator"

RSpec.describe Middleware::ApiKeyAuthenticator do
  let(:inner_app) { ->(env) { [200, { "Content-Type" => "text/plain" }, ["OK"]] } }
  let(:middleware) { described_class.new(inner_app) }
  
  def env_for(path, headers = {})
    Rack::MockRequest.env_for(path, headers)
  end

  context "when accessing non-API paths" do
    it "passes through without authentication" do
      status, _, _ = middleware.call(env_for("/users"))
      expect(status).to eq(200)
    end
  end

  context "when accessing API paths without key" do
    it "returns 401 unauthorized" do
      status, _, body = middleware.call(env_for("/api/users"))
      expect(status).to eq(401)
      expect(body.first).to include("Missing API key")
    end
  end

  context "when accessing API paths with valid key" do
    let!(:account) { Account.create!(api_key: "valid-key-123") }

    it "passes through and sets current account" do
      env = env_for("/api/users", "HTTP_X_API_KEY" => "valid-key-123")
      status, _, _ = middleware.call(env)
      
      expect(status).to eq(200)
      expect(env["api.current_account"]).to eq(account)
    end
  end
end

Summary

Custom middleware handles cross-cutting concerns elegantly. The pattern works well for request timing, authentication, compression, and any logic that applies across multiple endpoints. Keep middleware focused on a single responsibility, test in isolation, and consider the stack order carefully. For request-specific logic that varies by controller, stick with traditional before_action callbacks instead.

10 claps
← Back to Blog