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.
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:toJWT.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.app → api.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.
Related Articles
Rails pgvector: Semantic Search and RAG with PostgreSQL for LLM Applications
Rails pgvector guide: build semantic search and RAG on PostgreSQL with embeddings, HNSW indexes, and hybrid retrieval...
Rails Passkeys: WebAuthn Passwordless Authentication with webauthn-ruby in Rails 8
Rails passkeys and WebAuthn in Rails 8: ship production passwordless authentication with webauthn-ruby, from registra...
Rails PostgreSQL Row-Level Security: Multi-Tenant SaaS Isolation with RLS Policies
Rails PostgreSQL Row-Level Security for multi-tenant SaaS: how to implement RLS policies, session variables, and safe...