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:

  1. Keeping a denylist — a database table or Redis set of revoked token IDs (jti claim). Every request checks the denylist, eliminating the stateless advantage.
  2. 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%.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Australia's Backend Talent Pool Is Tiny Compared to Demand — Remote Contractors Close the Gap

You've been looking for a backend engineer for two months. The recruiter keeps sending frontend developers who "also do Node." That's not the same thing.

Read more

No Online System Is Safe? Why Forcing Developers Into the Office Backfires

Managers love to claim “no online system is safe” as a reason to pull developers into the office. But forcing presence often drains lives more than it protects systems.

Read more

Amsterdam Backend Salaries Hit €100K. Here Is How Startups Avoid That Overhead

Your next backend hire in Amsterdam will probably cost you six figures before you even factor in the 30% ruling changes and mandatory benefits. That number used to be reserved for staff engineers. Now it's table stakes for anyone decent.

Read more

Employee vs Contractor: The Real Financial Difference

Why that “expensive” contractor rate isn’t as simple as it looks (and why employees aren’t as cheap as they seem)

Read more