Building an API with Rails 8
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.