This guide has been sitting in my drafts since the Rails 5 days, and the bones of it held up surprisingly well: Rails API mode remains an ideal way to build a streamlined JSON API quickly. The details around it have all moved on though, so here’s the guide as I’d write it today.

Creating the app

API mode has shipped in Rails core since version 5. Pass the --api flag and you get a slimmed-down app: no views, no asset pipeline, middleware trimmed to what an API needs.

rails new api_app_name --api -d postgresql
cd api_app_name
rails db:create

That’s a full Rails app minus the front-end bloat, with all the inherent “Railsy” goodness intact.

Setting up RSpec

Worth doing first, so the scaffold generators create specs for you as you go. Add to your Gemfile:

group :development, :test do
  gem "rspec-rails"
  gem "factory_bot_rails"
end

(If you last did this a while ago: factory_girl became factory_bot back in 2017. Same gem, better name.)

bundle install
bin/rails generate rspec:install

You can delete the default test/ directory; we’re writing specs instead.

Building resources

With --api mode, the standard scaffold generator does the right thing:

rails g scaffold user name email
rails db:migrate
rails s

No views are generated, controllers inherit from ActionController::API, and your endpoints render JSON out of the box. Your API is now running at localhost:3000. Sweet!

You’re not done yet though; there are still some important considerations.

Serializing output

Left alone, your controllers will spit out a JSON blob of every column in the database, so you need control over what gets served.

For simple cases, plain Rails is honestly enough:

render json: @user.as_json(only: [:id, :name, :email])

When you outgrow that (nested associations, per-endpoint shapes), reach for a serializer gem. The old default, active_model_serializers, is in maintenance mode these days; Alba is the one I’d pick now: fast, actively maintained, zero magic.

# Gemfile
gem "alba"
# app/resources/user_resource.rb
class UserResource
  include Alba::Resource

  attributes :id, :name, :email
end
# in the controller
render json: UserResource.new(@user).serialize

Collections work the same way: UserResource.new(User.all).serialize.

Enabling CORS

If browsers will call your API from other origins, you need CORS. Nice quality-of-life change since the old days: API-mode apps now generate this for you. Uncomment gem "rack-cors" in your Gemfile, then open the ready-made config/initializers/cors.rb:

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "app.example.com"

    resource "*",
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

Resist the temptation to ship origins "*" on anything authenticated.

Rate limiting with Rack::Attack

To protect the API from brute force attacks and over-eager clients, rack-attack is still the tool:

gem "rack-attack"
# config/initializers/rack_attack.rb
class Rack::Attack
  safelist("allow-localhost") do |req|
    ["127.0.0.1", "::1"].include?(req.ip)
  end

  # 5 requests per 5 seconds per IP
  throttle("req/ip", limit: 5, period: 5) do |req|
    req.ip
  end

  self.throttled_responder = lambda do |request|
    match_data = request.env["rack.attack.match_data"] || {}
    [
      429,
      { "Content-Type" => "application/json", "Retry-After" => match_data[:period].to_s },
      [{ error: "Throttle limit reached. Retry later." }.to_json]
    ]
  end
end

One small rename to note if you’re updating old code: throttled_response became throttled_responder. Enable it in config/application.rb with config.middleware.use Rack::Attack.

Authentication

APIs should be stateless, so token authentication over the Authorization header is the classic pattern. The nice bit: generating the token used to mean hand-rolling a SecureRandom loop, and now it’s one line, built into Rails:

rails g migration AddApiKeyToUsers api_key:string:index
class User < ApplicationRecord
  has_secure_token :api_key
end

Every new user gets a unique token automatically, and you get user.regenerate_api_key for free. The controller side uses Rails’ built-in HTTP token support, same as it ever did:

class ApplicationController < ActionController::API
  include ActionController::HttpAuthentication::Token::ControllerMethods

  before_action :authenticate

  private

  def authenticate
    authenticate_token || render_unauthorized
  end

  def authenticate_token
    authenticate_with_http_token do |token, _options|
      @current_user = User.find_by(api_key: token)
    end
  end

  def render_unauthorized
    headers["WWW-Authenticate"] = 'Token realm="Application"'
    render json: { error: "Bad credentials" }, status: :unauthorized
  end
end

Test it with curl:

curl -H "Authorization: Token token=YOUR_TOKEN" http://localhost:3000/users

For anything beyond a personal or server-to-server API (user sessions, third-party clients), look at OAuth or Rails 8’s new authentication generator as a starting point instead.

Securing it properly

Authentication gets you a locked door; it doesn’t make the house secure. A few things I’d consider non-negotiable before an API faces the internet.

HTTPS, enforced. Tokens in headers are credentials, and credentials over plain HTTP are public. In config/environments/production.rb:

config.force_ssl = true

Don’t store tokens in plain text. This is the uncomfortable truth about the has_secure_token setup above: the tokens sit readable in your database, so a leaked backup or a stray SQL injection hands out every key. For anything serious, store a digest instead and hash the incoming token to look it up:

class User < ApplicationRecord
  def self.find_by_api_key(token)
    find_by(api_key_digest: Digest::SHA256.hexdigest(token))
  end
end

Show the raw token to the user exactly once at creation, keep only the digest. As a bonus, digest lookups are constant-time-friendly, which closes off timing attacks. (This is precisely the model Rails credentials use, which I’ve written about before: secrets exist in one place, encrypted or hashed everywhere else.)

Authentication is not authorisation. Knowing who is calling says nothing about what they may touch. It’s the classic hole: /v1/users/42 happily serving user 42’s data to any authenticated caller. Scope every query to the caller (@current_user.projects.find(params[:id]), never Project.find(params[:id])), and once rules grow beyond that, reach for Pundit and make policies explicit.

Rate limiting, now built in. Since Rails 7.2 the basics need no gem at all:

class ApiController < ActionController::API
  rate_limit to: 100, within: 1.minute
end

Rack::Attack (above) is still the tool for the fancy stuff like safelists and fail2ban-style banning, but for “stop hammering me”, this one-liner is plenty.

Fail without oversharing. Rescue errors into deliberately boring JSON so stack traces, gem names and SQL never reach a client, and keep tokens out of your logs:

# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [:api_key, :token]

Audit what you ship. Two gems, run in CI, cost nothing: brakeman static-scans your code for vulnerability patterns, and bundler-audit checks your Gemfile.lock against known CVEs. Between them they catch the embarrassing stuff before the internet does.

Versioning

Before releasing a public API into the wild, version it, so you can introduce breaking changes as v2 without stranding existing clients. Namespace the controllers:

app/controllers/
|-- api
|   `-- v1
|       |-- api_controller.rb
|       `-- users_controller.rb
|-- application_controller.rb
# app/controllers/api/v1/users_controller.rb
module Api::V1
  class UsersController < ApiController
    # GET /v1/users
    def index
      render json: UserResource.new(User.all).serialize
    end
  end
end
# config/routes.rb
constraints subdomain: "api" do
  scope module: "api" do
    namespace :v1 do
      resources :users
    end
  end
end

That gives you api.mysite.com/v1/users. Whether you prefer the subdomain or a plain /api/v1/ path prefix is taste; both are easy in Rails.

Conclusion

Same castle, newer keys. The remarkable thing writing this update is how little of the shape changed in six years: API mode, serialize, CORS, throttle, authenticate, version. Rails just quietly absorbed the fiddly bits (generated CORS config, has_secure_token) so there’s less to hand-roll than ever.