 
Middleware in Rails
A typical scenario in the Rails world, after spending some time using it and playing with forms and requests, you realize that not everything is magic, there is some code that is in charge of cleaning things up so that you get in your controller the params, headers, and other request data that you need.
That’s where Rack comes in. Rack is the code that lives between the layers, from the moment the request starts until it reaches your controller. But it’s not just about input, the output works the same way. When you return something from your controller, Rack is there too.
In this post, we’ll cover a few examples where understanding how middleware works can help you solve real-life problems.
Middleware in Rails 101
The middleware system used by Rails is called Rack[1].
Rack provides a minimal, modular, and adaptable interface for developing web applications in Ruby. By wrapping HTTP requests and responses in the simplest way possible, it unifies and distills the API for web servers, web frameworks, and software in between (the so-called middleware) into a single method call.
In other terms, Rack defines a simple interface for all the middlewares, which they must implement in order to handle HTTP requests and responses. This interface lets each layer receive the request as a Ruby object, process or modify it, and then pass it along the stack. Middleware usually lives in the framework itself, acting as a bridge between the web server and your application code, enabling these components to communicate and transform requests and responses in a consistent way.
It’s important to note that a Rails application can include several middleware components. Each middleware receives the environment from the previous one (if present), can inspect or change it, and then passes the updated environment along to the next middleware in the stack.
A few input examples:
Session Example:
Did you know that popular gems like Devise (for authentication) and OmniAuth (for OAuth) use Rack middleware behind the scenes? They integrate with the request/response cycle to manage user sessions, redirects, and authentication flows before your Rails controllers process the request.
For example, with OmniAuth, you add its middleware to your Rails application like this:
# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
  provider :github, ENV['GITHUB_KEY'], ENV['GITHUB_SECRET']
end
This means every request goes through OmniAuth’s middleware before reaching your app’s logic.
OmniAuth Custom Middleware Creation:
OmniAuth defines its middleware by doing:
module OmniAuth
  class Builder < ::Rack::Builder
    # ...OmniAuth logic...
  end
end
Notice that it inherits from ::Rack::Builder. After this, inside the class, you’ll find the provider method, which is the one that let’s you add providers (like GitHub, Google, etc.) as shown above:
def provider(klass, *args, &block)
  use klass, *args, &block
end
This method adds the provider’s middleware to the stack, so every request goes through it. You can check out the full implementation here[2].
Basically, what we did is adding the provider middleware, so now, every incoming request passes through it before reaching your Rails controllers. The middleware inspects the request to determine if it matches an OmniAuth authentication path(like /auth/github). If it does, the middleware takes over and handles the authentication flow, redirects the user to the provider (in this case GitHub), and processes the callback. Only after this process is complete, and the user is
authenticated, the request continue down the stack to your application, where controllers like SessionsController can access the authentication data set by OmniAuth. This is how middleware acts as a guardian, ensuring authentication logic runs before your app’s business logic.
Sinatra Uses Middleware Too
Sinatra[3], just like Rails, uses Rack middleware under the hood. If you look at the Sinatra source code, you’ll see it adds middleware like Rack::MethodOverride and Rack::Head to every app by default. (See sinatra/main.rb, line 55 )
For example, Sinatra adds Rack::MethodOverride[4] so you can use HTTP verbs like PUT and DELETE in your routes, even though standard HTML forms only support POST:
def call(env)
  if allowed_methods.include?(env[REQUEST_METHOD])
    method = method_override(env)
    if HTTP_METHODS.include?(method)
      env[RACK_METHODOVERRIDE_ORIGINAL_METHOD] = env[REQUEST_METHOD]
      env[REQUEST_METHOD] = method
    end
  end
end
This means you can send a POST request with a hidden _method parameter, and Rack will treat it as a PUT or DELETE request. Middleware like this is quietly working for you in the background!
Output examples
Middleware isn’t just for handling incoming requests—it can also help you manage what happens after your app processes a request, or when you interact with external services.
Here’s a real-world example. A few years ago, we were doing API calls to Twitter API from multiple worker servers, all them at the same time, with a lot of concurrent requests per second.
The main issue was that the API enforced strict rate limits, so we needed to find a way to prevent our application from exceeding those limits. If we continued making requests after hitting the limit, we risked being temporarily or permanently blocked by the API provider. To address this, we had to implement a mechanism that would monitor our request count and automatically pause or throttle outgoing requests as we approached the limit.
We solved it by adding a custom middleware. Before making a request to the API, we updated a counter in Redis to track how many requests we’d already made. Let’s see it:
class TwitterRateLimit
  attr_reader :env
  def initialize(app, options = {})
    @app = app
    @options = options
    @logger = Logger.new('log/twitter_rate_limit.log')
  end
  def call(env)
    # This code checks if the request is allowed by the token pool and raises an error if the rate limit is exceeded.
    if allowed_by_pool?
      log_rate_limit(@current_count, @new_token.token, @new_token.seconds_until_reset)
    else
      raise InternalTwitterRateLimitError
    end
    log_request(env)
    @app.call(env)
  end
  def log_rate_limit req_count, token, reset_time
    log "#{Time.now} - API #{api_type} - #{req_count} requests remaining - Resets in #{reset_time} secs - Token: #{token}"
  end
  #...
The scenario was even more complex because we had to handle multiple rate limits for the same API, including global limits and per-user limits. By carefully tracking these limits and coordinating requests across our workers, we were able to avoid being banned by Twitter and similar services.
In conclusion
Middleware is one of those things that’s always working in the background of your Rails (and Sinatra) apps, even if you don’t notice it. Understanding how it works can help you debug tricky issues, add custom features, or just appreciate how much is happening before your code even runs.
Next time that you are using Devise, OmniAuth, or just building your own API, knowing a bit about Rack and middleware gives you more control and confidence as a Ruby developer.
Looking to enhance your Rails app, implement custom middleware, or need support with scaling and maintaining Ruby projects? Contact us .
References
- 1: https://guides.rubyonrails.org/rails_on_rack.html “Rack”
- 2: https://github.com/omniauth/omniauth/blob/master/lib/omniauth/builder.rb “OmniAuth::Builder class”
- 3: https://github.com/sinatra/sinatra “Sinatra”
- 4: https://github.com/rack/rack/blob/main/lib/rack/method_override.rb “Rack::MethodOverride”