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.routesThe 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
endThe 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
endAPI 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
endSetting 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
endControlling 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::RequestTimerEnvironment-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
endTesting 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
endSummary
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.