Securing Your Ruby on Rails API: JWT vs. Cookies
[ Web Development ]

Securing Your Ruby on Rails API: JWT vs. Cookies

Master Rails API authentication by comparing JWT tokens and session cookies across security models, mobile compatibility, implementation patterns, revocation strategies, and production deployment considerations for web and mobile applications.

→ featured
→ essential
→ timely
By Paul Badarau 15 min read
[ Article Content ]
Share this article:
P
Paul Badarau
Author

Securing Your Ruby on Rails API: The Complete JWT vs. Cookies Authentication Guide

Meta Description: Master Rails API authentication by comparing JWT tokens and session cookies across security models, mobile compatibility, implementation patterns, revocation strategies, and production deployment considerations for web and mobile applications.

The Hook: The Authentication Decision That Shapes Your Architecture

You're building a Rails API. Authentication is day-one functionality. Your frontend developer asks: "Should I store a JWT in localStorage or use cookies?" Your mobile developer asks: "Can we use the same auth system for iOS and Android?" Your security team asks: "How do we revoke access when credentials are compromised?"

The authentication strategy you choose now dictates your architecture for years. Pick JWT and you've committed to stateless token management, revocation lists, and client-side storage security. Pick session cookies and you've chosen server-side state, CSRF protection, and browser-centric workflows. Both work. Both have shipped millions of successful applications. But they solve different problems and carry different tradeoffs.

The complexity comes from mixed requirements: Your React web app wants simple cookie-based sessions. Your React Native mobile app needs stateless tokens. Your B2B clients want single sign-on. Your security policy requires instant revocation. No single approach handles all of these perfectly, and most teams don't realize the limitations of their choice until they're deep into implementation.

This comprehensive guide provides the decision framework you need upfront. We'll compare JWT and session-based authentication across security models, implementation patterns, client compatibility (web vs mobile), revocation strategies, scalability considerations, and production operational concerns. You'll see complete Rails code examples for both approaches, understand when to use each (or combine them), and learn how to implement hybrid strategies that serve diverse client types from a single API.

By the end, you'll make an informed authentication choice aligned with your client architecture, security requirements, and operational constraints—avoiding the costly mid-project pivot when your initial approach hits a fundamental limitation.

For comprehensive API design patterns that complement authentication, see /blog/rails-api-best-practices. For mobile client implementation details, reference /blog/react-native-expo-modern-guide.

The Core Authentication Models

Stateless JWT Tokens

How it works:

  1. Client sends credentials (email/password)
  2. Server validates, generates signed JWT containing user_id and expiration
  3. Client stores JWT (mobile: secure storage, web: localStorage/memory)
  4. Client includes JWT in Authorization header on every request
  5. Server validates JWT signature and expiration, extracts user_id

JWT structure:

# JWT payload
{
  "user_id": 123,
  "email": "user@example.com",
  "exp": 1703001600,  # Unix timestamp expiration
  "iat": 1702997000   # Issued at timestamp
}

# Signed token format
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImV4cCI6MTcwMzAwMTYwMCwiaWF0IjoxNzAyOTk3MDAwfQ.signature"

Key characteristics:

  • Stateless: Server doesn't store session data; JWT is self-contained
  • Scalable: No shared session store required across API servers
  • Mobile-friendly: Token stored in device secure storage
  • Revocation challenge: Can't invalidate tokens without additional infrastructure

Stateful Session Cookies

How it works:

  1. Client sends credentials
  2. Server validates, creates session record in database/Redis
  3. Server sets encrypted session cookie in response
  4. Browser automatically includes cookie on every request
  5. Server looks up session, loads user

Session flow:

# Session record in database/Redis
{
  "session_id": "a1b2c3d4e5f6",
  "user_id": 123,
  "created_at": "2024-01-01 10:00:00",
  "last_activity": "2024-01-01 10:15:00"
}

# Cookie (encrypted by Rails)
_app_session=encrypted_session_id; HttpOnly; Secure; SameSite=Lax

Key characteristics:

  • Stateful: Server stores session data
  • Browser-integrated: Cookies handled automatically
  • Easy revocation: Delete session record, user logged out
  • CSRF protection: Built-in with Rails authenticity tokens
  • Mobile complexity: Requires cookie handling in mobile HTTP clients

Decision Framework: When to Use Each

Use JWT When:

1. Primary clients are mobile apps

Mobile applications can't rely on automatic cookie handling:

// React Native Expo - JWT in SecureStore
import * as SecureStore from 'expo-secure-store';

async function login(email: string, password: string) {
  const response = await fetch('https://api.example.com/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password })
  });
  
  const { token } = await response.json();
  await SecureStore.setItemAsync('auth_token', token);
}

async function authenticatedRequest(url: string) {
  const token = await SecureStore.getItemAsync('auth_token');
  return fetch(url, {
    headers: { 'Authorization': `Bearer ${token}` }
  });
}

2. API serves multiple first-party and third-party clients

JWT allows diverse clients without session affinity:

  • iOS app
  • Android app
  • React SPA
  • Third-party integrations
  • CLI tools

All use the same token-based auth without session storage.

3. Horizontal scaling without shared state

JWT validation is CPU-only, no database/Redis lookup:

# Stateless validation scales infinitely
def current_user
  @current_user ||= begin
    token = request.headers['Authorization']&.split(' ')&.last
    payload = JwtService.decode(token)  # Just signature verification
    User.find(payload['user_id'])
  end
end

4. Cross-domain API access

JWT works across domains without CORS cookie complexity:

// Frontend on app.example.com
// API on api.example.com
fetch('https://api.example.com/data', {
  headers: { 'Authorization': `Bearer ${jwt}` }
});  // No cookie issues

Use Session Cookies When:

1. Primary clients are web browsers

Session cookies integrate seamlessly with Rails:

# app/controllers/sessions_controller.rb
def create
  user = User.find_by(email: params[:email])&.authenticate(params[:password])
  
  if user
    session[:user_id] = user.id  # Rails handles cookie automatically
    redirect_to dashboard_path
  else
    render :new, status: :unauthorized
  end
end

# Access user in any controller
def current_user
  @current_user ||= User.find_by(id: session[:user_id])
end

2. You want built-in CSRF protection

Rails' authenticity token works automatically with sessions:

<!-- Rails generates CSRF token for forms -->
<%= form_with url: posts_path do |f| %>
  <%= f.text_field :title %>
  <%= f.submit %>
<% end %>
<!-- CSRF token included automatically when session-based -->

3. Instant revocation is critical

Delete session, user immediately logged out:

# Revoke all sessions for user
def logout_everywhere
  # With database sessions
  Session.where(user_id: current_user.id).destroy_all
  
  # With Redis sessions
  redis_keys = $redis.keys("session:#{current_user.id}:*")
  $redis.del(*redis_keys) if redis_keys.any?
end

4. Server-side rendering with Hotwire/Turbo

Session cookies integrate naturally with Rails' server-rendered flows:

# No JSON API needed for server-rendered pages
class DashboardController < ApplicationController
  before_action :require_login
  
  def show
    @user = current_user  # From session
    # Render HTML directly
  end
  
  private
  
  def require_login
    redirect_to login_path unless session[:user_id]
  end
end

Complete Implementation: JWT Authentication

Setup JWT Gem

# Gemfile
gem 'jwt'

# config/initializers/jwt.rb
module JwtService
  SECRET_KEY = Rails.application.credentials.dig(:jwt, :secret_key) || ENV['JWT_SECRET_KEY']
  ALGORITHM = 'HS256'
  
  def self.encode(payload, exp: 30.minutes.from_now)
    payload[:exp] = exp.to_i
    JWT.encode(payload, SECRET_KEY, ALGORITHM)
  end
  
  def self.decode(token)
    decoded = JWT.decode(token, SECRET_KEY, true, algorithm: ALGORITHM)
    HashWithIndifferentAccess.new(decoded.first)
  rescue JWT::DecodeError, JWT::ExpiredSignature
    nil
  end
end

Authentication Controller

# app/controllers/api/v1/authentication_controller.rb
module Api
  module V1
    class AuthenticationController < ApplicationController
      skip_before_action :authenticate_user, only: [:login, :refresh]
      
      def login
        user = User.find_by(email: params[:email])&.authenticate(params[:password])
        
        unless user
          return render json: { error: 'Invalid credentials' }, status: :unauthorized
        end
        
        tokens = generate_tokens(user)
        render json: {
          access_token: tokens[:access_token],
          refresh_token: tokens[:refresh_token],
          user: UserSerializer.new(user).as_json
        }
      end
      
      def refresh
        refresh_token = params[:refresh_token]
        payload = JwtService.decode(refresh_token)
        
        unless payload && payload[:type] == 'refresh'
          return render json: { error: 'Invalid refresh token' }, status: :unauthorized
        end
        
        user = User.find_by(id: payload[:user_id])
        unless user
          return render json: { error: 'User not found' }, status: :not_found
        end
        
        # Check if refresh token is revoked
        if RefreshToken.revoked?(refresh_token)
          return render json: { error: 'Token revoked' }, status: :unauthorized
        end
        
        tokens = generate_tokens(user)
        render json: {
          access_token: tokens[:access_token],
          refresh_token: tokens[:refresh_token]
        }
      end
      
      def logout
        # Revoke refresh token
        RefreshToken.revoke(params[:refresh_token])
        head :no_content
      end
      
      private
      
      def generate_tokens(user)
        {
          access_token: JwtService.encode(
            { user_id: user.id, type: 'access' },
            exp: 15.minutes.from_now
          ),
          refresh_token: JwtService.encode(
            { user_id: user.id, type: 'refresh' },
            exp: 7.days.from_now
          )
        }
      end
    end
  end
end

Authentication Middleware

# app/controllers/concerns/jwt_authenticatable.rb
module JwtAuthenticatable
  extend ActiveSupport::Concern
  
  included do
    before_action :authenticate_user
  end
  
  private
  
  def authenticate_user
    token = extract_token
    payload = JwtService.decode(token)
    
    unless payload && payload[:type] == 'access'
      render json: { error: 'Unauthorized' }, status: :unauthorized
      return
    end
    
    @current_user = User.find_by(id: payload[:user_id])
    
    unless @current_user
      render json: { error: 'User not found' }, status: :unauthorized
    end
  end
  
  def current_user
    @current_user
  end
  
  def extract_token
    header = request.headers['Authorization']
    header&.split(' ')&.last if header&.start_with?('Bearer ')
  end
end

# app/controllers/api/v1/base_controller.rb
module Api
  module V1
    class BaseController < ApplicationController
      include JwtAuthenticatable
      skip_before_action :verify_authenticity_token  # No CSRF for JWT
    end
  end
end

Revocation with Refresh Tokens

# app/models/refresh_token.rb
class RefreshToken < ApplicationRecord
  belongs_to :user
  
  validates :token_digest, presence: true, uniqueness: true
  
  def self.revoke(token)
    digest = Digest::SHA256.hexdigest(token)
    find_by(token_digest: digest)&.update(revoked_at: Time.current)
  end
  
  def self.revoked?(token)
    digest = Digest::SHA256.hexdigest(token)
    find_by(token_digest: digest)&.revoked_at.present?
  end
  
  def self.cleanup_expired
    where('created_at < ?', 7.days.ago).delete_all
  end
end

# db/migrate/xxx_create_refresh_tokens.rb
class CreateRefreshTokens < ActiveRecord::Migration[7.1]
  def change
    create_table :refresh_tokens do |t|
      t.references :user, null: false, foreign_key: true
      t.string :token_digest, null: false, index: { unique: true }
      t.datetime :revoked_at
      t.timestamps
    end
  end
end

Complete Implementation: Session Cookies

Session Configuration

# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store,
  key: '_app_session',
  domain: Rails.env.production? ? '.example.com' : nil,
  secure: Rails.env.production?,
  httponly: true,
  same_site: :lax

# Use Redis for session storage (recommended for production)
# Rails.application.config.session_store :redis_store,
#   servers: [{ host: ENV['REDIS_HOST'], port: 6379, db: 0 }],
#   expire_after: 30.days,
#   key: '_app_session',
#   secure: Rails.env.production?,
#   httponly: true,
#   same_site: :lax

Authentication Controller

# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  skip_before_action :require_login, only: [:new, :create]
  
  def new
    # Render login form
  end
  
  def create
    user = User.find_by(email: params[:email])&.authenticate(params[:password])
    
    if user
      reset_session  # Prevent session fixation
      session[:user_id] = user.id
      session[:last_activity] = Time.current
      
      redirect_to dashboard_path, notice: 'Logged in successfully'
    else
      flash.now[:alert] = 'Invalid credentials'
      render :new, status: :unauthorized
    end
  end
  
  def destroy
    reset_session
    redirect_to root_path, notice: 'Logged out successfully'
  end
end

Authentication Middleware

# app/controllers/concerns/session_authenticatable.rb
module SessionAuthenticatable
  extend ActiveSupport::Concern
  
  included do
    before_action :require_login
    before_action :check_session_timeout
  end
  
  private
  
  def require_login
    unless logged_in?
      redirect_to login_path, alert: 'Please log in'
    end
  end
  
  def logged_in?
    session[:user_id].present? && current_user.present?
  end
  
  def current_user
    @current_user ||= User.find_by(id: session[:user_id])
  end
  helper_method :current_user
  
  def check_session_timeout
    last_activity = session[:last_activity]
    
    if last_activity && last_activity < 30.minutes.ago
      reset_session
      redirect_to login_path, alert: 'Session expired'
    else
      session[:last_activity] = Time.current
    end
  end
end

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include SessionAuthenticatable
end

Database-Backed Sessions (Production)

# Gemfile
gem 'activerecord-session_store'

# config/initializers/session_store.rb
Rails.application.config.session_store :active_record_store,
  key: '_app_session',
  secure: Rails.env.production?,
  httponly: true,
  same_site: :lax

# Generate migration
# rails generate active_record:session_migration
# rails db:migrate

# app/models/session.rb
class Session < ApplicationRecord
  def self.sweep(time = 30.days.ago)
    where('updated_at < ?', time).delete_all
  end
end

# Cleanup task
# lib/tasks/sessions.rake
namespace :sessions do
  desc 'Clean up expired sessions'
  task cleanup: :environment do
    Session.sweep(30.days.ago)
    puts "Expired sessions cleaned up"
  end
end

Hybrid Approach: JWT + Cookies for Multi-Client

Serve web clients with cookies, mobile with JWT:

# app/controllers/concerns/flexible_authentication.rb
module FlexibleAuthentication
  extend ActiveSupport::Concern
  
  included do
    before_action :authenticate_user
  end
  
  private
  
  def authenticate_user
    if jwt_request?
      authenticate_with_jwt
    else
      authenticate_with_session
    end
  end
  
  def jwt_request?
    request.headers['Authorization'].present?
  end
  
  def authenticate_with_jwt
    token = request.headers['Authorization']&.split(' ')&.last
    payload = JwtService.decode(token)
    
    unless payload
      render json: { error: 'Unauthorized' }, status: :unauthorized
      return
    end
    
    @current_user = User.find_by(id: payload[:user_id])
    unless @current_user
      render json: { error: 'User not found' }, status: :unauthorized
    end
  end
  
  def authenticate_with_session
    unless session[:user_id]
      redirect_to login_path, alert: 'Please log in'
      return
    end
    
    @current_user = User.find_by(id: session[:user_id])
    unless @current_user
      reset_session
      redirect_to login_path, alert: 'Session invalid'
    end
  end
  
  def current_user
    @current_user
  end
  helper_method :current_user
end

This allows a single API to serve:

  • React Native apps (JWT with Authorization: Bearer <token>)
  • React SPAs (cookies with session)
  • Server-rendered Hotwire (cookies with session)

Security Best Practices

1. Always Use HTTPS in Production

# config/environments/production.rb
config.force_ssl = true  # Redirects HTTP to HTTPS

# config/initializers/secure_headers.rb
SecureHeaders::Configuration.default do |config|
  config.hsts = "max-age=31536000; includeSubDomains; preload"
end

2. Implement Rate Limiting

# Gemfile
gem 'rack-attack'

# config/initializers/rack_attack.rb
Rack::Attack.throttle('login/ip', limit: 5, period: 60) do |req|
  req.ip if req.path == '/api/v1/login' && req.post?
end

Rack::Attack.throttle('login/email', limit: 5, period: 60) do |req|
  if req.path == '/api/v1/login' && req.post?
    req.params['email'].presence
  end
end

3. Rotate JWT Secrets Regularly

# Support multiple valid secrets during rotation
module JwtService
  CURRENT_SECRET = Rails.application.credentials.dig(:jwt, :secret_key)
  OLD_SECRETS = [
    Rails.application.credentials.dig(:jwt, :old_secret_key_1)
  ].compact
  
  def self.decode(token)
    # Try current secret first
    begin
      decoded = JWT.decode(token, CURRENT_SECRET, true, algorithm: 'HS256')
      return HashWithIndifferentAccess.new(decoded.first)
    rescue JWT::DecodeError
      # Try old secrets during rotation period
      OLD_SECRETS.each do |secret|
        begin
          decoded = JWT.decode(token, secret, true, algorithm: 'HS256')
          return HashWithIndifferentAccess.new(decoded.first)
        rescue JWT::DecodeError
          next
        end
      end
    end
    
    nil
  end
end

4. Log Authentication Failures

# app/controllers/api/v1/authentication_controller.rb
def login
  user = User.find_by(email: params[:email])&.authenticate(params[:password])
  
  unless user
    Rails.logger.warn("Failed login attempt", {
      email: params[:email],
      ip: request.remote_ip,
      user_agent: request.user_agent,
      timestamp: Time.current
    })
    
    return render json: { error: 'Invalid credentials' }, status: :unauthorized
  end
  
  # ... successful login
end

5. Implement Token Refresh Flow

Short-lived access tokens (15 min) with long-lived refresh tokens (7 days):

// Frontend token management
class AuthService {
  private accessToken: string | null = null;
  private refreshToken: string | null = null;
  
  async refreshAccessToken() {
    const response = await fetch('/api/v1/refresh', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refresh_token: this.refreshToken })
    });
    
    const { access_token } = await response.json();
    this.accessToken = access_token;
    return access_token;
  }
  
  async authenticatedFetch(url: string, options: RequestInit = {}) {
    let response = await fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${this.accessToken}`
      }
    });
    
    // If 401, try refreshing token
    if (response.status === 401) {
      await this.refreshAccessToken();
      response = await fetch(url, {
        ...options,
        headers: {
          ...options.headers,
          'Authorization': `Bearer ${this.accessToken}`
        }
      });
    }
    
    return response;
  }
}

Production Operational Concerns

Monitoring Authentication

# config/initializers/instrumentation.rb
ActiveSupport::Notifications.subscribe('authenticate.jwt') do |*args|
  event = ActiveSupport::Notifications::Event.new(*args)
  
  Metrics.increment('auth.jwt.attempts')
  Metrics.timing('auth.jwt.duration', event.duration)
  
  if event.payload[:success]
    Metrics.increment('auth.jwt.success')
  else
    Metrics.increment('auth.jwt.failure')
    Rails.logger.warn("JWT auth failed: #{event.payload[:error]}")
  end
end

# In authentication code
def authenticate_with_jwt
  ActiveSupport::Notifications.instrument('authenticate.jwt') do |payload|
    token = extract_token
    jwt_payload = JwtService.decode(token)
    
    if jwt_payload
      @current_user = User.find_by(id: jwt_payload[:user_id])
      payload[:success] = @current_user.present?
    else
      payload[:success] = false
      payload[:error] = 'Invalid token'
    end
  end
end

Session Cleanup Jobs

# app/jobs/session_cleanup_job.rb
class SessionCleanupJob < ApplicationJob
  queue_as :low_priority
  
  def perform
    # ActiveRecord sessions
    Session.where('updated_at < ?', 30.days.ago).delete_all
    
    # JWT refresh tokens
    RefreshToken.cleanup_expired
    
    Rails.logger.info("Session cleanup completed")
  end
end

# config/initializers/good_job.rb (or Sidekiq)
GoodJob.cron.set({
  session_cleanup: {
    cron: '0 2 * * *',  # Run at 2 AM daily
    class: 'SessionCleanupJob'
  }
})

Authentication Decision Checklist

Choose JWT If:

Choose Session Cookies If:

Use Hybrid Approach If:

Conclusion: Authentication That Fits Your Architecture

The JWT vs. session cookie decision isn't about which is "better"—it's about which fits your client architecture and operational constraints. JWT excels for mobile-first and API-driven applications where stateless scaling and cross-platform token storage matter most. Session cookies excel for browser-centric applications where built-in Rails conventions, CSRF protection, and instant revocation provide the fastest path to secure authentication.

The patterns and implementations in this guide give you production-ready starting points for both approaches. Start with your primary client type: If it's mobile apps, lean toward JWT with refresh tokens. If it's web browsers with server-side rendering, lean toward session cookies. If you're serving both equally, implement the hybrid approach that detects client type and routes to the appropriate authentication method.

The security best practices—HTTPS enforcement, rate limiting, secret rotation, authentication logging—apply regardless of your choice. Build those in from day one rather than retrofitting them later under pressure.

For API architecture beyond authentication, see /blog/rails-api-best-practices for error handling, versioning, and response formatting. For mobile-specific secure storage implementation, reference /blog/react-native-expo-modern-guide.

Take the Next Step

Need to architect secure, scalable authentication for Rails APIs serving web and mobile clients? Elaris can design your authentication strategy, implement JWT or session-based systems with proper revocation, integrate with OAuth providers for social login, set up rate limiting and monitoring, and establish security practices that meet compliance requirements.

We've secured APIs handling millions of authentication requests daily, implemented zero-downtime secret rotation, and built hybrid systems serving React SPAs, React Native apps, and third-party integrations from a single Rails backend. Our team can audit your existing auth system, identify vulnerabilities, and implement hardened production-ready authentication.

Contact us to schedule a security assessment and lock down your Rails API authentication.

[ Let's Build Together ]

Ready to transform your
business with software?

From strategy to implementation, we craft digital products that drive real business outcomes. Let's discuss your vision.

Related Topics:
Rails authentication JWT cookies API 2025