How I Handle Authentication in Rails API Mode Without Overcomplicating It
by Eric Hanson, Backend Developer at Clean Systems Consulting
The decision before the implementation
Most Rails API authentication debates start with "JWT vs sessions" when the real first question is: who are the clients?
If the API is consumed by a mobile app or a single-page application you control, stateless token authentication makes sense — the client stores the token, sends it on every request, and you validate it without a database lookup.
If the API is consumed by other backend services (machine-to-machine), API keys with database lookup are simpler, auditable, and easier to revoke than tokens.
If the API is consumed by a server-rendered Rails frontend in the same app, session-based authentication with CSRF protection is the right default — it's what Rails was built for and it requires no extra machinery.
The implementation that follows covers the first two cases, which are the ones where the decision is non-obvious.
Stateless token authentication — the minimal implementation
JWT (JSON Web Token) is the dominant format for stateless API tokens. A JWT encodes claims (user ID, expiry, scopes) in a Base64-encoded payload signed with a secret. The server validates the signature without touching the database.
The jwt gem handles encoding and decoding:
# Gemfile
gem "jwt"
A token service that issues and verifies tokens:
module Auth
class TokenService
ALGORITHM = "HS256".freeze
EXPIRY_HOURS = 24
def self.issue(user_id:, scopes: [])
payload = {
sub: user_id,
scopes: scopes,
iat: Time.current.to_i,
exp: EXPIRY_HOURS.hours.from_now.to_i
}
JWT.encode(payload, secret, ALGORITHM)
end
def self.verify(token)
payload, _header = JWT.decode(token, secret, true, algorithm: ALGORITHM)
payload
rescue JWT::ExpiredSignature
raise Auth::TokenExpiredError
rescue JWT::DecodeError
raise Auth::TokenInvalidError
end
def self.secret
Rails.application.credentials.jwt_secret || raise("JWT secret not configured")
end
private_class_method :secret
end
end
iat (issued at) and exp (expiry) are standard JWT claims. sub (subject) conventionally holds the user identifier. scopes is a custom claim for scope-based authorization.
The token is issued on login and returned once. The client stores it — in memory for SPAs, in secure storage for mobile. It's sent on subsequent requests in the Authorization header:
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
A concern that authenticates requests:
module Api
module Authenticatable
extend ActiveSupport::Concern
included do
before_action :authenticate_request!
end
private
def authenticate_request!
token = extract_token
payload = Auth::TokenService.verify(token)
@current_user_id = payload["sub"]
rescue Auth::TokenExpiredError
render_error({ code: "token_expired", message: "Token has expired" }, status: :unauthorized)
rescue Auth::TokenInvalidError
render_error({ code: "token_invalid", message: "Token is invalid" }, status: :unauthorized)
end
def extract_token
header = request.headers["Authorization"]
raise Auth::TokenInvalidError unless header&.start_with?("Bearer ")
header.split(" ").last
end
def current_user
@current_user ||= User.find(@current_user_id)
end
end
end
current_user is loaded lazily — the database query only fires when a controller actually calls current_user. Controllers that only need the user ID (for scoping queries) use @current_user_id directly and never hit the database for authentication.
The session controller:
module Api
module V1
class SessionsController < Api::BaseController
skip_before_action :authenticate_request!
def create
user = User.find_by(email: session_params[:email])
if user&.authenticate(session_params[:password])
token = Auth::TokenService.issue(user_id: user.id)
render_success({ token: token, expires_in: 24.hours.to_i })
else
render_error({ code: "invalid_credentials", message: "Email or password is incorrect" }, status: :unauthorized)
end
end
private
def session_params
params.require(:session).permit(:email, :password)
end
end
end
end
User#authenticate comes from has_secure_password — bcrypt password verification built into Rails. No Devise required for this flow.
What JWT doesn't give you — and what to do about it
JWTs are stateless. Once issued, a token is valid until it expires. You cannot invalidate a specific token without either:
- Keeping a denylist — a database table or Redis set of revoked token IDs (
jticlaim). Every request checks the denylist, eliminating the stateless advantage. - Using short expiry with refresh tokens — access tokens expire in 15–60 minutes; refresh tokens are long-lived, stored server-side, and used to issue new access tokens.
The denylist approach is simpler and appropriate for internal APIs or low-security applications. The refresh token approach is appropriate for public APIs where stolen tokens are a genuine threat.
Short-expiry access tokens with refresh tokens:
# Issue both at login
def create
# ...
access_token = Auth::TokenService.issue(user_id: user.id, expiry: 15.minutes)
refresh_token = RefreshToken.create!(user: user, expires_at: 30.days.from_now)
render_success({
access_token: access_token,
refresh_token: refresh_token.token,
expires_in: 15.minutes.to_i
})
end
# Refresh endpoint
def refresh
token = RefreshToken.find_by!(token: params[:refresh_token])
raise Auth::TokenExpiredError if token.expires_at < Time.current
token.rotate! # invalidate old, issue new
new_access = Auth::TokenService.issue(user_id: token.user_id, expiry: 15.minutes)
render_success({ access_token: new_access, expires_in: 15.minutes.to_i })
end
token.rotate! generates a new refresh token and invalidates the old one. Refresh token rotation means a stolen refresh token can only be used once before it's invalidated by the legitimate client's next refresh.
API key authentication for machine-to-machine
Backend-to-backend authentication doesn't need JWTs. A random token associated with a record in the database is simpler, auditable by default, and trivially revocable:
class ApiKey < ApplicationRecord
belongs_to :account
before_create :generate_token
scope :active, -> { where(revoked_at: nil) }
def revoke!
update!(revoked_at: Time.current)
end
private
def generate_token
self.token = SecureRandom.hex(32)
end
end
Store a hash of the token, not the token itself. The token is shown to the client once on creation and never stored in plaintext:
class ApiKey < ApplicationRecord
attribute :raw_token # virtual, not persisted
before_create :hash_token
def self.authenticate(raw_token)
hashed = Digest::SHA256.hexdigest(raw_token)
active.find_by(token_digest: hashed)
end
private
def hash_token
self.raw_token = SecureRandom.hex(32)
self.token_digest = Digest::SHA256.hexdigest(raw_token)
end
end
The authentication concern for API key requests:
def authenticate_request!
raw_token = request.headers["X-API-Key"]
api_key = ApiKey.authenticate(raw_token)
if api_key
@current_account = api_key.account
api_key.touch(:last_used_at) # optional — last-used auditing
else
render_error({ code: "invalid_api_key", message: "API key is invalid or revoked" }, status: :unauthorized)
end
end
The database lookup is one query against an indexed token_digest column. It's fast, it's auditable, and revoking a key is api_key.revoke! — instant, no token expiry wait.
The JWT secret management that actually matters
JWT security rests entirely on the secrecy of the signing secret. Three rules:
Generate it with sufficient entropy. A 256-bit random value:
rails secret
# => c1a2b3d4e5f6... (64 hex characters = 256 bits)
Store it in Rails credentials, not environment variables in version control:
rails credentials:edit
# jwt_secret: c1a2b3d4e5f6...
Rotate it when compromised. Rotation invalidates all outstanding tokens — every client must reauthenticate. Plan for this in the client integration: surface clear error messaging when tokens are globally invalidated, and provide a re-authentication flow.
When to add Devise
Devise is worth adding when you need: email confirmation, password reset flows, account locking after failed attempts, Omniauth for third-party OAuth, or remember-me sessions. These are solved problems and Devise's implementations are well-tested.
Devise is not worth adding when you need: token authentication only, API key management, or a custom authentication flow that doesn't map to Devise's assumptions. Devise's devise-jwt extension exists but requires careful configuration to avoid common pitfalls around token revocation.
The minimal setup — has_secure_password, a token service, and a sessions controller — handles the 80% case with no Devise dependency. Add Devise when you need the remaining 20%.