Modern applications often need to expose APIs for mobile apps, third-party integrations, or single-page applications. Rails 8 provides everything needed to build secure token-based authentication without reaching for external gems like Devise or JWT libraries.
This guide covers building a complete API authentication system using Rails 8's built-in features: has_secure_password, generates_token_for, and Active Record encryption for secure token storage.
The Authentication Flow
Token-based API authentication follows a straightforward pattern: clients exchange credentials for a token, then include that token in subsequent requests. The server validates the token and identifies the user without maintaining session state.
This approach works well for APIs because it's stateless, scales horizontally, and supports multiple client types from a single authentication system.
Setting Up the User Model
Start with a User model that handles password authentication and token generation. Rails 8's generates_token_for method creates secure, expiring tokens without additional dependencies.
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
generates_token_for :api_session, expires_in: 30.days do
# Include password_salt to invalidate tokens when password changes
password_salt&.last(10)
end
validates :email, presence: true,
uniqueness: { case_sensitive: false },
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 12 }, allow_nil: true
normalizes :email, with: ->(email) { email.strip.downcase }
def self.authenticate_by_token(token)
find_by_token_for(:api_session, token)
end
endThe generates_token_for block includes the password salt, which means tokens automatically become invalid when users change their passwordsβa critical security feature that many custom implementations miss.
The corresponding migration creates the users table with password digest storage:
# db/migrate/20251231000000_create_users.rb
class CreateUsers < ActiveRecord::Migration[8.0]
def change
create_table :users do |t|
t.string :email, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email, unique: true
end
endBuilding the API Authentication Controller
The sessions controller handles token creation and destruction. Keeping this logic in a dedicated API namespace maintains clean separation from any web-based authentication.
# app/controllers/api/v1/sessions_controller.rb
module Api
module V1
class SessionsController < Api::V1::BaseController
skip_before_action :authenticate_request, only: [:create]
def create
user = User.find_by(email: session_params[:email])
if user&.authenticate(session_params[:password])
token = user.generate_token_for(:api_session)
render json: {
token: token,
user: UserSerializer.new(user).as_json,
expires_at: 30.days.from_now.iso8601
}, status: :created
else
render json: {
error: "Invalid email or password"
}, status: :unauthorized
end
end
def destroy
# Token invalidation happens via password change or expiration
# For immediate invalidation, implement a token blocklist
head :no_content
end
private
def session_params
params.require(:session).permit(:email, :password)
end
end
end
endNotice the generic error message for failed authentication. Revealing whether an email exists in the system creates an enumeration vulnerability that attackers exploit for targeted attacks.
Implementing the Base Controller with Authentication
A base controller for API endpoints centralizes authentication logic and provides helper methods for accessing the current user:
# app/controllers/api/v1/base_controller.rb
module Api
module V1
class BaseController < ActionController::API
before_action :authenticate_request
private
def authenticate_request
token = extract_token_from_header
@current_user = User.authenticate_by_token(token) if token
render_unauthorized unless @current_user
end
def extract_token_from_header
header = request.headers["Authorization"]
return nil unless header
scheme, token = header.split(" ")
return nil unless scheme&.downcase == "bearer"
token
end
def render_unauthorized
render json: { error: "Unauthorized" }, status: :unauthorized
end
attr_reader :current_user
end
end
endThe extract_token_from_header method follows the Bearer token standard from RFC 6750, making the API compatible with standard HTTP clients and libraries.
Adding Rate Limiting for Security
Authentication endpoints are prime targets for brute force attacks. Rails 8 includes Rack::Attack-style rate limiting through the Rack::Throttle middleware, but a simpler approach uses Redis with Solid Cache:
# app/controllers/concerns/rate_limitable.rb
module RateLimitable
extend ActiveSupport::Concern
class RateLimitExceeded < StandardError; end
included do
rescue_from RateLimitExceeded, with: :render_rate_limit_exceeded
end
private
def rate_limit!(key:, limit:, period:)
cache_key = "rate_limit:#{key}:#{period.ago.beginning_of_minute.to_i / period.to_i}"
count = Rails.cache.increment(cache_key, 1, expires_in: period)
raise RateLimitExceeded if count > limit
end
def render_rate_limit_exceeded
render json: { error: "Too many requests" }, status: :too_many_requests
end
endApply rate limiting to the sessions controller to protect against credential stuffing:
# In Api::V1::SessionsController
include RateLimitable
def create
rate_limit!(
key: "login:#{request.remote_ip}",
limit: 5,
period: 1.minute
)
# ... rest of create action
endConfiguring Routes
Namespace the API routes with versioning to allow future breaking changes without disrupting existing clients:
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resource :session, only: [:create, :destroy]
# Additional API resources go here
end
end
endCommon Mistakes to Avoid
Storing tokens in the database: The generates_token_for method creates stateless tokens that don't require database storage. The token encodes the user ID and expiration, verified cryptographically on each request.
Using predictable token formats: Never implement tokens as simple random strings stored in a database column. Rails' token generation uses ActiveSupport::MessageVerifier with the application's secret key base, making tokens cryptographically secure.
Forgetting token expiration: Always set reasonable expiration times. Thirty days works for most mobile apps, while shorter durations suit high-security applications. The expires_in option handles this automatically.
Leaking timing information: The authenticate method from has_secure_password uses constant-time comparison, preventing timing attacks that could reveal valid passwords character by character.
Testing the Authentication Flow
Request specs verify the complete authentication cycle:
# spec/requests/api/v1/sessions_spec.rb
RSpec.describe "Api::V1::Sessions" do
describe "POST /api/v1/session" do
let(:user) { User.create!(email: "[email protected]", password: "secure_password_123") }
it "returns a token for valid credentials" do
post api_v1_session_path, params: {
session: { email: user.email, password: "secure_password_123" }
}
expect(response).to have_http_status(:created)
expect(json_response[:token]).to be_present
expect(json_response[:user][:email]).to eq(user.email)
end
it "returns unauthorized for invalid credentials" do
post api_v1_session_path, params: {
session: { email: user.email, password: "wrong_password" }
}
expect(response).to have_http_status(:unauthorized)
expect(json_response[:error]).to eq("Invalid email or password")
end
end
endNext Steps
This authentication system provides a solid foundation for Rails 8 APIs. Consider extending it with refresh token rotation for enhanced security, OAuth provider integration for social login, or scope-based permissions for granular access control.
For applications requiring immediate token revocation, implement a blocklist using Solid Cache to store invalidated token signatures until their natural expiration.