Guide: Rails 8 API Application – Authentication 🔐 mechanisms | Sample Rails API app with Rspec Test cases

When building a Rails API app, you typically need token-based authentication instead of cookie-based sessions (which are more common in full-stack Rails apps). Here are the most common authentication mechanisms you can use in a Rails API-only application:

🔐 1. Token-Based Authentication

Most Common & Recommended for APIs

a. JWT (JSON Web Tokens)

  • Gems: jwt, knock, devise-jwt
  • How it works: After login, the server issues a JWT token which the client must include in the Authorization header (Bearer <token>) in subsequent requests.
  • Pros:
    • Stateless, scalable.
    • Widely supported across mobile and frontend frameworks.
  • Cons:
    • Tokens can’t be invalidated easily without extra measures (e.g., a blacklist).

b. Token-based Auth with Devise + TokenAuthenticatable

  • Gems: devise_token_auth
  • Uses Devise under the hood.
  • Stores tokens on the server (in DB), enabling logout and token revocation.
  • Compatible with React Native and SPAs.

🔐 2. OAuth 2.0 / OmniAuth (for Third-party Logins)

  • Gems: omniauth, doorkeeper
  • Use when you want users to log in via:
    • Google
    • Facebook
    • GitHub
  • Doorkeeper is often used to implement OAuth 2 provider (if you’re exposing your API to other apps).
  • Best when integrating external identity providers.

🔐 3. API Key Authentication

  • Useful for machine-to-machine communication or when exposing APIs to third-party developers.
  • Each user/client is assigned a unique API key.
  • Example: Authorization: Token token=abc123
  • You store the API key in the DB and verify it on each request.
  • Lightweight and easy to implement.

🔐 4. HTTP Basic Authentication

  • Simple and built-in with Rails (authenticate_or_request_with_http_basic).
  • Not suitable for production unless combined with HTTPS and only used for internal/testing tools.

👉🏻 Choosing the Right Auth Mechanism

Use CaseRecommended Method
Mobile app or frontend SPAJWT (devise-jwt / knock)
Internal API between servicesAPI key
Want email/password with token authdevise_token_auth
External login via Google/GitHubomniauth + doorkeeper
OAuth2 provider for third-party devsdoorkeeper
Quick-and-dirty internal authHTTP Basic Auth

🔄 How JWT Authentication Works — Step by Step

1. User Logs In

  • The client (e.g., React app, mobile app) sends a POST /login request with email/password.
  • Your Rails API validates the credentials.
  • If valid, it generates a JWT token and sends it back to the client.
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

2. Client Stores the Token

  • The client stores the token in localStorage, sessionStorage, or memory (for SPAs), or a secure storage for mobile apps.

3. Client Sends Token on Requests

  • For any subsequent request to protected resources, the client includes the JWT in the Authorization header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

4. Server Verifies the Token

  • Rails extracts the token, decodes it using a secret key, and verifies:
    • The signature is valid.
    • The token is not expired.
    • The user ID (or sub claim) is valid.

If everything checks out, the request is allowed to proceed.

5. Token Expiration

  • Tokens usually include an exp (expiration) claim, e.g., 15 minutes, 1 hour, etc.
  • After expiration, the client must log in again or use a refresh token flow if supported.

🔒 Security: Is JWT Secure?

JWT can be secure, if used correctly. Here’s a breakdown:

✅ Security Benefits

FeatureWhy It Helps
StatelessNo session storage needed; scales easily
SignedThe token is signed (HMAC or RSA), so it can’t be tampered with
CompactSent in headers; easy to pass around
Exp claimTokens expire automatically after a period

⚠️ Security Considerations

IssueDescriptionMitigation
Token theftIf an attacker steals the token, they can impersonate the user.Always use HTTPS. Avoid storing tokens in localStorage if possible.
No server-side revocationTokens can’t be invalidated until they expire.Use short-lived access tokens + refresh tokens or token blacklist (DB).
Long token lifespanLonger expiry means higher risk if leaked.Keep exp short (e.g., 15–30 min). Use refresh tokens if needed.
Poor secret handlingIf your secret key leaks, anyone can forge tokens.Store your JWT_SECRET in environment variables, never in code.
JWT stored in localStorageSusceptible to XSS attacks in web apps.Use HttpOnly cookies when possible, or protect against XSS.
Algorithm confusionAttacker could force a weak algorithm.Always validate the algorithm (alg) on decoding. Use only HMAC or RSA.

🧪 Example Token (Decoded)

A typical JWT has three parts:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VyX2lkIjoxLCJleHAiOjE3MDAwMDAwMDB9.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Breakdown:

  1. Header (Base64-encoded JSON)
{
  "alg": "HS256",
  "typ": "JWT"
}

  1. Payload
{
  "user_id": 1,
  "exp": 1700000000
}

  1. Signature
  • HMAC-SHA256 hash of header + payload + secret key.

🛡 Best Practices for JWT in Rails API

  • Use devise-jwt or knock to handle encoding/decoding securely.
  • Set short token lifetimes (exp claim).
  • Use HTTPS only.
  • Consider implementing refresh tokens for session continuation.
  • Avoid token storage in localStorage unless you trust your frontend.
  • Rotate secrets periodically (invalidate tokens when secrets change).

Now Let’s create a sample Rails API application and test what we learned.

🧱 Sample Rails API web app: Prerequisites

  • A Rails 8 app with --api mode enabled: rails new my_api_app --api
  • A User model with email and password_digest.
  • We’ll use bcrypt for password hashing.

✅ Step 1: Add Required Gems

In your Gemfile:

gem 'jwt'
gem 'bcrypt'

Then run:

bundle install

✅ Step 2: Generate the User Model

rails g model User email:string password_digest:string
rails db:migrate

In app/models/user.rb:

class User < ApplicationRecord
  has_secure_password
end

Now you can create users with secure passwords.

✅ Step 3: Create JWT Helper Module

Create a service object or helper to encode/decode tokens.

app/lib/json_web_token.rb (create the lib folder if needed):

# app/lib/json_web_token.rb
class JsonWebToken
  SECRET_KEY = Rails.application.credentials.secret_key_base

  def self.encode(payload, exp = 24.hours.from_now)
    payload[:exp] = exp.to_i
    JWT.encode(payload, SECRET_KEY)
  end

  def self.decode(token)
    decoded = JWT.decode(token, SECRET_KEY)[0]
    HashWithIndifferentAccess.new(decoded)
  rescue JWT::DecodeError => e
    nil
  end
end

✅ Step 4: Create the Authentication Controller

rails g controller auth

app/controllers/auth_controller.rb:

class AuthController < ApplicationController
  def login
    user = User.find_by(email: params[:email])

    if user&.authenticate(params[:password])
      token = JsonWebToken.encode(user_id: user.id)
      render json: { token: token }, status: :ok
    else
      render json: { error: 'Invalid credentials' }, status: :unauthorized
    end
  end
end

✅ Step 5: Protect Other Endpoints with Authentication

Make a reusable authenticate_request method.

app/controllers/application_controller.rb:

class ApplicationController < ActionController::API
  before_action :authenticate_request

  attr_reader :current_user

  private

  def authenticate_request
    header = request.headers['Authorization']
    token = header.split(' ').last if header.present?

    if token
      decoded = JsonWebToken.decode(token)
      @current_user = User.find_by(id: decoded[:user_id]) if decoded
    end

    render json: { error: 'Unauthorized' }, status: :unauthorized unless @current_user
  end
end

Now all your controllers inherit this behaviour unless you skip_before_action.

✅ Step 6: Add Routes

config/routes.rb:

Rails.application.routes.draw do
  post '/login', to: 'auth#login'

  get '/profile', to: 'users#profile' # Example protected route
end

✅ Step 7: Example Protected Controller

rails g controller users

app/controllers/users_controller.rb:

class UsersController < ApplicationController
  def profile
    render json: { id: current_user.id, email: current_user.email }
  end
end

🧪 Test It Out (Example)

Step 1: Create a User (via Rails Console)

User.create!(email: "test@example.com", password: "password123")

Step 2: Login via POST /login

POST /login
Content-Type: application/json

{
  "email": "test@example.com",
  "password": "password123"
}

Response:

{ "token": "eyJhbGciOi..." }

Step 3: Use Token in Authenticated Request

GET /profile
Authorization: Bearer eyJhbGciOi...

🔒 Extras You Might Add Later

  • Token expiration errors
  • Refresh tokens
  • Token revocation (e.g., a blacklist table)
  • Roles/permissions inside the token (e.g., admin claims)

Let’s now write RSpec tests for the JWT-based authentication flow we just set up in your Rails API app.

Assumptions

  • You already have:
    • A User model with email and password_digest
    • An AuthController with login
    • A UsersController with a protected profile action
    • JWT auth logic in JsonWebToken

🔧 Step 1: Add RSpec & Factory Bot

In your Gemfile (if not already added):

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

group :test do
  gem 'faker'
end

Then install:

bundle install
rails generate rspec:install


🏭 Step 2: Setup Factory for User

spec/factories/users.rb:

FactoryBot.define do
  factory :user do
    email { Faker::Internet.email }
    password { 'password123' }
    password_confirmation { 'password123' }
  end
end


🧪 Step 3: Auth Request Specs

spec/requests/auth_spec.rb:

require 'rails_helper'

RSpec.describe 'Authentication', type: :request do
  let!(:user) { create(:user, password: 'password123') }

  describe 'POST /login' do
    context 'with valid credentials' do
      it 'returns a JWT token' do
        post '/login', params: { email: user.email, password: 'password123' }

        expect(response).to have_http_status(:ok)
        expect(JSON.parse(response.body)).to include('token')
      end
    end

    context 'with invalid credentials' do
      it 'returns unauthorized' do
        post '/login', params: { email: user.email, password: 'wrong' }

        expect(response).to have_http_status(:unauthorized)
        expect(JSON.parse(response.body)).to include('error')
      end
    end
  end
end


🔒 Step 4: Profile (Protected) Request Specs

spec/requests/users_spec.rb:

require 'rails_helper'

RSpec.describe 'Users', type: :request do
  let!(:user) { create(:user) }
  let(:token) { JsonWebToken.encode(user_id: user.id) }

  describe 'GET /profile' do
    context 'with valid token' do
      it 'returns user profile' do
        get '/profile', headers: { 'Authorization' => "Bearer #{token}" }

        expect(response).to have_http_status(:ok)
        json = JSON.parse(response.body)
        expect(json['email']).to eq(user.email)
      end
    end

    context 'without token' do
      it 'returns unauthorized' do
        get '/profile'
        expect(response).to have_http_status(:unauthorized)
      end
    end

    context 'with invalid token' do
      it 'returns unauthorized' do
        get '/profile', headers: { 'Authorization' => 'Bearer invalid.token' }
        expect(response).to have_http_status(:unauthorized)
      end
    end
  end
end

📦 Final Tips

  • Run tests with: bundle exec rspec
  • You can stub JsonWebToken.decode in unit tests if needed to isolate auth logic.