In this article
February 27, 2026
February 27, 2026

Building authentication in Rails web applications: The complete guide for 2026

Master secure authentication in Rails with production-ready patterns and enterprise features.

Authentication in Ruby on Rails has evolved significantly. With Rails 8 introducing a built-in authentication generator, Hotwire/Turbo changing how we think about real-time interactions, and enterprise B2B requirements becoming standard, Rails developers must understand both the framework's conventions and modern security practices.

This comprehensive guide covers everything you need to know about authentication in Rails: from core concepts and security patterns to implementation strategies and production best practices. Whether you're building authentication from scratch, using popular gems like Devise, or evaluating managed solutions like WorkOS, you'll gain the knowledge to make informed decisions for your Rails application.

Understanding authentication in Rails

Rails approaches authentication with strong opinions about convention over configuration. Unlike frameworks that require you to piece together authentication from separate libraries, Rails provides a cohesive ecosystem where authentication integrates naturally with the MVC pattern, Active Record, and Action Controller.

The Rails request-response cycle

Understanding how Rails processes requests is foundational to implementing authentication correctly:

  1. Request arrives → Rack middleware receives the HTTP request before it reaches your Rails application. This is the earliest point where you can inspect cookies and headers.
  2. Rails router → Routes.rb matches the request to a controller action. You can use authenticate blocks in routes to protect entire sections of your application.
  3. Controller filtersbefore_action callbacks run before your controller action. This is where most authentication checks happen: verifying the user is logged in before executing business logic.
  4. Controller action → Your controller method processes the authenticated request, interacting with models and preparing data for views.
  5. View rendering → ERB templates render with automatic XSS protection. Rails escapes output by default, protecting against script injection.
  6. Response → Rails sends the HTTP response back through the Rack middleware stack, where session cookies are encrypted and set.

The key insight here is that in Rails authentication happens in controller filters. Unlike some frameworks where middleware handles everything, Rails puts authentication logic in controllers, giving you fine-grained control per action while maintaining the framework's conventions.

Rails philosophy: Convention over configuration

Rails follows several conventions that protect you:

  • Bcrypt password hashing by default (since Rails 3.1): Rails' has_secure_password method uses bcrypt to hash passwords automatically. Bcrypt is a deliberately slow hashing algorithm (taking 200-300ms) that includes automatic salting and makes brute-force attacks impractical. You don't configure work factors or salt generation, Rails chooses secure defaults. You just add has_secure_password to your User model and Rails handles the rest, storing hashed passwords in a password_digest column. This means even if your database is compromised, attackers cannot recover the actual passwords.
	
class User < ApplicationRecord
  has_secure_password
  # Rails automatically:
  # - Hashes passwords with bcrypt (cost factor 12)
  # - Adds password and password_confirmation attributes
  # - Validates password presence on create
  # - Provides authenticate(password) method
end
  
  • Automatic CSRF protection via protect_from_forgery: Rails includes CSRF (Cross-Site Request Forgery) protection automatically in all controllers. Every form generated with Rails helpers includes a hidden authenticity_token field. When the form is submitted, Rails verifies this token matches the one in the session. This prevents malicious websites from submitting forms to your application using a victim's session cookie. You don't write any CSRF protection code, it's enabled by default in ApplicationController and works transparently through Rails' form helpers.
	
# ApplicationController (Rails default)
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  # Rails automatically:
  # - Generates unique token per session
  # - Includes token in form helpers
  # - Verifies token on non-GET requests
  # - Raises exception if token invalid
end
  
  • Encrypted session cookies (since Rails 5.2): Rails encrypts all session data before storing it in cookies, using your application's secret_key_base as the encryption key. This means session data cannot be read or tampered with by users. Before Rails 5.2, sessions were only signed (tamper-evident but readable). Now they're encrypted (both tamper-proof and unreadable). You don't configure encryption algorithms or key management, Rails handles it automatically. This protects sensitive session data like user IDs from being exposed to browser inspection.
  • Strong parameters preventing mass assignment: Rails requires you to explicitly permit which parameters can be used for mass assignment. This prevents attackers from submitting unexpected parameters (like admin=true) that could elevate their privileges. Without strong parameters, a malicious user could add &user[admin]=true to a form submission and potentially grant themselves admin access. Rails forces you to be explicit about which fields are allowed, making this attack impossible by default.
	
# Controller (Rails convention)
def create
  @user = User.new(user_params)
  # Only permitted params are used
end

private

def user_params
  # Explicitly whitelist allowed parameters
  params.require(:user).permit(:email, :password, :name)
  # If attacker submits user[admin]=true, it's silently ignored
  # Only email, password, name are allowed
end
  
  • Automatic HTML escaping in views: Rails automatically escapes all HTML in ERB templates using the <%= %> tag. This means user-submitted content like <script>alert('xss')</script> becomes &lt;script&gt;alert('xss')&lt;/script&gt; : visible text instead of executable code. This prevents Cross-Site Scripting (XSS) attacks where malicious users inject JavaScript to steal credentials or session cookies. You only need to be aware of this when you intentionally want to render HTML (using raw() or .html_safe), which should be rare and carefully controlled.

This "secure by default" approach means authentication in Rails is less about fighting the framework and more about leveraging its built-in protections while adding your application-specific logic on top.

Rails 8 authentication generator

Rails 8 introduces a significant addition: a built-in authentication generator that provides a solid foundation without the complexity of full-featured gems like Devise.

Running bin/rails generate authentication creates:

  • Models:
    • User model with email, password_digest, and standard fields.
    • Session model for database-backed session tracking.
    • Bcrypt for password hashing.
  • Controllers:
    • SessionsController for login/logout.
    • Password reset controller and mailers.
    • Email/password authentication flow.
  • Views:
    • Login form
    • Password reset request form.
    • Password reset form.
  • Migrations:
    • Users table with proper indexes.
    • Sessions table for server-side session storage.

What it doesn't include:

  • User registration/sign-up flow (you build this).
  • Social OAuth integration.
  • Two-factor authentication.
  • Enterprise SSO.
  • User roles/permissions.

The generator gives you a clean starting point that follows Rails conventions. Think of it as "authentication scaffolding": a foundation you build upon, not a complete solution.

Critical security considerations

Rails provides strong security defaults, but understanding common vulnerabilities and how Rails protects against them is essential for building secure authentication systems.

SQL Injection protection

Rails' Active Record ORM automatically parameterizes queries, making SQL injection very difficult.

	
# ✅ SAFE: Active Record parameterizes automatically
User.where(email: params[:email])
User.find_by(email: params[:email])

# ✅ SAFE: Named placeholders
User.where("email = ? AND active = ?", params[:email], true)
  

However, you can still introduce vulnerabilities:

	
# ❌ DANGEROUS: String interpolation
User.where("email = '#{params[:email]}'")
# Input: ' OR '1'='1 bypasses authentication

# ❌ DANGEROUS: Raw SQL with user input
ActiveRecord::Base.connection.execute(
  "SELECT * FROM users WHERE email = '#{params[:email]}'"
)
  

Always use Active Record's query methods or properly parameterized raw SQL.

Cross-Site Scripting (XSS) protection

Rails automatically escapes output in ERB templates, but you must understand when this protection applies:

	
<%# ✅ SAFE: Automatic HTML escaping %>
<p>Welcome, <%= @user.name %></p>
<%# If @user.name = "<script>alert('xss')</script>"
    Output: Welcome, <script>alert('xss')</script> %>

<%# ✅ SAFE: Using sanitize for controlled HTML %>
<div><%= sanitize @user.bio, tags: ['p', 'br', 'strong'] %></div>

<%# ❌ DANGEROUS: raw() disables escaping %>
<p>Welcome, <%= raw @user.name %></p>
<%# Script executes! %>

<%# ❌ DANGEROUS: html_safe marks as safe %>
<p>Welcome, <%= @user.name.html_safe %></p>

<%# ❌ DANGEROUS: == operator in older ERB %>
<p>Welcome, <%== @user.name %></p>
  

XSS in authentication pages can steal session cookies, capture login credentials, or modify forms to send credentials to attackers. Never use raw() or .html_safe with user-provided content.

Cross-Site Request Forgery (CSRF) protection

When you enable CSRF protection in your ApplicationController (which Rails does by default), every non-GET request must include a valid authenticity token. Rails generates a unique token per session and embeds it in your forms automatically.

	
# ApplicationController (enabled by default)
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
end
  

The with: :exception parameter tells Rails to raise an exception if a request comes in without a valid token, blocking the attack immediately rather than silently ignoring it.

When you use Rails' form helpers (form_with, form_for, form_tag) the authenticity token is automatically included as a hidden field. When the form submits, Rails verifies the token matches the one stored in the user's session. If an attacker tries to submit this form from their malicious website, they won't have access to the victim's session token, so the request will be rejected.

Sometimes though, API controllers skip CSRF protection because they use alternative authentication methods (API keys, JWT tokens, OAuth) instead of session cookies. Since API requests don't rely on browser-stored session cookies, CSRF attacks aren't possible (the attacker would need the API key itself, not just the victim's browser session).

	
# API controllers often skip CSRF for token-based auth
class Api::V1::BaseController < ApplicationController
  skip_before_action :verify_authenticity_token
  # Must use alternative auth (API keys, JWT, OAuth tokens)
end
  

When you skip CSRF protection, you're explicitly stating: "This endpoint doesn't use session-based authentication, so CSRF isn't a risk." However, you must implement proper alternative authentication. Never skip CSRF protection on endpoints that rely on session cookies, as this would leave them completely vulnerable to CSRF attacks.

Session security

Rails encrypts session cookies by default (since Rails 5.2), but proper configuration is still critical:

	
# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store,
  key: '_myapp_session',
  secure: Rails.env.production?,    # HTTPS only in production
  httponly: true,                     # No JavaScript access
  same_site: :lax                     # CSRF protection

# config/application.rb
config.force_ssl = true if Rails.env.production?  # Redirect HTTP to HTTPS
  
  • secure: true → Cookies only sent over HTTPS, preventing network sniffing on public WiFi.
  • httponly: true → JavaScript cannot access cookies via document.cookie, preventing XSS cookie theft.
  • same_site: :lax → Prevents CSRF attacks while allowing normal navigation from other sites.

Password security and bcrypt protections

Rails uses bcrypt by default for password hashing, which is secure and industry-standard.

Bcrypt is deliberately slow: taking 250-300ms per password hash. This slowness is intentional and protects you: it makes brute-force attacks on stolen password databases impractical. An attacker trying to guess passwords must spend 300ms per attempt, meaning they can only try 3-4 passwords per second. Against a database of thousands of hashed passwords, this makes rainbow tables and brute-force attacks infeasible.

However, this slowness also affects your application's performance. Every login requires bcrypt to hash the provided password and compare it to the stored hash. Under normal traffic this is fine, but if you're rate-limiting logins improperly or experiencing a legitimate spike in authentication requests, you might see performance degradation.

	
# User model with has_secure_password (Rails default)
class User < ApplicationRecord
  has_secure_password
  
  # This gives you:
  # - password and password_confirmation attributes
  # - authenticate(password) method
  # - automatic bcrypt hashing
  # - password validations
end

# In controller
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
  session[:user_id] = user.id
  redirect_to dashboard_path
else
  flash.now[:alert] = "Invalid email or password"
  render :new
end
  

The has_secure_password method integrates bcrypt seamlessly. When you set user.password = "secret123", Rails automatically hashes it with bcrypt before storing it in password_digest. The authenticate method handles the comparison using bcrypt's secure comparison, which is designed to take constant time regardless of whether the password matches (preventing timing attacks).

Rails uses bcrypt with a default cost of 12 (since Rails 6.0), which takes approximately 250-300ms to hash a password. This is the work factor: a number that determines how many iterations bcrypt performs. Each increment doubles the time required.

Should you change the work factor? Probably not. Rails' default of 12 is appropriate for 2026 hardware. Increasing it to 13 or 14 makes brute-force attacks even harder but also slows legitimate logins. Only increase the work factor if:

  • Your application handles extremely sensitive data (banking, healthcare).
  • You have dedicated security requirements mandating higher costs.
  • You've measured and can accept the performance impact.

When might you decrease the work factor? Very rarely, and only in specific scenarios:

  • Test environments to speed up your test suite (bcrypt dominates test time).
  • Development environments for faster page reloads.
  • Never in production; the slowness is the security.
	
# To adjust bcrypt cost (rarely needed)
class User & ApplicationRecord
  has_secure_password validations: false  # Disable default validations
  
  validates :password, length: { minimum: 12 }, if: -> { password.present? }
  
  # Customize bcrypt cost
  def password=(new_password)
    @password = new_password
    self.password_digest = BCrypt::Password.create(new_password, cost: 13)
  end
end

# In test environment only
# test/test_helper.rb
BCrypt::Engine.cost = 4  # Much faster for tests
  

If you're experiencing performance problems during login, the solution is almost never to lower the bcrypt cost. Instead:

  • Implement proper rate limiting (5 attempts per hour per email).
  • Add caching for expensive operations after authentication.
  • Use Redis for session storage instead of database queries.
  • Optimize database queries in your authentication flow.
  • Consider horizontal scaling if authentication becomes a bottleneck.

Password requirements

  • Minimum 8 characters (12+ strongly recommended).
  • Never store passwords in plain text.
  • Use bcrypt (Rails default), Argon2, or PBKDF2.
  • Rate limit login attempts (5 per hour per email).
  • Implement account lockout after repeated failures.
  • Consider password strength meters client-side.
  • Check passwords against known breach databases (HaveIBeenPwned API).
  • Never decrease bcrypt cost in production.
  • Don't implement password complexity requirements; length matters more than special characters.

Authentication implementation approaches

Rails offers multiple ways to implement authentication, from using the built-in generator to established gems to building custom solutions to using auth providers.

Approach 1: Rails 8 authentication generator

Rails 8 introduces a built-in authentication generator that gives you a clean, minimal starting point. Unlike gems that add layers of abstraction, the generator creates actual code files in your application that you can read, understand, and modify.

The generator creates User and Session models, a SessionsController for login/logout, and database migrations. It doesn't include user registration (you build that yourself based on your requirements), social OAuth, or enterprise features; it focuses on the authentication core.

The Rails 8 generator provides a minimal, conventional starting point:

	
# Generate authentication
bin/rails generate authentication

# Creates:
# - app/models/user.rb
# - app/models/session.rb
# - app/controllers/sessions_controller.rb
# - app/views/sessions/
# - db/migrate/...
  

Implementation example:

	
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def new
    # GET /session/new - Login form
  end

  def create
    # POST /session - Process login
    @user = User.find_by(email: params[:email])
    
    if @user&.authenticate(params[:password])
      @session = @user.sessions.create!
      cookies.signed.permanent[:session_token] = { value: @session.id, httponly: true }
      redirect_to root_path, notice: "Signed in successfully"
    else
      flash.now[:alert] = "Invalid email or password"
      render :new, status: :unprocessable_entity
    end
  end

  def destroy
    # DELETE /session - Logout
    if session = Session.find_by(id: cookies.signed[:session_token])
      session.destroy
    end
    cookies.delete(:session_token)
    redirect_to login_path, notice: "Signed out successfully"
  end
end

# app/controllers/concerns/authentication.rb
module Authentication
  extend ActiveSupport::Concern

  included do
    before_action :require_authentication
  end

  private

  def require_authentication
    unless current_user
      redirect_to login_path, alert: "Please sign in to continue"
    end
  end

  def current_user
    @current_user ||= begin
      session_token = cookies.signed[:session_token]
      session = Session.find_by(id: session_token)
      session&.user
    end
  end
  
  helper_method :current_user
end

# In controllers that need authentication
class DashboardController < ApplicationController
  include Authentication

  def show
    # current_user available here
    @projects = current_user.projects
  end
end
  

When to use this approach:

  • Rails 8+ applications.
  • You want minimal dependencies.
  • Authentication requirements are straightforward.
  • You prefer explicit, readable code you control.

Approach 2: Devise gem

Devise is the most established authentication solution in the Rails ecosystem, with over 13 years of production use and millions of downloads. It provides user registration, email confirmation, password reset, remember me, account locking, session timeout, and more.

The trade-off with Devise is comprehensiveness versus complexity. Installing Devise adds significant code and conventions to your application. It's highly opinionated; following Devise patterns rather than always following Rails conventions.

With Devise you generate a User model, configure which Devise modules you want (database authentication, registerable, recoverable, etc.), and Devise handles the rest: controllers, views, mailers, routes.

Implementation example:

	
# Gemfile
gem 'devise'

# Install Devise
rails generate devise:install
rails generate devise User
rails db:migrate

# app/models/user.rb (generated)
class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :confirmable, :lockable, :timeoutable, :trackable
end

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :authenticate_user!
  
  # Devise provides these methods:
  # - authenticate_user!
  # - user_signed_in?
  # - current_user
  # - user_session
end

# Protecting specific actions
class ArticlesController < ApplicationController
  before_action :authenticate_user!, except: [:index, :show]
  
  def create
    @article = current_user.articles.build(article_params)
    # ...
  end
end
  

What Devise provides:

  • User registration with confirmable emails.
  • Password reset functionality.
  • "Remember me" functionality.
  • Session timeout.
  • Account locking after failed attempts.
  • User tracking (sign-in count, IPs, timestamps).
  • OAuth integration (with devise-omniauth).
  • Two-factor authentication (with devise-two-factor).

When to use Devise:

  • You need comprehensive authentication quickly.
  • Standard authentication flows are sufficient.
  • You're comfortable with Devise's conventions and defaults.

Devise considerations:

  • Highly opinionated (follows Devise conventions, not always Rails conventions).
  • Can be overwhelming for simple needs.
  • Customizing views/workflows requires learning Devise patterns.
  • Adds significant code to your application.
  • Enterprise features like SSO, SCIM, and audit logs require significant custom work.
  • Multi-tenancy is entirely application-defined.
  • Operational concerns like suspicious login detection and compliance logging are DIY.
  • Devise can accumulate complexity over time as requirements grow.

Approach 3: Rodauth gem

Rodauth takes a different philosophy from Devise: security and flexibility over convenience. While Devise aims for ease of use with sensible defaults, Rodauth is designed for applications where authentication security is critical and you need precise control over every aspect of the authentication flow.

Rodauth implements security best practices by default: constant-time string comparisons to prevent timing attacks, proper token generation, and secure session handling. It offers over 60 authentication features you can enable individually: from basic login/logout to advanced features like WebAuthn (passwordless), passkeys, audit logging, and JWT support. You configure exactly what you need, nothing more.

The Rodauth approach is more explicit than Devise. You write configuration code specifying how authentication should work rather than relying on Devise's conventions. This makes it more work upfront but gives you complete transparency and control. For security-conscious applications, fintech products, or healthcare applications where authentication auditing is critical, Rodauth's granular control is valuable.

Implementation example:

	
# Gemfile
gem 'rodauth-rails'

# Install and configure
rails generate rodauth:install

# config/initializers/rodauth.rb
class RodauthMain < Rodauth::Rails::Auth
  configure do
    enable :login, :logout, :remember,
           :reset_password, :change_password,
           :verify_account, :lockout

    # Rodauth is very configurable
    account_password_hash_column :password_hash
    require_login_confirmation? false
    verify_account_grace_period 3.days
  end
end

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  private

  def current_user
    rodauth.rails_account
  end
  helper_method :current_user

  def require_authentication
    rodauth.require_authentication
  end
end
  

What Rodauth provides:

  • Security-first design (constant-time comparisons, timing attack protection).
  • Extensive feature set (60+ features).
  • Very granular control over every aspect.
  • JWT support built-in.
  • Audit logging.
  • WebAuthn/passkey support.

When to use Rodauth:

  • Security is your top priority.
  • You need fine-grained control over authentication behavior.
  • You want modern features (WebAuthn, passkeys).
  • You're comfortable with more configuration.

Rodauth considerations:

  • Steeper learning curve than Devise.
  • Minimal built-in UI or user management tooling.
  • No enterprise features like SSO or SCIM out of the box.
  • You own operational tooling, monitoring, and incident response.

Approach 4: WorkOS AuthKit

WorkOS AuthKit provides enterprise-ready authentication as a service (hosted or on prem), eliminating the need to build and maintain authentication infrastructure yourself. Unlike the previous approaches that run entirely in your Rails application, AuthKit handles authentication flows on WorkOS's secure infrastructure, then returns authenticated users to your application.

The fastest way to integrate AuthKit is using the WorkOS CLI, which detects your Rails setup, installs the Ruby SDK, and generates the integration code automatically:

	
# One command to set up WorkOS AuthKit
npx workos@latest install
  

The CLI takes care of everything you would normally do manually:

  1. Detects your framework: Identifies your framework and version from your project’s dependencies and file structure.
  2. Authenticates your account: Opens your browser for secure WorkOS sign-in.
  3. Configures your dashboard: Sets redirect URIs, CORS origins, and homepage URL automatically.
  4. Installs the right SDK: Adds the correct AuthKit package for your framework.
  5. Analyzes your project :Reads your project structure to understand routing, existing middleware, and configuration.
  6. Creates routes and middleware: Writes OAuth callback routes, auth middleware/proxy, and provider wrappers.
  7. Sets up environment variables: Writes API keys and configuration to .env.local
  8. Validates the integration: Runs your build to verify everything compiles without errors.

The CLI understands framework-specific nuances  and generates the appropriate code for your setup. If you have existing middleware or configuration, it composes with it rather than replacing it.

You can also set up the WorkOS integration manually. The pattern is straightforward: redirect users to WorkOS's hosted authentication, then handle the callback when they return authenticated.

Implementation example:

	
# Gemfile
gem 'workos'

# config/initializers/workos.rb
WorkOS.configure do |config|
  config.key = Rails.application.credentials.workos[:api_key]
end

# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def login
    # Redirect to WorkOS hosted authentication
    authorization_url = WorkOS::UserManagement.authorization_url(
      provider: 'authkit',
      client_id: ENV['WORKOS_CLIENT_ID'],
      redirect_uri: callback_url
    )
    redirect_to authorization_url, allow_other_host: true
  end

  def callback
    # Exchange authorization code for authenticated user
    auth_response = WorkOS::UserManagement.authenticate_with_code(
      client_id: ENV['WORKOS_CLIENT_ID'],
      code: params[:code],
      session: {
        seal_session: true,
        cookie_password: ENV['WORKOS_COOKIE_PASSWORD']
      }
    )

    # Store encrypted session in cookie
    cookies.encrypted[:wos_session] = {
      value: auth_response.sealed_session,
      httponly: true,
      secure: Rails.env.production?,
      same_site: :lax
    }

    redirect_to root_path, notice: 'Signed in successfully'
  end

  def destroy
    # Redirect to WorkOS logout URL
    session_data = cookies.encrypted[:wos_session]
    
    if session_data
      session = WorkOS::UserManagement.load_sealed_session(
        client_id: ENV['WORKOS_CLIENT_ID'],
        session_data: session_data,
        cookie_password: ENV['WORKOS_COOKIE_PASSWORD']
      )
      
      logout_url = session.get_logout_url
      cookies.delete(:wos_session)
      redirect_to logout_url, allow_other_host: true
    else
      cookies.delete(:wos_session)
      redirect_to root_path
    end
  end
end

# app/controllers/concerns/authentication.rb
module Authentication
  extend ActiveSupport::Concern

  private

  def current_user
    return @current_user if defined?(@current_user)
    
    @current_user = begin
      session_data = cookies.encrypted[:wos_session]
      return nil unless session_data

      session = WorkOS::UserManagement.load_sealed_session(
        client_id: ENV['WORKOS_CLIENT_ID'],
        session_data: session_data,
        cookie_password: ENV['WORKOS_COOKIE_PASSWORD']
      )

      auth_result = session.authenticate
      
      # Create or update user in your database from WorkOS data
      if auth_result[:authenticated] && auth_result[:user]
        user_data = auth_result[:user]
        User.find_or_create_by(workos_id: user_data[:id]) do |u|
          u.email = user_data[:email]
          u.first_name = user_data[:first_name]
          u.last_name = user_data[:last_name]
        end
      end
    rescue StandardError => e
      Rails.logger.error "WorkOS authentication error: #{e.message}"
      nil
    end
  end
  helper_method :current_user
  
  def require_authentication
    redirect_to login_path, alert: 'Please sign in' unless current_user
  end
end
  

For more details on how to set up the integration, see the Ruby Quickstart.

What WorkOS provides:

  • Hosted authentication UI (no views to build).
  • Email/password, passwordless, social login, and MFA.
  • Enterprise SSO (SAML, OIDC) without additional code.
  • User management API.
  • Organization/team management; multi-tenant by default;
  • Automatic security updates and compliance.
  • 1 million monthly active users for free.
  • And more.

When to use WorkOS:

  • You are B2B and plan on selling to enterprises (and will therefore need enterprise features).
  • You want authentication fully managed and maintained so you can focus on building your product.
  • You need SSO and Directory Sync without building them.
  • You want to ship quickly without sacrificing enterprise features.

Session management strategies

How you store and validate sessions is one of the most important architectural decisions in your Rails authentication system. This choice affects four critical aspects of your application: security (can you immediately revoke compromised sessions?), performance (how fast is session validation?), scalability (can you add more servers easily?), and user experience (how often do users need to re-login?).

Rails' default cookie-based sessions are appropriate for the vast majority of applications. They're fast, simple, scale horizontally without any additional infrastructure, and have been the Rails default since 2007. Most Rails applications never need to change from the default.

However, specific requirements might push you toward server-side session storage: regulatory requirements mandating server-side storage, session data exceeding 4KB, or detailed audit trail requirements. When Rails applications do need server-side sessions, they typically use database-backed sessions with ActiveRecord rather than external stores.

Strategy 1: Cookie-based sessions (Rails default)

Rails' default session store is elegant in its simplicity: encrypt session data, send it to the client as a cookie, and decrypt it on subsequent requests. No database, no Redis, no server-side storage at all. The entire session lives in the user's browser, encrypted so they can't read or tamper with it.

This stateless approach has been Rails' default since Rails 2.0 (2007) and remains the default in Rails 8 for good reason. It's "dramatically faster than the alternatives" (per Rails documentation), requires zero maintenance, works without any database, and scales horizontally perfectly. Add 10 more Rails servers and they all validate sessions independently using the same secret_key_base ; no shared session store needed.

Rails encrypts cookies by default (since Rails 4), so the client cannot read or edit session contents without breaking encryption. Sessions typically contain just a user ID and flash message; both easily fit within the 4KB cookie limit. For the vast majority of Rails applications, cookie sessions provide the ideal balance of simplicity, security, and performance.

The main limitation is immediate revocation. Once Rails sends a session cookie to a user, that cookie remains valid until it expires. If you need to immediately log out a user (perhaps their account was compromised), you can't "delete" their session; it lives in their browser, not on your server. The cookie will continue working until expiration. For some applications, this trade-off is acceptable. For others, not.

Rails' default session store encrypts session data and stores it in cookies:

	
# config/initializers/session_store.rb (Rails default)
Rails.application.config.session_store :cookie_store,
  key: '_myapp_session',
  secure: Rails.env.production?,
  httponly: true,
  same_site: :lax

# Storing data in session
session[:user_id] = user.id
session[:login_time] = Time.current

# Reading from session
current_user_id = session[:user_id]
  

Rails serializes your session hash, encrypts it with your application's secret_key_base, and sends it to the client as a cookie. On subsequent requests, Rails decrypts and deserializes the cookie to restore the session.The encryption ensures users cannot read or tamper with session data.

Pros:

  • No server-side storage needed (stateless).
  • Scales horizontally trivially (no shared session store).
  • Fast (~5ms to decrypt and validate).
  • Simple infrastructure (no Redis/database required).
  • Zero maintenance (no cleanup jobs, no growing tables).
  • Rails default since 2007; proven at massive scale.

Cons:

  • Size limited to 4KB.
  • Can't immediately invalidate sessions (cookie lives until expiration).
  • All session data sent with every request.
  • If secret_key_base leaks, all sessions compromised.

When to use cookie sessions:

  • Standard Rails applications (default choice).
  • Applications storing minimal session data (user ID, preferences).
  • Startups prioritizing simplicity and speed.
  • Applications that don't need immediate session revocation.
  • Any application without specific regulatory requirements.

Strategy 2: Database-backed sessions

With database sessions instead of storing everything in the client's browser, you store a random session ID in the cookie and keep all session data server-side in your database. This gives you complete control: you own the data, you can inspect it, query it, and most importantly, delete it instantly to revoke access.

This control is essential for certain applications. If a user reports their account as compromised, you can find their session in the database and delete it immediately. Their next request will be rejected even if they still have the cookie. If you need to see all active sessions across devices ("You're logged in on iPhone, MacBook, and Windows PC - sign out others?"), the database makes this straightforward. If compliance requires detailed audit trails of when users logged in from which IP addresses, database sessions provide the necessary data structure.

The trade-off is performance and infrastructure burden. Every authenticated request now requires a database query to look up the session. At 30-50ms per query, this adds noticeable latency. Your database becomes critical to request handling, not just data storage. For high-traffic applications, this means careful connection pooling, query optimization, and potentially higher infrastructure costs. You also need to clean up old sessions periodically; the sessions table grows continuously without maintenance.

Despite these costs, database sessions remain popular for applications where immediate revocation and detailed auditing outweigh performance concerns.

Store sessions in your database for complete control:

	
# Gemfile
gem 'activerecord-session_store'

# Install
rails generate active_record:session_migration
rails db:migrate

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

# Session model automatically created
# db/migrate/xxx_add_sessions_table.rb creates:
# - session_id (string, indexed)
# - data (text, serialized session hash)
# - created_at, updated_at

# Clean up old sessions periodically
# lib/tasks/sessions.rake
namespace :sessions do
  desc "Clear expired sessions"
  task :cleanup => :environment do
    ActiveRecord::SessionStore::Session
      .where("updated_at < ?", 30.days.ago)
      .delete_all
  end
end
# Schedule with whenever, sidekiq-cron, or cron
  

Pros:

  • Immediate session revocation (delete the database row).
  • No size limits on session data.
  • Detailed audit trail (see all active sessions).
  • Multi-device session management (list sessions per user).

Cons:

  • Database query per authenticated request (~30-50ms).
  • Requires database connection pooling.
  • Sessions table grows over time (needs cleanup job).
  • Additional database load.

When to use database sessions:

  • Regulatory requirements explicitly mandating server-side session storage.
  • Session data exceeding 4KB (user storing complex preferences, shopping cart data).
  • Detailed audit trails required for compliance (who logged in when, from which IP).
  • Multi-device session management (show users "You're logged in on iPhone, MacBook, Windows").
  • Immediate session revocation is critical (banking, healthcare, administrative systems).

Strategy 3: Cache store sessions

Rails can store sessions in your cache layer (Memcached or Redis) using cache_store. This is less common than database sessions but offers faster lookups while still providing server-side storage.

	
# config/initializers/session_store.rb
Rails.application.config.session_store :cache_store,
  key: '_myapp_session',
  secure: Rails.env.production?,
  httponly: true

# Requires cache configuration
# config/environments/production.rb
config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] }
  

This works smilarly to database sessions, but Rails stores session data in your cache (Memcached/Redis) instead of the database. Sessions automatically expire based on cache eviction policies or TTL settings.

Pros:

  • Faster than database sessions (~5-10ms vs ~30-50ms).
  • Automatic expiration via cache TTL.
  • Immediate revocation capability (delete from cache).
  • Lower latency than database queries.

Cons:

  • Requires cache infrastructure (Memcached/Redis).
  • Sessions can be evicted if cache fills up.
  • In-memory storage (need sufficient RAM).
  • More complex than cookies.
  • Less common in Rails ecosystem than database sessions.

When to use cache store sessions:

  • You already have Redis/Memcached for caching.
  • Need faster server-side sessions than database.
  • Automatic session expiration via TTL is acceptable.
  • Session loss from cache eviction is acceptable.

Strategy 4: JWT-based cookie sessions

This is a hybrid approach used by many modern authentication providers (including WorkOS): store a JWT (JSON Web Token) in an encrypted cookie. Unlike traditional Rails cookie sessions that store arbitrary session data, this approach stores a structured, signed JWT that can be validated without server-side storage.

The JWT contains claims about the user (user ID, email, roles, permissions) and is signed by the authentication provider. Your Rails application validates the JWT signature on each request to verify it hasn't been tampered with. The JWT itself is encrypted before being stored in the cookie for additional security.

	
get "/callback" do
  code = params["code"]

  begin
    auth_response = WorkOS::UserManagement.authenticate_with_code(
      client_id: ENV["WORKOS_CLIENT_ID"],
      code: code,
      session: {
        seal_session: true,
        cookie_password: ENV["WORKOS_COOKIE_PASSWORD"]
      }
    )

    # store the session in a cookie
    response.set_cookie(
      "wos_session",
      value: auth_response.sealed_session,
      httponly: true,
      secure: true,
      samesite: "lax"
    )

    # Use the information in auth_response for further business logic.

    redirect "/"
  rescue => e
    puts e
    redirect "/login"
  end
end
  

The key difference from Strategy 1 is that the JWT structure allows stateless validation. The encryption layer on top prevents tampering and protects the refresh token.

Pros:

  • Stateless validation (no database lookup needed).
  • Structured data with standard claims (user ID, roles, permissions).
  • Can be validated by multiple services (microservices architecture).
  • Cryptographically signed (tamper-proof).
  • Automatic expiration via JWT exp claim.
  • Refresh token rotation for security.

Cons:

  • JWT size limits (typically 3KB within cookie to stay under 4KB total).
  • Need to handle JWT validation, refresh token rotation.

When to use JWT-based cookie sessions:

  • Building microservices that need to share authentication.
  • Want stateless validation without database queries.
  • Need standardized token format for multiple services.

Performance optimization

Rails authentication performance directly impacts every protected endpoint in your application. A slow authentication check adds latency to every request, and under load, inefficient authentication can become your bottleneck. This section covers Rails-specific optimizations that keep authentication overhead minimal.

Database query optimization

Authentication adds a database query (or Redis lookup) to every authenticated request. Without optimization, this can add 50-100ms of latency per request and create bottlenecks under load. Proper indexing, N+1 prevention, and request-scoped caching can reduce authentication overhead to under 10ms.

1. Add proper indexes

Database indexes are critical for authentication queries. Without an index on users.email, Rails performs a full table scan to find users during login; acceptable with 100 users, catastrophic with 100,000. Indexes turn these O(n) operations into O(log n) lookups, reducing query time from hundreds of milliseconds to single-digit milliseconds.

	
# db/migrate/xxx_add_indexes_to_users.rb
class AddIndexesToUsers < ActiveRecord::Migration[7.1]
  def change
    add_index :users, :email, unique: true
    add_index :sessions, :user_id
    add_index :sessions, [:user_id, :created_at]
    
    # For database sessions - composite index for cleanup queries
    add_index :sessions, [:updated_at, :user_id]
    
    # For permission checks
    add_index :permissions, [:user_id, :action, :resource]
  end
end
  

The unique: true constraint on email serves double duty: it enforces data integrity (preventing duplicate accounts) and creates an index optimized for uniqueness checks. The composite index on [:user_id, :created_at] helps queries that filter by user and order by creation time, like "show me this user's 5 most recent sessions."

2. Use includes to avoid N+1 queries

N+1 queries are a common performance killer in Rails authentication flows. Use the Bullet gem in development to detect these automatically:

	
# Gemfile
group :development do
  gem 'bullet'
end

# config/environments/development.rb
config.after_initialize do
  Bullet.enable = true
  Bullet.alert = true
  Bullet.bullet_logger = true
  Bullet.console = true
  Bullet.rails_logger = true
end
  

Bullet will alert you to N+1 queries, unused eager loading, and missing counter caches. Fix authentication-related N+1 queries with includes:

	
# ❌ BAD: N+1 queries
def current_user
  @current_user ||= User.find_by(id: session[:user_id])
end

def index
  @articles = current_user.articles
  # Later in view: @articles.each { |a| a.user.name }  # N+1!
end

# ✅ GOOD: Eager load associations
def index
  @articles = current_user.articles.includes(:comments, :tags)
end
  

The includes method tells Rails to load all associations in 2-3 queries instead of N+1. For 100 articles with comments and tags, this reduces query count from 200+ to 3.

3. Use select and pluck to avoid loading unnecessary data

When you only need specific fields (like counting sessions or checking permissions), don't load entire ActiveRecord objects:

	
# ❌ BAD: Loads all User columns into memory
def active_user_count
  User.where(active: true).count  # Good
  User.where(active: true).size   # Loads all users into memory - BAD
end

# ✅ GOOD: Only load what you need
def user_emails_for_notification
  User.where(notify: true).pluck(:email)  # Returns array of emails
end

# ✅ GOOD: Select specific columns
def current_user
  User.select(:id, :email, :name, :role)
      .find_by(id: session[:user_id])
end
  

pluck returns an array of values without instantiating ActiveRecord objects. select loads only specified columns, reducing memory usage and query transfer time. For authentication checks that just need a user ID or boolean, this can be 10x faster than loading the full User object.

4. Cache expensive lookups

Request-scoped caching prevents redundant database queries within a single request. If your authentication filter runs before every action, and your view helper checks current_user 5 times, you don't want 6 database queries - you want 1 query cached in memory.

	
# Cache user object for the duration of the request
def current_user
  return @current_user if defined?(@current_user)
  
  @current_user = begin
    user_id = session[:user_id] || cookies.signed[:user_id]
    User.find_by(id: user_id) if user_id
  end
end
  

The defined?(@current_user) check is crucial - it distinguishes between "not cached yet" (nil) and "cached but user is nil" (logged out). Without it, you'd query the database every time for logged-out requests. This pattern reduces authentication overhead to exactly one query per request.

Use counter caches for authentication counts

If you display session counts, login counts, or permission counts, use Rails' counter_cache to avoid repeated COUNT queries:

	
# app/models/user.rb
class User < ApplicationRecord
  has_many :sessions
  has_many :login_attempts
end

# db/migrate/xxx_add_counter_caches.rb
class AddCounterCaches < ActiveRecord::Migration[7.1]
  def change
    add_column :users, :sessions_count, :integer, default: 0
    add_column :users, :login_attempts_count, :integer, default: 0
    
    # Backfill existing counts
    User.find_each do |user|
      User.reset_counters(user.id, :sessions, :login_attempts)
    end
  end
end

# app/models/session.rb
class Session < ApplicationRecord
  belongs_to :user, counter_cache: true
end

# Now instead of user.sessions.count (database query)
# Use user.sessions_count (reads from column, no query)
  

Counter caches maintain a count column that updates automatically when associations change. For dashboards showing "You have 3 active sessions" or "5 failed login attempts," this eliminates COUNT queries entirely.

Fragment caching for authenticated views

Cache rendered partials that don't change frequently, even for authenticated users:

	
# app/views/dashboard/_navigation.html.erb
<% cache ['navigation', current_user.id, current_user.updated_at] do %>
  <nav>
    <% if current_user.admin? %>
      <%= link_to 'Admin Panel', admin_path %>
    <% end %>
    <!-- Navigation that rarely changes -->
  </nav>
<% end %>

# app/views/users/_profile_card.html.erb
<% cache [@user, 'profile_card'] do %>
  <div class="profile-card">
    <%= image_tag @user.avatar_url %>
    <h2><%= @user.name %></h2>
    <!-- Profile info that's expensive to render -->
  </div>
<% end %>
  

Fragment caching stores rendered HTML in your cache store (Redis, Memcached). The cache key includes current_user.id and updated_at so each user gets their own cache that invalidates when their data changes. This is especially valuable for navigation, sidebars, or dashboard widgets that render the same way for multiple requests.

Connection pooling

Rails configures database connection pooling by default, but proper sizing is critical for authentication performance. Connection pooling maintains a pool of open database connections that requests can reuse, avoiding the 50-100ms overhead of opening a new connection for every authenticated request.

Without connection pooling, every request pays the full connection cost: TCP handshake, authentication with the database, and connection teardown. With pooling, requests "check out" a connection (< 5ms), use it, and return it to the pool. The connections stay open and warm, ready for the next request.

Sizing your connection pool

The pool size must match your server's concurrency model. Too small, and requests wait for available connections (adding latency). Too large, and you exhaust database connection limits or waste memory. The formula is straightforward: pool size = number of threads/workers handling requests concurrently.

	
# config/database.yml
production:
  adapter: postgresql
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000
  # Pool size should match your server's thread/worker count
  # Puma with 5 threads = pool size 5
  # 4 Puma workers × 5 threads = 20 total connections needed
  

For Puma with 5 threads per worker: each worker needs 5 connections. If you run 4 Puma workers, you need 20 total database connections. PostgreSQL defaults to 100 max connections, so 4 application servers × 20 connections = 80 connections used, leaving headroom for background jobs and admin tasks.

Monitor connection pool

Rails provides built-in connection pool statistics to help diagnose issues. If waiting is consistently > 0, your pool is too small. If idle is always high, your pool might be oversized.

	
# Check pool statistics
ActiveRecord::Base.connection_pool.stat
# => {size: 5, connections: 3, busy: 1, dead: 0, idle: 2, waiting: 0, checkout_timeout: 5}
  
  • size: Total pool capacity (configured).
  • connections: Currently established connections.
  • busy: Connections actively in use.
  • idle: Connections available for checkout.
  • waiting: Requests waiting for a connection (bad - increase pool size).
  • checkout_timeout: How long to wait before raising error.

Common issues

If you see "could not obtain a database connection within 5 seconds" errors, your pool is undersized for your traffic. Increase pool size or reduce request concurrency. If your database shows connection exhaustion, you've exceeded its max_connections setting - either increase the database limit or reduce application pools.

Rate limiting

Rate limiting protects your authentication endpoints from brute-force attacks where attackers attempt thousands of login combinations to guess passwords. Without rate limiting, an attacker can try 10,000 passwords per minute against your login endpoint. With rate limiting, they're restricted to 5 attempts per hour, making brute-force attacks impractical.

Rate limiting is one of the highest-value security measures you can implement. It's straightforward to set up with Rack::Attack, adds minimal overhead (< 5ms per request), and dramatically reduces your attack surface. Even if an attacker has a weak password list, 5 attempts per hour means it would take years to exhaust common passwords.

Protect authentication endpoints from brute-force attacks:

	
# Gemfile
gem 'rack-attack'

# config/initializers/rack_attack.rb
class Rack::Attack
  # Throttle login attempts by email
  throttle('logins/email', limit: 5, period: 1.hour) do |req|
    if req.path == '/login' && req.post?
      req.params['email'].presence
    end
  end

  # Throttle login attempts by IP
  throttle('logins/ip', limit: 10, period: 1.hour) do |req|
    if req.path == '/login' && req.post?
      req.ip
    end
  end

  # Block IPs after too many failed attempts
  blocklist('fail2ban') do |req|
    Rack::Attack::Fail2Ban.filter("fail2ban-#{req.ip}", maxretry: 10, findtime: 10.minutes, bantime: 1.hour) do
      req.path == '/login' && req.post?
    end
  end
end
  

Rate limiting strategies explained:

  • Per-email throttling (5/hour): Limits login attempts for a specific email address. This stops attackers from targeting a single account, even if they use different IPs (distributed attack). If someone tries to log in as "admin@example.com" 5 times, further attempts are blocked regardless of IP.
  • Per-IP throttling (10/hour): Limits login attempts from a specific IP address across all email addresses. This stops attackers from trying multiple accounts from one location. Set this limit higher than per-email (10 vs 5) to allow legitimate users behind shared IPs (corporate networks, VPNs) to make multiple login attempts across different accounts.
  • Fail2ban (block after 10 failures): Automatically blocks IPs that exceed failure thresholds. After 10 failed attempts in 10 minutes, the IP is banned for 1 hour. This provides progressive enforcement - legitimate users who forget passwords aren't immediately locked out, but persistent attackers get blocked entirely.

Storage considerations:

By default, Rack::Attack stores rate limit counters in memory (process memory). This works fine for single-server setups, but for multi-server deployments, use Redis as a shared store so rate limits work correctly across all servers:

	
# Use Redis for distributed rate limiting
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(url: ENV['REDIS_URL'])
  

User experience:

When rate limits are exceeded, return a 429 (Too Many Requests) response with a clear message and retry timing. Let users know when they can try again rather than showing a generic error.

Caching authentication results

While current_user should be cached per-request (covered earlier), complex permission checks often benefit from longer-lived caching. If your application performs expensive authorization queries (checking roles, permissions, group memberships, or organization hierarchies) caching these results can dramatically improve response times.

Consider a dashboard that checks 10 different permissions per page load. Without caching, that's 10 database queries per request. With 5-minute caching, it's 10 queries once, then 0 queries for the next 5 minutes. For a page rendering 20 items, each requiring a permission check, that's 200 queries reduced to 20.

Cache expensive permission checks:

	
# app/models/user.rb
class User < ApplicationRecord
  def can?(action, resource)
    Rails.cache.fetch("user:#{id}:can:#{action}:#{resource}", expires_in: 5.minutes) do
      # Expensive permission calculation
      permissions.where(action: action, resource: resource).exists?
    end
  end
end
  

How Rails.cache.fetch works:

On first call, Rails checks the cache for the key. If not found, it executes the block (database query), stores the result in cache, and returns it. On subsequent calls within 5 minutes, Rails returns the cached value without executing the block - no database query.

Cache expiration strategies:

  • Time-based expiration (5 minutes): Simple and effective for most cases. Permissions don't change frequently, so 5-minute staleness is acceptable. Increase for less critical permissions, decrease for security-sensitive checks.
  • Explicit invalidation: When permissions change, explicitly clear the cache:
	
# When granting a permission
def grant_permission(user, action, resource)
  Permission.create!(user: user, action: action, resource: resource)
  Rails.cache.delete("user:#{user.id}:can:#{action}:#{resource}")
end
  

Cache key design:

Use specific cache keys that include all relevant parameters: user:#{id}:can:#{action}:#{resource}. This ensures different users and different permissions have separate cache entries. If you used just can:#{action}, User A's cached result would be returned for User B's query.

What NOT to cache:

  • Current user lookup (use @current_user instance variable instead).
  • Session validation (must be checked every request for security).
  • Authentication tokens (never cache security credentials).
  • User's basic attributes like email or name (these should come from the already-loaded user object).

Cache storage:

Rails defaults to memory cache in development and file cache in production. For production applications, use Redis or Memcached for shared caching across servers:

	
# config/environments/production.rb
config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] }
  

Monitoring:

Track cache hit rates to verify your caching is effective. If hit rate is < 50%, your cache keys might be too specific or your expiration too short.

Enable YJIT for Ruby 3.2+

Ruby 3.2+ includes YJIT (Yet Another Ruby Just-In-Time compiler) which can significantly improve authentication performance:

	
# config/boot.rb
if defined?(RubyVM::YJIT.enable)
  RubyVM::YJIT.enable
end
  

YJIT compiles hot code paths to native machine code, providing 15-25% performance improvements for typical Rails applications. Authentication code (bcrypt hashing, session validation, permission checks) benefits significantly from YJIT. This is a free performance win with no code changes required - just enable it and benchmark your application.

Use rack-mini-profiler in Development

Profile authentication performance in development to identify bottlenecks:

	
# Gemfile
group :development do
  gem 'rack-mini-profiler'
  gem 'memory_profiler'
  gem 'stackprof'
end
  

rack-mini-profiler shows a speed badge on every page displaying:

  • Total request time.
  • Database query time.
  • View rendering time.
  • Number of SQL queries.

Click the badge to see a detailed breakdown. For authentication, focus on:

  • How long current_user lookups take.
  • Number of queries in authentication filters.
  • Permission check performance.
  • Session storage speed.

This makes it immediately obvious when authentication is slowing down requests, allowing you to optimize before deploying to production.

Building authentication from scratch

Building authentication yourself gives you complete control and eliminates vendor dependencies. But "authentication" isn't a single feature, it's an entire subsystem of interconnected components, each with its own security considerations and edge cases.

Let's walk through what you're actually signing up for when you decide to build auth in-house.

The core authentication system

At minimum, you need:

  • User registration:
    • Password hashing (bcrypt or Argon2; never store plain text).
    • Email validation and uniqueness checks.
    • Password strength requirements (length, complexity).
    • Protection against enumeration attacks (don't reveal if email exists).
    • Rate limiting on registration endpoint (prevent spam).
  • Login flow:
    • Secure password verification (constant-time comparison).
    • Session creation after successful login.
    • Failed login tracking (lock accounts after N attempts).
    • Rate limiting by IP and by email.
    • Login audit logs (IP, device, timestamp, success/failure).
  • Session management:
    • Session creation with secure random IDs.
    • Session storage (JWT, database, or Redis).
    • Session validation on every request.
    • Session refresh/renewal logic.
    • Logout (session deletion).

This is the absolute minimum before anyone can even log in.

Email verification

Most applications need to verify email addresses:

  • Generate unique, cryptographically secure verification tokens.
  • Store tokens with expiration (typically 24 hours).
  • Send verification emails.
  • Handle verification link clicks.
  • Resend verification emails (with rate limiting).
  • Update user status from "unverified" to "verified".
  • Decide what unverified users can/cannot do.

The hidden complexity here is that mail delivery is unreliable. You need retry logic, bounce handling, and spam folder considerations. Plus, verification tokens need to be long enough to be unguessable (128+ bits) but work in email links.

Password reset

Users forget passwords. You need:

  • Password reset request flow (rate limited).
  • Generate secure reset tokens (different from verification tokens). They must be cryptographically random, expire quickly (1-2 hours), and be single-use. You also need to prevent token enumeration (attacker trying tokens to find valid ones).
  • Send reset emails.
  • Validate reset tokens (check expiration, single-use).
  • Password reset form with strength validation.
  • Invalidate old sessions after password change.
  • Notify user via email that password was changed (security).

Multi-Factor Authentication (MFA)

Enterprise customers expect MFA. You'll need:

  • TOTP (Time-based One-Time Passwords):
    • Generate QR codes for authenticator apps.
    • Verify 6-digit codes with time-window tolerance.
    • Recovery codes (for when users lose their device).
    • Enforce MFA for certain roles or actions.
    • Remember trusted devices (optional).
  • SMS/Email codes:
    • Send time-limited codes via SMS or email.
    • Code verification with attempt limiting.
    • Cost management (SMS isn't free).
    • Handle delivery failures.
  • WebAuthn/Passkeys:
    • Most secure but most complex to implement.
    • Browser compatibility issues.
    • Key storage and management.
    • Fallback methods when devices are lost.

OAuth/Social login

If you want "Sign in with Google/GitHub/Microsoft":

  • Register OAuth applications with each provider.
  • Implement OAuth 2.0 flow (redirect, callback, token exchange).
  • Handle state parameter to prevent CSRF.
  • Map provider user IDs to your user accounts.
  • Account linking (user has both email and social login).
  • Handle provider-specific quirks and errors.
  • Keep up with provider API changes.

Each provider is different. Google's OAuth works differently than GitHub's. Some return email, some don't. Some verify emails, some don't. You need provider-specific code for each one.

Account management

Users need to manage their accounts:

  • Change email (with verification of new email).
  • Change password (with current password verification).
  • View active sessions and terminate them.
  • Enable/disable MFA.
  • Manage recovery codes.
  • Delete account (with confirmation).
  • Export user data (GDPR compliance).

Security features

Beyond basic authentication:

  • Rate limiting:
    • Login attempts per email (5 per hour).
    • Registration per IP (3 per day).
    • Password reset requests (3 per hour).
    • MFA code attempts (5 per device).
  • Anomaly detection:
    • Login from new country/IP.
    • Login from new device.
    • Multiple failed login attempts.
    • Concurrent sessions from different locations.
  • Audit logging:
    • Every authentication event.
    • Account changes.
    • Permission changes.
    • Data access (for compliance).
  • Bot protection:

Edge cases and corner cases

The devil is in the details:

  • What happens if a user changes their email while a verification email is in flight?
  • How do you handle password reset requests when the user's email is compromised?
  • What if a user signs up with OAuth, then tries to set a password?
  • How do you merge accounts if a user signs up twice with different emails?
  • What happens to active sessions when a user changes their password?
  • How do you handle timezones in MFA TOTP codes?
  • What's the user experience when MFA codes aren't working?

Each of these requires thoughtful handling and UI/UX consideration.

Infrastructure and operations

Beyond the code:

  • Email infrastructure:
    • Transactional email service (SendGrid, Postmark, AWS SES).
    • Email templates for verification, reset, alerts.
    • Bounce and complaint handling.
    • SPF, DKIM, DMARC configuration.
    • Spam filter considerations.
  • Monitoring and alerting:
    • Failed login spike detection.
    • Session creation anomalies.
    • Email delivery failures.
    • Rate limit violations.
    • Error rates and latency.
  • Compliance:
    • GDPR (data export, deletion, consent).
    • SOC 2 (if you want enterprise customers).
    • Password policy enforcement.
    • Data breach notification procedures.

The time investment

Realistic estimates for building this yourself:

  • MVP (email/password only): 1-3+ weeks
  • Production-ready (with MFA, OAuth): 2-3+ months
  • Enterprise-grade (SSO, compliance): 6+ months
  • Ongoing maintenance: ~25% of initial time annually

This doesn't include the opportunity cost: the features you're not building while you're building auth.

When building makes sense

You should consider building authentication yourself when:

  • You have unique authentication requirements that no provider supports.
  • You're building a platform where authentication is your product.
  • You have a team with deep security expertise.
  • You have months to invest in getting it right.

For most B2B SaaS Rails applications, managed authentication accelerates time-to-market and reduces security risk.

The question isn't whether you can build it yourself; you can. The question is whether building authentication is the best use of your engineering time.

More often than not, the answer is no.

The case for managed authentication

While Rails provides excellent tools for building authentication, managed authentication providers offer compelling advantages.

  • Time to production: Building authentication is complex. A managed provider eliminates weeks of development time, letting you focus on your core product. With the right provider you can add authentication to your app within hours, and have all the other flows like MFA, resets, etc up and running as well. Estimated time to market:
    • DIY (Rails 8 generator): minimum 1-3 weeks for MVP.
    • DIY (Devise): minimum 1-2 weeks for MVP.
    • DIY (custom): minimum 3-6 weeks for MVP.
    • Managed provider: 1-3 hours to production.
    • Security maintenance: Authentication security requires constant vigilance: monitoring for new vulnerabilities, updating dependencies, implementing new security standards, responding to security incidents, performing security audits, etc. Managed providers employ security teams dedicated to these tasks, often discovering and patching vulnerabilities before they become public knowledge.
  • Compliance requirements: Enterprise customers often require GDPR compliance, SOC 2 Type II certification, HIPAA compliance (for healthcare), and more. Building and maintaining compliance yourself can cost $50,000-$200,000+ annually in audits alone. What managed providers can handle for you:
    • Automatic dependency updates.
    • Proactive vulnerability patching.
    • 24/7 incident response.
    • Regular penetration testing.
    • Compliance maintenance (SOC 2, GDPR, HIPAA).
    • Security research and threat monitoring.
  • Feature richness: Modern authentication needs include many features that managed providers offer out of the box, tested and production-ready:
    • OAuth/social login (Google, GitHub, Microsoft, etc.).
    • Multi-factor authentication.
    • Single Sign-On (SSO) for enterprise.
    • Directory sync (automatic user provisioning).
    • Session management across devices.
    • Bot detection.
    • Passwordless authentication.
    • Organization/team management.
    • Audit logs.
    • Access control (RBAC, FGA).
    • Admin Portal.

WorkOS: Enterprise ready authentication

WorkOS takes a different approach to authentication compared to traditional providers. Rather than being purely an authentication service, WorkOS is a platform that enables B2B SaaS companies to ship enterprise features quickly. This matters because you can start with just authentication and add enterprise capabilities like SSO and Directory Sync later without rearchitecting your application.

Why WorkOS stands out

1. Generous free tier

WorkOS offers free authentication for up to 1 million monthly active users. This is significantly more generous than other providers:

  • Clerk: Free up to 10,000 MAU, then $0.02 per user.
  • Auth0: Free up to 7,000 MAU, then $35+ per month.
  • Supabase: Free up to 50,000 MAU.

For products with large user bases or freemium models, WorkOS's pricing is substantially more cost-effective. You can use the pricing calculator to calculate exactly how much you will have to pay. No hidden fees.

2. Ruby-First SDK

WorkOS provides a dedicated Ruby SDK that integrates naturally with Rails across frameworks (Rails, Sinatra, etc):

	
require "dotenv/load"
require "workos"
require "sinatra"

WorkOS.configure do |config|
  config.key = ENV["WORKOS_API_KEY"]
end

set :port, 3000

get "/" do
  send_file "index.html"
end

# add the sign in endpoint
get "/login" do
  authorization_url = WorkOS::UserManagement.authorization_url(
    provider: "authkit",
    client_id: ENV["WORKOS_CLIENT_ID"],
    redirect_uri: "http://localhost:3000/callback"
  )

  redirect authorization_url
end

# add the callback endpoint + save the encrypted session
get "/callback" do
  code = params["code"]

  begin
    auth_response = WorkOS::UserManagement.authenticate_with_code(
      client_id: ENV["WORKOS_CLIENT_ID"],
      code: code,
      session: {
        seal_session: true,
        cookie_password: ENV["WORKOS_COOKIE_PASSWORD"]
      }
    )

    # store the session in a cookie
    response.set_cookie(
      "wos_session",
      value: auth_response.sealed_session,
      httponly: true,
      secure: true,
      samesite: "lax"
    )

    # Use the information in auth_response for further business logic.

    redirect "/"
  rescue => e
    puts e
    redirect "/login"
  end
end

# use a helper method to specify which routes should be protected
helpers do
  def with_auth(request, response)
    session = WorkOS::UserManagement.load_sealed_session(
      client_id: ENV["WORKOS_CLIENT_ID"],
      session_data: request.cookies["wos_session"],
      cookie_password: ENV["WORKOS_COOKIE_PASSWORD"]
    )

    session.authenticate => { authenticated:, reason: }

    return if authenticated == true

    redirect "/login" if !authenticated && reason == "NO_SESSION_COOKIE_PROVIDED"

    # If no session, attempt a refresh
    begin
      session.refresh => { authenticated:, sealed_session: }

      redirect "/login" if !authenticated

      response.set_cookie(
        "wos_session",
        value: sealed_session,
        httponly: true,
        secure: true,
        samesite: "lax"
      )

      # Redirect to the same route to ensure the updated cookie is used
      redirect request.url
    rescue => e
      warn e
      response.delete_cookie("wos_session")
      redirect "/login"
    end
  end
end

# call the helper method in the route that should only be accessible to logged in users
get "/dashboard" do
  with_auth(request, response)

  session = WorkOS::UserManagement.load_sealed_session(
    client_id: ENV["WORKOS_CLIENT_ID"],
    session_data: request.cookies["wos_session"],
    cookie_password: ENV["WORKOS_COOKIE_PASSWORD"]
  )

  session.authenticate => { authenticated:, user: }

  redirect "/login" if !authenticated

  puts "User #{user[:first_name]} is logged in"

  # Render a dashboard view
end

# end a session
get "/logout" do
  session = WorkOS::UserManagement.load_sealed_session(
    client_id: ENV["WORKOS_CLIENT_ID"],
    session_data: request.cookies["wos_session"],
    cookie_password: ENV["WORKOS_COOKIE_PASSWORD"]
  )

  url = session.get_logout_url

  response.delete_cookie("wos_session")

  # After log out has succeeded, the user will be redirected to your
  # app homepage which is configured in the WorkOS dashboard
  redirect url
end
  

3. Complete feature set from day one

WorkOS provides a comprehensive platform that goes beyond basic authentication:

  • Flexible UI support via APIs and SDKs, with AuthKit as a highly customizable hosted login powered by Radix.
  • Multiple authentication methods, each one enabled in minutes:
  • Sessions model with access + refresh tokens and guidance for secure cookie storage. Automatic token refresh and secure cookies.
  • Enterprise SSO with native SAML and OIDC, configurable by customers through an Admin Portal.
  • SCIM provisioning: Automated user provisioning and deprovisioning that enterprises expect, handling the "remove this employee immediately" requests that inevitably arrive. Real-time synchronization with any identity provider (Okta, Azure AD, Google Workspace, and more).
  • Tamper-proof audit logs for SOC 2, HIPAA, and GDPR.
  • Secure session handling with server-side validation and instant session revocation capabilities.
  • Customizable JWT claims: Add custom data to JWT payloads with JWT templates for flexible token customization.
  • Radar for suspicious login detection and threat monitoring that alerts you to potential account compromises.
  • Fine-grained authorization: Role-based access control with customizable permissions.
  • Feature flags: Integrated feature flagging for gradual rollouts.
  • First-class multi-tenancy with organization management, member invitations, and role assignment.
  • Enterprise SLA and dedicated support.
  • Domain verification: Prove ownership of email domains. Enable domain-based routing where users from acme.com auto-route to Acme's SSO.
  • Vault: Store sensitive customer data securely with customer-managed encryption keys for compliance and data residency requirements.
  • Webhooks: Real-time event notifications for user lifecycle, SSO, and Directory Sync events. Automatic retry logic with exponential backoff and secure webhook verification.
  • Feature Flags: Control feature rollout to specific users or organizations. A/B testing capabilities and gradual rollout strategies.
  • And more.

4. Platform approach

The key differentiator is how these products work together. When you use WorkOS:

  • Users provisioned through Directory Sync automatically work with AuthKit authentication.
  • SSO sessions integrate seamlessly with your existing auth flow.
  • Audit Logs capture all authentication and authorization events.
  • Admin Portal allows customers to self-configure enterprise features.
  • Feature flags information is included in every JWT so you know which users should have access to this new feature you're launching.
  • And more.

You're not stitching together separate systems, it's one cohesive platform where features compound on each other.

5. Migration path

Start simple and add complexity as you grow:

  1. MVP stage: Just AuthKit for email/password authentication.
  2. Growth stage: Add social login and MFA.
  3. Enterprise stage: Enable SSO for enterprise customers.
  4. Scale stage: Add Directory Sync for automatic provisioning.

Each stage builds on the previous one without rearchitecting. The authentication system you build on day one scales to enterprise without major refactoring.

6. Developer experience

WorkOS prioritizes developer experience at every level:

  • Quick setup: Complete integration in 1-2 hours.
  • Excellent documentation: Clear guides, API references, and working examples for every feature.
  • CLI installer: Automated setup that detects your framework and configures everything.
  • Example apps: Production-ready reference implementations for Ruby and other frameworks.
  • Generous free tier: Build and test without worrying about costs (up to 1M MAU).
  • No vendor lock-in: Standard protocols (OAuth, SAML, SCIM) make migration possible if needed.
  • Responsive support: Active Discord community and responsive support team.
  • Transparent changelog: Clear communication about new features, breaking changes, and deprecations.

Production best practices

Security checklist

  • Keep Rails and gems updated: Subscribe to Rails security mailing list and update dependencies regularly. Security patches are released frequently, and outdated gems are a primary attack vector.
  • Use strong parameters: Never permit all parameters with params.permit!. Always explicitly whitelist allowed attributes to prevent mass assignment vulnerabilities where attackers modify fields like admin or role.
  • Enable CSRF protection: Verify protect_from_forgery with: :exception is enabled in ApplicationController. This prevents malicious sites from submitting forms using your users' sessions.
  • Configure secure cookies: Set secure: true, httponly: true, and same_site: :lax on session cookies. These flags prevent cookie theft via JavaScript (XSS) and network sniffing, and provide CSRF protection.
  • Use has_secure_password: Rails' built-in has_secure_password provides bcrypt hashing with proper cost factors and secure validations. Don't implement your own hashing - it's easy to get wrong.
  • Use bcrypt for passwords: Rails default has_secure_password uses bcrypt with proper cost factors. Never implement your own password hashing or use MD5/SHA1.
  • Implement rate limiting: Protect login endpoints with rack-attack, limiting to 5 attempts per hour per email. This makes brute-force password attacks impractical.
  • Force HTTPS in production: Set config.force_ssl = true to redirect all HTTP traffic to HTTPS. This encrypts data in transit, protecting session cookies and passwords from network sniffing.
  • Validate all user input: Use strong parameters and ActiveRecord validations on all user-provided data. Never trust client-side validation alone.
  • Escape output by default: Use <%= %> in ERB templates, which escapes HTML automatically. Never use raw() or .html_safe with user-provided content as this enables XSS attacks.
  • Use parameterized queries: Active Record parameterizes queries automatically. Avoid string interpolation in queries ("WHERE email = '#{params[:email]}'") which enables SQL injection.
  • Use Rails credentials: Store secrets in config/credentials.yml.enc (encrypted) or environment variables. Never commit secret_key_base, API keys, or passwords to version control.
  • Implement proper logging: Log authentication events (login, logout, password changes), failed attempts, and security incidents. Use Rails.logger.info for authentication events and Rails.logger.error for failures. Exclude sensitive data like passwords and tokens from logs.
  • Run Brakeman regularly: Use Brakeman for static security analysis in your CI pipeline. It detects SQL injection, XSS, mass assignment, and other vulnerabilities before deployment.
  • Use bundler-audit: Run bundle audit regularly to check gems for known CVEs. Integrate into CI to prevent deploying apps with vulnerable dependencies.
  • Monitor failed login attempts: Track failed login attempts and implement alerting for suspicious patterns like rapid failures from single IPs or targeting specific accounts.
  • Set up error tracking: Use Sentry, Rollbar, or similar to capture exceptions in real-time. Authentication errors often indicate attacks or misconfigurations that need immediate attention.
  • Follow Rails security guide: Read the official Rails security guide at https://guides.rubyonrails.org/security.html. It covers session hijacking, CSRF, injection attacks, and Rails-specific protections.
  • Use Rails' built-in protections: Rails automatically provides CSRF protection, XSS prevention (output escaping), and SQL injection prevention (parameterized queries). Don't disable these without understanding the risks.

Performance checklist

  • Add database indexes: Create indexes on users.email, sessions.user_id, and any columns used in WHERE clauses or joins. Missing indexes turn 5ms queries into 500ms queries at scale.
  • Use connection pooling: Configure pool size to match your server's thread count (pool: 5 for 5 Puma threads). Connection pooling reduces overhead from 50-100ms per request to under 5ms.
  • Cache expensive operations: Use Rails.cache.fetch for permission checks and complex queries. Cache with 5-minute TTL reduces repeated database queries from dozens per request to zero.
  • Eager load associations: Use includes(:comments, :tags) to avoid N+1 queries. Without eager loading, 100 records can generate 200+ queries instead of 2-3.
  • Use Redis for sessions: Redis sessions provide 5-10ms lookup times versus 30-50ms for database sessions, while still allowing immediate revocation. Best balance of speed and control.
  • Monitor query performance: Install Bullet gem in development to detect N+1 queries, unused eager loading, and missing counter caches. Fix issues before they reach production.
  • Profile authentication code: Use rack-mini-profiler to see real-time performance breakdown of each request. Focus on authentication overhead - it should be under 10ms.
  • Configure proper timeouts: Set database timeouts (5 seconds), HTTP client timeouts, and session expiration appropriately. Prevents hung connections from degrading performance under load.
  • Leverage Rails conventions: Follow MVC patterns for authentication - keep logic in models, controllers thin, and views presentation-only. Rails conventions make code predictable and maintainable.
  • Use concerns for shared logic: Extract authentication logic into app/controllers/concerns/Authentication.rb and authorization into app/models/concerns/Authorizable.rb. Concerns keep controllers clean and logic reusable.

Deployment checklist

  • Set environment variables: Store SECRET_KEY_BASE, database URLs, API keys as environment variables or Rails credentials. Use different values for staging and production.
  • Configure secret_key_base: Generate a secure secret with rails secret and store in credentials or ENV. Changing this invalidates all sessions, so rotate carefully with proper user communication.
  • Enable HTTPS: Obtain SSL/TLS certificates (free from Let's Encrypt) and configure your web server. Set config.force_ssl = true to redirect HTTP to HTTPS automatically.
  • Set up monitoring: Use Scout, New Relic, or Skylight to monitor request times, database queries, and memory usage. Set alerts for authentication endpoint slowdowns or errors.
  • Configure logging: Send logs to Papertrail, Logz.io, or CloudWatch for centralized searching and alerting. Structure logs with request IDs for tracing authentication flows.
  • Implement backups: Configure automated database backups with point-in-time recovery. Test restoration regularly - untested backups are worthless when you need them.
  • Use process manager: Run Puma, Passenger, or Unicorn with proper worker/thread configuration. Set up automatic restarts on crashes and graceful restarts for deploys.
  • Configure web server: Use Nginx or Apache as a reverse proxy to handle SSL termination, static files, and load balancing. Don't expose your Rails server directly to the internet.
  • Set up health checks: Implement /health endpoint that checks database connectivity and critical services. Configure load balancers to remove unhealthy instances automatically.
  • Test disaster recovery: Practice restoring from backups, failing over to backup systems, and recovering from data corruption. Document the process and train your team.
  • Configure ActionMailer properly: Set up SMTP settings, configure default URL options for email links, and test email delivery in staging. Broken password reset emails block user access.
  • Use background jobs: Send emails via ActiveJob with Sidekiq or Solid Queue. Never send emails synchronously in web requests - it adds 500ms+ latency and blocks the response.
  • Test authentication thoroughly: Write request specs for login/logout flows, system specs for full user journeys, and model specs for password validation. Authentication bugs in production are costly.

Conclusion

Authentication in Ruby on Rails benefits from the framework's "convention over configuration" philosophy and strong security defaults. Whether you're using the Rails 8 authentication generator, established gems like Devise or Rodauth, or building custom solutions, Rails provides the tools and conventions to implement secure authentication systems.

If you're building authentication yourself:

  • Budget 1-3 weeks for MVP (with Rails 8 generator).
  • Plan for ongoing security maintenance.
  • Leverage Rails' built-in security features.
  • Use established gems for complex features (OAuth, MFA).
  • Follow Rails conventions and security best practices.

If you're considering managed authentication:

  • Evaluate based on your needs (features, cost, enterprise requirements).
  • Consider long-term costs, not just initial pricing.
  • Verify Rails integration and SDK quality.
  • Check compliance requirements for your industry.

WorkOS provides compelling advantages for Rails teams:

  • Ship enterprise features quickly.
  • Generous free tier (up to 1M MAU).
  • Native Ruby SDK with Rails-friendly patterns.
  • SSO and Directory Sync out-of-the-box.
  • Comprehensive platform features.

Authentication is critical infrastructure. Invest the time to get it right, whether building it yourself with Rails' excellent ecosystem or choosing a partner like WorkOS that shares your commitment to security and developer experience.

Choose the authentication provider that matches where your application is headed, not just where it is today. Your future self (and your enterprise customers) will thank you.

Sign up for WorkOS today and secure your Rails app.

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.