How to build a Rails API with rate limiting

How secure is your API? Learn how to build an API with Ruby on Rails and use rack-attack to keep the bad actors out.

APIs are the bread and butter of the internet. The ability to interact with our applications programmatically enables interoperability and makes our lives as developers easier. Unfortunately, web applications are vulnerable to malicious actors that seek to misuse them or degrade their performance, which is why rate limiting is an important part of any API. This guide will walk you through using Ruby on Rails to make an API application that manages an animal shelter, and we'll also integrate rate limiting with rack-attack! We'll keep track of cats, dogs, and volunteers through a JSON interface.

You can follow along with the example app we create and even see the source of the final app here on Github. Let's get started!

Installing Ruby

You'll need to install Ruby if you don't already have Ruby installed. In this tutorial, I'll be using Ruby 3.2. If you're starting without a Ruby install, it's wise to use a Ruby version manager like rbenv. You can install rbenv with Homebrew:

brew install rbenv

Next, use rbenv to install Ruby 3.2.0:

rbenv install 3.2.0

Then, set rbenv to use this version of Ruby for your current directory:

rbenv local 3.2.0

Installing Rails

Now that you have Ruby installed, it's time to install Rails. In this tutorial, we'll use Rails 7.1.1. You can install it by running:

gem install rails -v 7.1.1

Generating a new Rails project

Rails is a full-stack framework, but you can omit all the front-end things when generating a project to generate an API. This is useful if you know you only need to serve JSON, like if you'll be integrating with a mobile app, a SPA frontend, or even another service. Generate a new Rails 7.1.1 api with:

rails _7.1.1_ new animal-shelter --api

Then, change into the directory of the new application, in this case, animal-shelter.

For this example, we'll build an application to help an animal shelter operate more efficiently. We'll make API endpoints for cats, dogs, and volunteers. This isn't especially useful on its own, but it lays the groundwork for more business logic later. If you'd like to call your application something different, substitute your name for animal-shelter in the command above.

Using the Rails scaffolds to build out endpoints for tracking cats

First, we'll generate the files to create, read, update, and delete cats. To do this, we'll use Rails' built-in scaffolding, which will generate models, controllers, and even a migration file. Inside your application, run:

rails generate scaffold Cat name:string arrival_date:datetime description:text --api

This creates a model, a controller, several routes, a database migration, and some test files.

Next, run the pending database migration that was just created:

rails db:migrate

Inspecting the cats code

If you go to app/controllers/cats_controller.rb, you'll see that the file has been prepopulated with a number of methods. These methods, without any edits at all, allow us to make HTTP requests to the application to create, read, update, and delete the cat resource. Here's the cats controller:

class CatsController < ApplicationController
  before_action :set_cat, only: %i[ show update destroy ]

  # GET /cats
  def index
    @cats = Cat.all

    render json: @cats
  end

  # GET /cats/1
  def show
    render json: @cat
  end

  # POST /cats
  def create
    @cat = Cat.new(cat_params)

    if @cat.save
      render json: @cat, status: :created, location: @cat
    else
      render json: @cat.errors, status: :unprocessable_entity
    end
  end

  # PATCH/PUT /cats/1
  def update
    if @cat.update(cat_params)
      render json: @cat
    else
      render json: @cat.errors, status: :unprocessable_entity
    end
  end

  # DELETE /cats/1
  def destroy
    @cat.destroy!
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_cat
      @cat = Cat.find(params[:id])
    end

    # Only allow a list of trusted parameters through.
    def cat_params
      params.require(:cat).permit(:name, :arrival_date, :description)
    end
end

Testing out the cats code

You can validate that the code works by using curl or, even easier, an API tool like Postman. Making a POST request to localhost:3000/cats with the following body will let you create a cat in the database:

{
    "name": "Bear",
    "description": "Bear is aloof but loving. She prefers not to be picked up, but in her own time will join happily you for a cuddle.",
    "arrival_date": "2024-01-28 14:11:09.818508"
}

This will create a new row in the cats table of the database and return a 201 created HTTP status with the created object in the response:

{
  "id": 1,
  "name": "Bear",
  "arrival_date": "2024-01-28T14:11:09.818Z",
  "description": "Bear is aloof but loving. She prefers not to be picked up, but in her own time will join happily you for a cuddle.",
  "created_at": "2024-01-28T19:13:51.047Z",
  "updated_at": "2024-01-28T19:13:51.047Z"
}

Manually building endpoints for tracking dogs

Using Rails' generators is incredibly convenient, and you have the opportunity to edit the generated files. Still, it's not great for learning purposes. We'll build a set of endpoints for CRUD operations on a 'dog' resource next, this time without the generators.

Creating a model

First, we'll create the model file. In app/models, create a new file called dog.rb. The file doesn't need a lot, just a class name and the class it should inherit from:

class Dog < ApplicationRecord
end

Inheriting from ApplicationRecord tells Rails that this is a model and provides the class with a set of methods that it will need. Next, we'll create the necessary table in the database to persist records of dogs. We'll first create a new migration by running:

Creating a new database table

rails generate migration CreateDogs

This creates a new timestamped file in the db/migrations directory. In that file, you'll see a change method, which will be executed when the migration is run. The change method already creates a new table with the right name but is missing the rows we want. Change the migration to this:

class CreateDogs < ActiveRecord::Migration[7.1]
  def change
    create_table :dogs do |t|
      t.string :name
      t.datetime :arrival_date
      t.text :description

      t.timestamps
    end
  end
end

Next, run the migration with:

rails db:migrate

Creating routes for the dog resource

Next, we must create routes telling the application where to send incoming web requests. These are defined in config/routes.rb with a DSL that makes this convenient. Inside the code block (before the final end), add this line:

resources :dogs

Writing the DogsController

Finally, we'll write out the DogsController, which will handle CRUD actions for the dog resource. Create a new file in app/controllers/ called dogs_controller.rb There are a few different ways to write the code as long as you contain write the appropriate public methods, but you can speed things up by copying the CatsController and replacing every cat with dog:

class DogsController < ApplicationController
  before_action :set_dog, only: %i[ show update destroy ]

  # GET /dogs
  def index
    @dogs = Dog.all

    render json: @dogs
  end

  # GET /dogs/1
  def show
    render json: @dog
  end

  # POST /dogs
  def create
    @dog = Dog.new(dog_params)

    if @dog.save
      render json: @dog, status: :created, location: @dog
    else
      render json: @dog.errors, status: :unprocessable_entity
    end
  end

  # PATCH/PUT /dogs/1
  def update
    if @dog.update(dog_params)
      render json: @dog
    else
      render json: @dog.errors, status: :unprocessable_entity
    end
  end

  # DELETE /dogs/1
  def destroy
    @dog.destroy!
  end

  private
  # Use callbacks to share common setup or constraints between actions.
  def set_dog
    @dog = Dog.find(params[:id])
  end

  # Only allow a list of trusted parameters through.
  def dog_params
    params.require(:dog).permit(:name, :arrival_date, :description)
  end
end

Testing the DogsController

Finally, you can create, read, update, and delete dogs via the API just like you can for cats! Restart your rails server with rails s, then you can use Postman to create a new dog like this:

A screenshot of creating a dog with the API via Postman A screenshot of creating a dog with the API via Postman

Using scaffolds to build endpoints for tracking volunteers

Lastly, we'll use the scaffold again to create endpoints for tracking volunteers. To start, run:

rails generate scaffold Volunteer name:string cumulative_hours:integer --api

Next, run the newly generated migration with:

rails db:migrate

This gives us everything we need for volunteers!

Integrating rack-attack for rate limiting

Now that we have a working Rails API with three different resources, we have a functional application for which we can add rate limiting! Rate limiting helps protect your application against malicious actors. Put simply, it throttles the amount of requests that someone can make to the API in a given time period. This adds a layer of protection to mitigate scraping, DoS, DDoS, and brute force attacks.

We can use a popular Ruby Gem called rack-attack to add rate limiting to this API. First, add the gem to the Gemfile:

gem 'rack-attack'

Next, install it with:

bundle install

Next, we'll configure rack-attack. Create a new file in config/initializers called rack-attack.rb. In that configuration, we'll add the setup from rack's documentation. We'll strip out all of the configuration except that which throttles requests by IP. Your config should look like this:

class Rack::Attack

  ### Configure Cache ###

  # If you don't want to use Rails.cache (Rack::Attack's default), then
  # configure it here.
  #
  # Note: The store is only used for throttling (not blocklisting and
  # safelisting). It must implement .increment and .write like
  # ActiveSupport::Cache::Store

  # Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new

  ### Throttle Spammy Clients ###

  # If any single client IP is making tons of requests, then they're
  # probably malicious or a poorly-configured scraper. Either way, they
  # don't deserve to hog all of the app server's CPU. Cut them off!
  #
  # Note: If you're serving assets through rack, those requests may be
  # counted by rack-attack, and this throttle may be activated too
  # quickly. If so, enable the condition to exclude them from tracking.

  # Throttle all requests by IP (60rpm)
  #
  # Key: "rack::attack:#{Time.now.to_i/:period}:req/ip:#{req.ip}"
  throttle('req/ip', limit: 300, period: 5.minutes) do |req|
    req.ip # unless req.path.start_with?('/assets')
  end


  ### Custom Throttle Response ###

  # By default, Rack::Attack returns an HTTP 429 for throttled responses,
  # which is just fine.
  #
  # If you want to return 503 so the attacker might be fooled into
  # believing that they've successfully broken your app (or you just want to
  # customize the response), then uncomment these lines.
  # self.throttled_responder = lambda do |env|
  #  [ 503,  # status
  #    {},   # headers
  #    ['']] # body
  # end
end

This contains helpful comments and sets a throttle for the entire application. If the application receives more than 300 requests in a 5-minute period, it will return a 429.

Testing our rate limiting

We can test our rate limiting in a number of ways, so let's first create a few more cats in the database with a few POST /cats to our application. Once we've done that, we can GET /cats and see an array of existing cats in our database.

A screenshot of Postman getting an index of all cats A screenshot of Postman getting an index of all cats

Next, we can temporarily change our configuration in rack-attack.rb to allow even fewer requests per 5-minute period.

# Throttle all requests by IP (60rpm)
#
# Key: "rack::attack:#{Time.now.to_i/:period}:req/ip:#{req.ip}"
throttle('req/ip', limit: 5, period: 5.minutes) do |req|
  req.ip # unless req.path.start_with?('/assets')
end

Also, we'll need to add in a chunk of code that configures rack-attack to use the MemoryStore cache if Redis is not present. Without this, rack-attack will not work in development, as the default cache in development is :null_store. In rack-attack.rb, add:

if !ENV['REDIS_URL'] || Rails.env.test?
  cache.store = ActiveSupport::Cache::MemoryStore.new
end

Without the placeholder comments, rack-attack.rb now looks like:

class Rack::Attack
  if !ENV['REDIS_URL'] || Rails.env.test?
    cache.store = ActiveSupport::Cache::MemoryStore.new
  end

  # Throttle all requests by IP (60rpm)
  # Key: "rack::attack:#{Time.now.to_i/:period}:req/ip:#{req.ip}"
  throttle('req/ip', limit: 5, period: 5.minutes) do |req|
    req.ip # unless req.path.start_with?('/assets')
  end
end

Lastly, you can use Postman to make six subsequent requests to the application on any endpoint. Six requests to GET /cats now returns a 429!

A screenshot of Postman getting rate limited A screenshot of Postman getting rate limited

This application-wide rate limiting is a great catch-all, but rack-attack allows you to do much more! You can limit specific endpoints, limit the total number of requests (disregarding source IP), blocklist, safelist, and more!

Conclusion

This tutorial has provided a comprehensive guide to building a Ruby on Rails API application with rate limiting to protect it! We've explored using the scaffold to quickly generate endpoints and also creating them manually. Rate limiting using rack-attack is a very helpful feature, safeguarding our application against abuse. With these tools and techniques at your disposal, you're now equipped to create robust and efficient Rails API applications tailored to your specific needs. I encourage you to explore the rack-attack documentation to learn more about rate limiting at a more granular level than we explored.

What to do next:
  1. Try Honeybadger for FREE
    Honeybadger helps you find and fix errors before your users can even report them. Get set up in minutes and check monitoring off your to-do list.
    Start free trial
    Easy 5-minute setup — No credit card required
  2. Get the Honeybadger newsletter
    Each month we share news, best practices, and stories from the DevOps & monitoring community—exclusively for developers like you.
    author photo

    Jeffery Morhous

    Jeff is a Software Engineer working in healtcare technology using Ruby on Rails, React, and plenty more tools. He loves making things that make life more interesting and learning as much he can on the way. In his spare time, he loves to play guitar, hike, and tinker with cars.

    More articles by Jeffery Morhous
    Stop wasting time manually checking logs for errors!

    Try the only application health monitoring tool that allows you to track application errors, uptime, and cron jobs in one simple platform.

    • Know when critical errors occur, and which customers are affected.
    • Respond instantly when your systems go down.
    • Improve the health of your systems over time.
    • Fix problems before your customers can report them!

    As developers ourselves, we hated wasting time tracking down errors—so we built the system we always wanted.

    Honeybadger tracks everything you need and nothing you don't, creating one simple solution to keep your application running and error free so you can do what you do best—release new code. Try it free and see for yourself.

    Start free trial
    Simple 5-minute setup — No credit card required

    Learn more

    "We've looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release."
    — Michael Smith, Cofounder & CTO of YvesBlue

    Honeybadger is trusted by top companies like:

    “Everyone is in love with Honeybadger ... the UI is spot on.”
    Molly Struve, Sr. Site Reliability Engineer, Netflix
    Start free trial
    Are you using Sentry, Rollbar, Bugsnag, or Airbrake for your monitoring? Honeybadger includes error tracking with a whole suite of amazing monitoring tools — all for probably less than you're paying now. Discover why so many companies are switching to Honeybadger here.
    Start free trial
    Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
    Start free trial
    “Wow — Customers are blown away that I email them so quickly after an error.”
    Chris Patton, Founder of Punchpass.com
    Start free trial