Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 026 / 100

Token authentication for Rails APIs: a step-by-step guide

Secure mobile and SPA clients.

Expona Admin8 min read

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:

ruby
# 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
end

If 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:

ruby
# 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
end

In your authentication controller, you'll extract the token from the request header, compute its digest, and look it up:

ruby
# 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
end

Expire 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:

ruby
# 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
end

Token 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.

ruby
# 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
end

Also implement account lockout. After N failed login attempts, lock the account temporarily:

ruby
# 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
end

Common 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 lograge and 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles