API Authentication in Rails 8

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
end

The 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
end

Building 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
end

Notice 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
end

The 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
end

Apply 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
end

Configuring 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
end

Common 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
end

Next 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.

10 claps
← Back to Blog