RUBY ON RAILS · 22 MIN READ ·

Rails API Authentication: JWT, Session Cookies, and API Keys — When to Use Each

Rails API authentication in 2026: JWT, session cookies, and API keys compared — security tradeoffs, Rails 8 code, and how to choose the right approach.

Rails API Authentication: JWT, Session Cookies, and API Keys — When to Use Each

A founder called me last year after their startup had passed a security audit — barely. The auditor’s finding was this: the Rails API had 47 valid JWT tokens per average user. Tokens were issued on every login. They never expired. There was no refresh flow. When the team realized tokens couldn’t be revoked without rolling the signing key (which would log out every user in production), they bolted a Redis-backed token blacklist onto the side of the JWT implementation. They had built a stateless authentication system and then made it stateful, just worse. The word “stateless” had become a cargo cult.

Rails API authentication is one of those areas where the wrong choice compounds over years. After nineteen years of Rails, I have seen every combination — sessions on everything, JWT on everything, homegrown token schemes that held together with string — and the pattern I always see is that people pick JWT because it sounds modern, then spend eighteen months working around its limitations. This post is the decision framework I hand to clients before a single line of auth code gets written.

The Three Patterns and What They Actually Solve

There are exactly three authentication mechanisms worth considering for a Rails API in 2026:

Session cookies. The browser stores an encrypted, signed session ID. Every request sends the cookie. Your Rails app looks up the session on the server side — in the cookie store (signed cookie), the database, Redis, or Solid Cache. The server knows whether the session is valid right now because it is the source of truth. Revocation is one database row delete.

JWT (JSON Web Tokens). The client stores a cryptographically signed blob that encodes claims. Your Rails app verifies the signature without any database lookup. The token is valid until it expires. You cannot un-issue a valid token before expiry without maintaining a revocation list — which brings you back to needing server-side state.

API keys. A long random string, typically a Bearer token in the Authorization header. The server stores a hash of the key and looks it up on every request. Long-lived by design. Scoped to specific permissions. Can be rotated or revoked instantly. Designed for machine-to-machine use.

Each solves a real problem. None of them is a universal replacement for the others.

Session Cookies: The Right Default for Browser Applications

If you are building a Rails app with a browser frontend — even a React or Vue SPA — session cookies are almost certainly the correct answer. The Rails 8 authentication generator ships a session-based model by default, and for good reason: HTTP-only cookies cannot be stolen by XSS, SameSite=Lax stops CSRF on cross-origin requests, and revocation is immediate.

The generated Session model looks like this:

class Session < ApplicationRecord
  belongs_to :user

  before_create { self.token = SecureRandom.urlsafe_base64(32) }
end

And the authentication concern:

module Authentication
  extend ActiveSupport::Concern

  included do
    before_action :require_authentication
    helper_method :authenticated?
  end

  private

  def authenticated?
    @current_session.present?
  end

  def require_authentication
    resume_session || request_authentication
  end

  def resume_session
    @current_session = Session.find_by(token: cookies.signed[:session_token])
    @current_user    = @current_session&.user
  end

  def start_new_session_for(user)
    session = user.sessions.create!
    cookies.signed.permanent[:session_token] = {
      value:     session.token,
      httponly:  true,
      samesite:  :lax,
      secure:    Rails.env.production?
    }
    @current_session = session
    @current_user    = user
  end
end

The httponly: true flag is the line that matters most. JavaScript cannot read an HTTP-only cookie. That means an XSS vulnerability in your React frontend cannot exfiltrate the session token. Compare this to a JWT stored in localStorage — localStorage is fully readable by any script on the page, which is why “store JWTs in memory, not localStorage” is the standard advice, which leads to “store in an HTTP-only cookie,” which is just a session.

The SameSite: :lax setting blocks the cookie from being sent on cross-site POST requests, eliminating the CSRF vector for state-changing operations. Rails still ships a CSRF token in the response headers for browsers that need it, but SameSite=Lax is now the primary defense on modern browsers.

The cross-origin exception. If your frontend is on app.example.com and your API is on api.example.com, cookies work fine — same eTLD+1. If your frontend is on app.vercel.app and your API is on api.example.com, you have a genuine cross-origin problem, and you need to either consolidate domains or reach for JWT for the browser-to-API call. This is the one legitimate case where JWT replaces sessions for browser traffic.

JWT: When It Actually Helps

JWT is not a session replacement. It is a token format for scenarios where you need self-contained, verifiable claims that can be checked without a database lookup. The legitimate use cases are narrower than most teams assume.

Service-to-service calls with embedded claims. Service A needs to call Service B and prove that the request is for user ID 7412 with role admin. Rather than Service B making a callback to Service A to verify, Service A signs a JWT containing those claims. Service B verifies the signature with Service A’s public key. No network call on the verification side.

Short-lived operation tokens. A user requests a password reset. You issue a JWT with a 15-minute expiry containing the user ID and a purpose: reset_password claim. The reset link embeds the token. When clicked, you verify the signature and the expiry — no database state required. The same pattern works for email verification links and magic login links.

Presigned download URLs for private files. You want to give a user a time-limited URL to download a private S3 file without your server being in the proxying path. Issue a short-lived JWT, encode it in the URL, and verify it at the edge.

For these cases, add the jwt gem and wrap it:

# Gemfile
gem "jwt", "~> 2.9"
# app/models/concerns/jwt_issuable.rb
module JwtIssuable
  extend ActiveSupport::Concern

  ALGORITHM = "RS256".freeze

  class_methods do
    def issue_jwt(payload, expires_in: 15.minutes)
      claims = payload.merge(
        iat: Time.current.to_i,
        exp: expires_in.from_now.to_i,
        iss: "api.example.com"
      )
      JWT.encode(claims, private_key, ALGORITHM)
    end

    def verify_jwt(token, purpose: nil)
      payload, _header = JWT.decode(
        token,
        public_key,
        true,
        algorithms: [ALGORITHM],
        iss: "api.example.com",
        verify_iss: true
      )

      if purpose && payload["purpose"] != purpose.to_s
        raise JWT::DecodeError, "Token purpose mismatch"
      end

      payload
    rescue JWT::ExpiredSignature
      raise
    rescue JWT::DecodeError => e
      Rails.logger.warn("JWT decode failed: #{e.message}")
      nil
    end

    private

    def private_key
      OpenSSL::PKey::RSA.new(ENV.fetch("JWT_PRIVATE_KEY").gsub("\\n", "\n"))
    end

    def public_key
      OpenSSL::PKey::RSA.new(ENV.fetch("JWT_PUBLIC_KEY").gsub("\\n", "\n"))
    end
  end
end

Two things in that code are non-negotiable:

  • Always pass algorithms: to JWT.decode. If you omit the algorithms list, the library will accept any algorithm the token header declares — including "none", which means no signature at all. This is CVE-2015-9235, and JWT libraries have been burned by it repeatedly.
  • Use RS256, not HS256, for service-to-service tokens. HS256 requires sharing the secret key. RS256 uses a keypair — you distribute the public key to verifying services without exposing anything signable.

Generate the keypair once:

openssl genrsa -out jwt_private.pem 2048
openssl rsa -in jwt_private.pem -pubout -out jwt_public.pem

Store both in Rails credentials or environment variables. The private key signs; the public key verifies.

Usage at a call site:

class PasswordResetsController < ApplicationController
  skip_before_action :require_authentication, only: %i[create update]

  def create
    user = User.find_by(email: params[:email])
    if user
      token = JwtIssuable.issue_jwt(
        { user_id: user.id, purpose: "password_reset" },
        expires_in: 15.minutes
      )
      PasswordResetMailer.with(user: user, token: token).reset_email.deliver_later
    end
    render json: { message: "If that email exists, a reset link is on its way." }
  end

  def update
    payload = JwtIssuable.verify_jwt(params[:token], purpose: :password_reset)
    return render json: { error: "Invalid or expired link" }, status: :unprocessable_entity if payload.nil?

    user = User.find(payload["user_id"])
    user.update!(password: params[:password], password_confirmation: params[:password_confirmation])
    render json: { message: "Password updated." }
  end
end

What the code above does not do is use JWT as a session replacement. No refresh tokens. No Authorization: Bearer on every API call. The token is single-use, scoped to one purpose, and expires in 15 minutes. This is JWT doing what it is good at.

API Keys: Machine-to-Machine Authentication

If a B2B customer is calling your API from their backend service — nightly data exports, webhook integrations, third-party tooling — they need API keys, not sessions and not JWT. Sessions are designed for users; JWT carries a timestamp problem (you have to pick an expiry, and if you pick anything longer than an hour you are accumulating revocation debt). API keys are designed for service principals: long-lived, scoped to specific permissions, rotatable without breaking other keys, and revocable instantly.

The implementation is straightforward. Store the key hash, not the key itself — the same principle as passwords:

class CreateApiKeys < ActiveRecord::Migration[8.0]
  def change
    create_table :api_keys do |t|
      t.references :user, null: false, foreign_key: true
      t.string :name, null: false
      t.string :token_digest, null: false
      t.string :prefix, null: false
      t.jsonb  :scopes, default: []
      t.datetime :last_used_at
      t.datetime :expires_at

      t.timestamps
    end

    add_index :api_keys, :token_digest, unique: true
    add_index :api_keys, :prefix
  end
end

The prefix column is a human-readable hint — something like ttb_live_ that appears in the full key. It lets users identify which key they’re looking at without your database storing anything revealable. The token_digest is Digest::SHA256.hexdigest(token):

class ApiKey < ApplicationRecord
  belongs_to :user

  TOKEN_PREFIX = "ttb_live_".freeze

  def self.generate_for(user, name:, scopes: [])
    raw_token = "#{TOKEN_PREFIX}#{SecureRandom.urlsafe_base64(32)}"
    key = create!(
      user:         user,
      name:         name,
      prefix:       raw_token.first(12),
      token_digest: Digest::SHA256.hexdigest(raw_token),
      scopes:       scopes
    )
    [key, raw_token]
  end

  def self.authenticate(raw_token)
    digest = Digest::SHA256.hexdigest(raw_token)
    key = find_by(token_digest: digest)
    return nil unless key
    return nil if key.expires_at&.past?

    key.touch(:last_used_at)
    key
  end

  def allows?(scope)
    scopes.empty? || scopes.include?(scope.to_s)
  end
end

The authentication concern for API endpoints:

module ApiKeyAuthentication
  extend ActiveSupport::Concern

  included do
    before_action :require_api_key
  end

  private

  def require_api_key
    raw_token = request.headers["Authorization"]&.delete_prefix("Bearer ")
    return render_unauthorized unless raw_token.present?

    @current_api_key = ApiKey.authenticate(raw_token)
    return render_unauthorized unless @current_api_key

    @current_user = @current_api_key.user
  end

  def require_scope(scope)
    render_unauthorized unless @current_api_key.allows?(scope)
  end

  def render_unauthorized
    render json: { error: "Unauthorized" }, status: :unauthorized
  end
end

One detail that gets missed in most tutorials: the Digest::SHA256.hexdigest comparison is safe against timing attacks because SHA256 is a fixed-length operation — the comparison time does not leak information about partial matches. If you were comparing the raw token directly (instead of hashing first), you would need ActiveSupport::SecurityUtils.secure_compare. Hash it and compare hashes; == is fine.

The last_used_at touch is worth doing. It lets you build tooling that shows customers which keys are active and flags keys that haven’t been used in 90 days for rotation prompts.

Combining the Three Approaches

Real production Rails apps use all three patterns simultaneously, layered by client type. Here is the routing structure I reach for on every Rails API authentication setup:

# config/routes.rb
Rails.application.routes.draw do
  # Browser-facing: session cookie auth
  scope module: :web do
    get  "/login",  to: "sessions#new"
    post "/login",  to: "sessions#create"
    delete "/logout", to: "sessions#destroy"

    resource :dashboard, only: :show
  end

  # API v1: API key auth
  namespace :api do
    namespace :v1 do
      resources :exports, only: %i[index create show]
      resources :webhooks, only: %i[index create destroy]
    end
  end

  # Internal service calls: JWT auth (issued by your own services)
  namespace :internal do
    resources :users, only: :show
  end
end
# app/controllers/web/base_controller.rb
class Web::BaseController < ApplicationController
  include Authentication  # session cookie auth
end

# app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ApplicationController
  include ApiKeyAuthentication
  skip_before_action :verify_authenticity_token
end

# app/controllers/internal/base_controller.rb
class Internal::BaseController < ApplicationController
  before_action :verify_internal_token
  skip_before_action :verify_authenticity_token

  private

  def verify_internal_token
    payload = JwtIssuable.verify_jwt(request.headers["X-Internal-Token"])
    return render json: { error: "Unauthorized" }, status: :unauthorized unless payload
    @claims = payload
  end
end

The skip_before_action :verify_authenticity_token on API controllers is the correct call — CSRF protection is irrelevant for endpoints that receive Authorization headers instead of cookies. For the cookie-based browser controllers, keep CSRF protection on.

Mobile clients (iOS/Android) are the one nuance. If your mobile app is same-origin with the API (you control both), issue short-lived JWTs from your auth endpoint and have the mobile client refresh them. If you are building for a third-party mobile integration, treat it as an API key client. The worst pattern I see is mobile apps storing long-lived JWTs in device keychain that are valid for 30 days — you have effectively built an API key with extra steps and no revocation path.

Security Patterns That Actually Matter

After the three approaches, these are the security practices that separate production-ready Rails API authentication from tutorial code:

Rate-limit auth endpoints. Every login form and token-issuance endpoint needs rate limiting. The Rack::Attack rate limiting post covers the setup. Without it, your login endpoint is an open invitation to credential stuffing.

Log auth events, never auth credentials. Log successful and failed authentication attempts with IP, user agent, and user ID (or email, pre-lookup). Never log the raw token, password, or API key — not even the first few characters. A log file with a partial credential is a liability.

# app/models/concerns/authentication.rb
def resume_session
  @current_session = Session.find_by(token: cookies.signed[:session_token])
  if @current_session
    Rails.logger.info(
      "auth.session_resumed user_id=#{@current_session.user_id} ip=#{request.remote_ip}"
    )
  end
  @current_user = @current_session&.user
end

Expire sessions on password change. When a user changes their password, delete all other active sessions. Rails 8’s session model makes this one line:

def update
  if @user.update(user_params)
    @user.sessions.where.not(id: current_session).destroy_all
    redirect_to dashboard_path, notice: "Password updated."
  end
end

Rotate API keys, not signing secrets. When a JWT signing secret is compromised, you must rotate it, which invalidates every token in circulation. When an API key is compromised, you rotate that one key. This is the practical argument for API keys over JWT for long-lived machine access: the blast radius of a compromise is bounded.

Audit log for API key usage. The last_used_at field is a start. For compliance-heavy clients (SOC 2, ISO 27001), you need a full event log: which endpoint was called, with what parameters, at what time, from what IP. Store this in a separate api_access_logs table with a 90-day retention policy and an index on (api_key_id, created_at).

The Decision Framework in One Table

When I am advising a client on Rails API authentication, I walk through this:

Client type Auth mechanism Reason
Browser (same-origin SPA or SSR) Session cookies HTTP-only, SameSite, instant revocation
Browser (cross-origin) Short-lived JWT Cookies blocked cross-origin; JWT in memory or HTTP-only
Mobile app (your own) Short-lived JWT + refresh Can’t use cookies; keep expiry ≤60 min
B2B API integration API keys Long-lived, scoped, rotatable
Service-to-service call JWT (RS256) Claims without DB lookup, short-lived
Password reset / magic link JWT (single-use, 15 min) Stateless, self-expiring

The founder whose JWT blacklist I audited last year is now running session cookies for their browser app, short-lived JWTs for their mobile clients, and API keys for their enterprise integrations. The Redis blacklist is gone. The revocation problem is solved. The audit finding is closed.

FAQ

Should I use JWT for my Rails API?

Only if you have a specific reason to. JWT’s advantage — no database lookup on verification — is real but rarely the bottleneck. If you are building a browser app, session cookies are more secure and simpler to implement correctly. If you are building service-to-service calls, short-lived JWTs with RS256 signatures are the right tool. If you need long-lived machine credentials, API keys are better than JWT because they are revocable without expiry gymnastics. The “JWT for everything” pattern creates problems; the “JWT for the right cases” pattern solves them.

How do I handle JWT refresh tokens in Rails?

Refresh tokens are themselves a form of server-side state. Issue the refresh token as a random opaque token stored in your database (like an API key), keep it out of the JWT payload, and exchange it for a new short-lived access JWT via a dedicated /auth/refresh endpoint. Never embed the refresh token in the access token. Never store either token in localStorage. If you are writing this much infrastructure to keep JWT alive, ask whether session cookies are a simpler answer for your use case.

Can I use session cookies with a React or Next.js frontend?

Yes, if they are on the same domain or the same eTLD+1. A Next.js app at app.example.com talking to a Rails API at api.example.com can share cookies with domain: ".example.com". Set SameSite: :lax and Secure: true in production. You will need config.action_dispatch.cookies_same_site_protection = :lax in your Rails initializer and CORS configured to include credentials. The cross-origin case — app.vercel.appapi.example.com — genuinely requires JWT or another non-cookie mechanism because SameSite restrictions prevent the cookie from being sent cross-site.

How do I revoke JWT tokens before they expire in Rails?

The honest answer is: you cannot, without server-side state. The common approaches are a Redis-backed token blocklist (a set of revoked JIDs — JWT IDs — that you check on every verification), keeping expiry very short (15 minutes) and accepting that window as your revocation latency, or using an opaque refresh token that you can revoke immediately and which invalidates the short-lived access token at its natural expiry. The deeper answer is: if you need instant revocation, JWT is the wrong tool. Use session cookies (browser) or API keys (machine), both of which support instant revocation without extra infrastructure.

Choosing the wrong authentication primitive for a Rails API creates technical debt that compounds for years. If you are designing a new auth layer or auditing an existing one, TTB Software helps Rails teams build authentication that is secure, maintainable, and actually appropriate for each client type. Nineteen years of production Rails — we have seen what the wrong choice costs.

#rails-api-authentication #rails-jwt-authentication #rails-api-keys #jwt-vs-sessions-rails #rails-bearer-token-authentication #rails-api-security

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software