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:
- Client sends credentials (email/password)
- Server validates, generates signed JWT containing user_id and expiration
- Client stores JWT (mobile: secure storage, web: localStorage/memory)
- Client includes JWT in Authorization header on every request
- 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:
- Client sends credentials
- Server validates, creates session record in database/Redis
- Server sets encrypted session cookie in response
- Browser automatically includes cookie on every request
- 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.