Why token authentication matters for modern Rails APIs
Mobile apps and single-page applications can't rely on session cookies alone—they need a stateless way to prove who they are. Token authentication for Rails APIs: a step-by-step guide gives you a practical framework for securing these clients without the overhead of traditional server sessions. When your API serves requests from a native iOS app, a React SPA, and third-party integrations simultaneously, tokens become your primary authentication mechanism.
The challenge isn't just generating tokens; it's managing their lifecycle securely. A token that lives forever becomes a liability. A token that's stolen and sent over an unencrypted connection is worthless. In this guide, we'll walk through signed tokens, secure storage strategies, expiration and rotation patterns, and rate-limiting tactics that teams across e-commerce, SaaS, and travel platforms use to lock down their APIs.
Signed tokens or JWT: picking your approach
Rails developers typically face a choice between two token strategies: signed tokens (Rails-native) and JSON Web Tokens (JWT). Both work; the trade-off is complexity versus portability.
Signed tokens use Rails' built-in ActiveSupport::MessageEncryptor or MessageVerifier. They're simpler, don't require external gems, and keep your secret key management centralized in your Rails app. The token itself is opaque to clients—they don't parse it, just pass it back.
JWT tokens are JSON payloads signed with a secret. Clients can decode and inspect them without your server. This matters if you need to share tokens across multiple services or let mobile clients make decisions based on claim data. However, JWTs add dependency complexity and require careful claim validation.
For most Rails APIs, we recommend signed tokens unless you're running a microservices architecture. Here's a minimal example using Rails' built-in encryptor:
# app/models/auth_token.rb
class AuthToken
def self.encode(user_id, expires_in: 24.hours)
payload = {
user_id: user_id,
issued_at: Time.current.to_i,
expires_at: (Time.current + expires_in).to_i
}
encryptor.encrypt_and_sign(payload.to_json)
end
def self.decode(token)
JSON.parse(encryptor.decrypt_and_verify(token))
rescue ActiveSupport::MessageVerifier::InvalidSignature
nil
end
private
def self.encryptor
key = Rails.application.secrets.secret_key_base
ActiveSupport::MessageEncryptor.new(key[0..31])
end
endIf you must use JWT, the jwt gem is stable and well-maintained. Stick with HS256 (HMAC) for simplicity unless you need asymmetric signing (which adds operational overhead).
Store token digests, not tokens
Here's a critical security principle: never store the raw token in your database. If your database is breached, an attacker gains immediate access to every active session. Instead, store a hash digest of the token, similar to how you store password hashes.
When a client logs in, you generate a token, compute its digest, and persist the digest. On subsequent requests, you hash the incoming token and compare it against stored digests. If they match and the token hasn't expired, the request is authenticated.
Here's a practical token model:
# app/models/api_token.rb
class ApiToken < ApplicationRecord
belongs_to :user
before_create :generate_token_digest
scope :active, -> { where('expires_at > ?', Time.current) }
scope :by_digest, ->(digest) { where(token_digest: digest) }
def self.create_for_user(user, expires_in: 24.hours)
token = SecureRandom.hex(32)
digest = Digest::SHA256.hexdigest(token)
api_token = create!(
user: user,
token_digest: digest,
expires_at: Time.current + expires_in
)
[api_token, token]
end
def active?
expires_at > Time.current
end
private
def generate_token_digest
self.token_digest ||= Digest::SHA256.hexdigest(token)
end
end
# Migration
class CreateApiTokens < ActiveRecord::Migration[7.0]
def change
create_table :api_tokens do |t|
t.references :user, null: false, foreign_key: true
t.string :token_digest, null: false, index: { unique: true }
t.datetime :expires_at, null: false
t.timestamps
end
end
endIn your authentication controller, you'll extract the token from the request header, compute its digest, and look it up:
# app/controllers/concerns/token_authenticatable.rb
module TokenAuthenticatable
def authenticate_token!
token = extract_token_from_header
return unauthorized! unless token
digest = Digest::SHA256.hexdigest(token)
@api_token = ApiToken.active.by_digest(digest).first
return unauthorized! unless @api_token
@current_user = @api_token.user
end
private
def extract_token_from_header
header = request.headers['Authorization']
return nil unless header
header.gsub(/^Bearer\s+/, '')
end
def unauthorized!
render json: { error: 'Unauthorized' }, status: :unauthorized
end
end
# app/controllers/api/base_controller.rb
class Api::BaseController < ApplicationController
include TokenAuthenticatable
before_action :authenticate_token!
attr_reader :current_user
endExpire and rotate tokens
Tokens with indefinite lifespans are a liability. Set a reasonable expiration window—24 hours is common for web APIs, though some teams use shorter windows (1-4 hours) for higher-risk operations. Combine short-lived tokens with a refresh token pattern for better UX.
In the refresh token pattern, you issue two tokens: an access token (short-lived, used for API calls) and a refresh token (longer-lived, used to get a new access token). This way, if an access token is stolen, it has limited utility. The refresh token is stored securely on the client (httpOnly cookie for web, keychain for mobile).
Here's a two-token implementation:
# app/models/user.rb
class User < ApplicationRecord
has_many :api_tokens, dependent: :destroy
def issue_token_pair(expires_in: 24.hours, refresh_expires_in: 7.days)
access_token_record, access_token = ApiToken.create_for_user(
self,
expires_in: expires_in
)
refresh_token_record, refresh_token = ApiToken.create_for_user(
self,
expires_in: refresh_expires_in
)
refresh_token_record.update(token_type: :refresh)
{
access_token: access_token,
refresh_token: refresh_token,
expires_in: expires_in.to_i
}
end
end
# config/routes.rb
namespace :api do
post '/auth/login', to: 'auth#login'
post '/auth/refresh', to: 'auth#refresh'
post '/auth/logout', to: 'auth#logout'
end
# app/controllers/api/auth_controller.rb
class Api::AuthController < Api::BaseController
skip_before_action :authenticate_token!, only: [:login]
def login
user = User.find_by(email: params[:email])
unless user&.authenticate(params[:password])
return render json: { error: 'Invalid credentials' }, status: :unauthorized
end
token_pair = user.issue_token_pair
render json: token_pair
end
def refresh
refresh_digest = Digest::SHA256.hexdigest(params[:refresh_token])
refresh_token = ApiToken.where(
token_type: :refresh
).by_digest(refresh_digest).active.first
return unauthorized! unless refresh_token
new_pair = refresh_token.user.issue_token_pair
render json: new_pair
end
def logout
@api_token.destroy
render json: { success: true }
end
private
def unauthorized!
render json: { error: 'Unauthorized' }, status: :unauthorized
end
endToken rotation is equally important. If you suspect a token has been compromised, immediately invalidate it by deleting its digest record. For systematic rotation, some teams issue new tokens on every request (revoke old one, issue new one), but this adds database load. A simpler approach: rotate on refresh.
Rate-limit login endpoints
Your login endpoint is a brute-force target. An attacker can hammer it with millions of password guesses. Rate-limiting slows them down dramatically. Use Redis and a gem like rack-attack to throttle login attempts per IP and per username.
# Gemfile
gem 'rack-attack'
# config/initializers/rack_attack.rb
class Rack::Attack
# Allow 5 login attempts per IP per 15 minutes
throttle('login/ip', limit: 5, period: 15.minutes) do |req|
if req.path == '/api/auth/login' && req.post?
req.remote_ip
end
end
# Allow 3 login attempts per email per 15 minutes
throttle('login/email', limit: 3, period: 15.minutes) do |req|
if req.path == '/api/auth/login' && req.post?
req.params['email']&.to_s&.downcase
end
end
endAlso implement account lockout. After N failed login attempts, lock the account temporarily:
# app/models/user.rb
class User < ApplicationRecord
MAX_LOGIN_ATTEMPTS = 5
LOCKOUT_DURATION = 15.minutes
def lock_out!
update(
failed_login_attempts: MAX_LOGIN_ATTEMPTS,
locked_at: Time.current
)
end
def locked?
return false unless locked_at
locked_at > Time.current - LOCKOUT_DURATION
end
def authenticate_with_lockout(password)
if locked?
return false
end
if authenticate(password)
update(failed_login_attempts: 0, locked_at: nil)
return true
else
increment!(:failed_login_attempts)
lock_out! if failed_login_attempts >= MAX_LOGIN_ATTEMPTS
return false
end
end
end
# app/controllers/api/auth_controller.rb
def login
user = User.find_by(email: params[:email])
if !user || !user.authenticate_with_lockout(params[:password])
return render json: { error: 'Invalid credentials' }, status: :unauthorized
end
token_pair = user.issue_token_pair
render json: token_pair
endCommon mistakes
- Storing raw tokens in the database. If your DB is compromised, every active token is exposed. Always store digests.
- Tokens that never expire. A stolen token is dangerous indefinitely. Use short expiration windows and rotate regularly.
- No rate-limiting on login. Attackers will brute-force your endpoint. Add throttling and account lockout.
- Logging tokens in error messages or logs. Scrub sensitive data. Use a gem like
logrageand filter Authorization headers. - Shipping tokens over unencrypted connections. Always enforce HTTPS in production. Tokens over HTTP are trivially intercepted.
- Leaking token expiration in error responses. Don't tell clients "your token expired" if it helps attackers learn the expiration window. Return generic 401s.
- Reusing refresh tokens across devices. If a mobile client and web client share a refresh token, compromising one compromises both. Issue separate tokens per session/device.
Key takeaways
- For most Rails APIs, signed tokens using Rails' built-in encryptors are simpler and more secure than JWT.
- Store token digests (SHA-256 hashes), not raw tokens. Hash incoming tokens before lookup.
- Use short-lived access tokens (1-24 hours) paired with longer-lived refresh tokens (7-30 days) for better security and UX.
- Always rate-limit and implement account lockout on login endpoints to defend against brute-force attacks.
- Enforce HTTPS, scrub tokens from logs, and invalidate tokens immediately if compromise is suspected.
- Test your authentication flow end-to-end: login, token refresh, token expiration, and logout.






Comments
Loading comments…