LaunchKit

Rails JWT API authentication with ActionController::API

September 12, 2026

A JSON API in Rails 8 does not need an authentication gem. ActionController::API, User.authenticate_by and the jwt gem are enough to exchange credentials for a signed bearer token, verify that token on every request, and hand the action the user it names.

Rails JWT authentication does need that one outside gem. Nothing in Action Pack, Active Model or Active Record encodes or decodes a JWT, and this repository declares gem "jwt" in its Gemfile with no version constraint, resolving to jwt 3.2.0. What Rails contributes is the controller stack, the constant-time credential check and the signing key. Everything below is the API layer of this boilerplate, quoted as it runs.

What ActionController::API drops from the controller stack

ActionController::API is ActionController::Base with the browser half removed. Its MODULES list in Action Pack includes StrongParameters, RateLimiting, Renderers::All, ConditionalGet and the callback, rescue and instrumentation modules. It does not include cookies, flash, CSRF protection or template rendering. The class documentation says so directly: the stack "doesn't include a number of features that are usually required by browser access only: layouts and templates rendering, flash, assets, and so on".

What survives is the part an API actually uses. "Request, response, and parameters objects all work the exact same way as ActionController::Base", so params, request and render json: behave exactly as you already know them.

Two consequences follow. There is no session cookie, so there is nothing to forge and no authenticity token to skip: a controller inheriting from ActionController::API is stateless because the state was never included, not because somebody switched it off. And because templates are never rendered, an action that renders nothing does not raise a missing-template error. The documentation spells out what happens instead: "you need to ensure your controller is calling either render or redirect_to in all actions, otherwise it will return 204 No Content." A forgotten render in an API controller is a silent 204, which a client reads as success.

The API base controller, and why it skips ApplicationController

The API base controller inherits ActionController::API directly instead of descending from ApplicationController:

module Api
  module V1
    class BaseController < ActionController::API
      include FeatureGated

      gated_by :api

      before_action :authenticate_api_user!

      private

      def authenticate_api_user!
        @current_user = Api::Auth::VerifyToken.new(bearer_token).call
        head :unauthorized unless @current_user
      end

      def current_user
        @current_user
      end

      def bearer_token
        request.authorization&.match(/\ABearer (.+)\z/)&.captures&.first
      end
    end
  end
end

The inheritance line is the decision, and it is the one most often got wrong. ApplicationController on the HTML side carries the cookie session lookup, the onboarding gate and everything else a browser needs. An API controller that inherited from it would run a cookie-based authentication pass and then a token-based one, and would answer a redirect to a login page where it meant to answer JSON.

Authentication itself is one before_action and two lines. VerifyToken returns a user or nil, and nil becomes head :unauthorized. head rather than render is deliberate: the 401 carries an empty body, so a caller learns the request was rejected and learns nothing about which of the several possible reasons applied.

Pulling the bearer token out of the Authorization header without a helper

The bearer token is extracted by hand, with an anchored regular expression and no Action Pack helper in sight:

request.authorization&.match(/\ABearer (.+)\z/)&.captures&.first

request.authorization is plain Action Dispatch, and it does slightly more than read one header. It tries four in order: HTTP_AUTHORIZATION, X-HTTP_AUTHORIZATION, X_HTTP_AUTHORIZATION and REDIRECT_X_HTTP_AUTHORIZATION. The three alternates exist because some proxies and some FastCGI setups rename or drop the original, and an API that reads only the first one works everywhere except behind the customer's load balancer.

Rails also ships ActionController::HttpAuthentication::Token, whose authenticate_with_http_token parses the same header against TOKEN_REGEX = /^(Token|Bearer)\s+/ and then splits whatever follows on commas, semicolons and tabs into a token plus an options hash. Nothing in this API namespace uses it. The helper's grammar accepts Token token="abc", nonce="xyz"; the regex above accepts exactly one shape, Bearer <token>, which is the only shape this API issues and therefore the only one it needs to read.

A malformed header never raises here. match returns nil, the safe navigation carries the nil through, VerifyToken receives nil and returns nil, and the callback turns that into the same 401 as a forged token.

Issuing the token: the sub claim, the exp claim and HS256

def call
  payload = { sub: user.id, exp: @expires_in.from_now.to_i }
  JWT.encode(payload, Api::Auth.secret, "HS256")
end

Api::Auth::IssueToken builds a two-claim payload and signs it with HS256. sub holds the user id, exp holds a Unix timestamp, and the constructor defaults expires_in: to 24.hours, so a token issued at login stops verifying a day later with nothing stored anywhere to make that happen.

The sub claim here holds an Integer, user.id, while RFC 7519 section 4.1.2 states that "the 'sub' value is a case-sensitive string containing a StringOrURI value". The jwt gem does not enforce it: the subject verifier only runs when the caller passes a :sub option, and this code does not pass one. The deviation is invisible while both ends are this codebase, and it is exactly the sort of thing a third-party client written against the spec will trip over.

Nothing else goes into the payload. No email address, no role, no name. A JWT is signed, not encrypted, so every claim in it is readable by anyone holding the token; base64 is an encoding, not a lock. Putting the role in the payload would also mean an admin demoted at nine in the morning keeps the claim until the token expires, because there is nothing left to re-read.

Verifying the token, and the one rescue that covers every decode failure

def call
  return if token.blank?

  payload, = JWT.decode(token, Api::Auth.secret, true, algorithm: "HS256")
  User.find_by(id: payload["sub"])
rescue JWT::DecodeError
  nil
end

Api::Auth::VerifyToken returns a user or nil and never raises. One early return, one decode, one lookup and one rescue clause cover every way a token can be wrong.

The single rescue works because of how the jwt gem arranges its exception tree. VerificationError, ExpiredSignature, IncorrectAlgorithm, Base64DecodeError and MissingRequiredClaim all inherit from JWT::DecodeError, so rescuing the parent catches a forged signature, an expired token, a token signed with an algorithm you do not accept, and a string that is not a JWT at all. A caller of this service sees one outcome for all of them: nil.

Passing algorithm: "HS256" explicitly matters more than it reads. The gem compares the token's own alg header against the allowed list before it does anything else, which is what stops a caller submitting a token whose header claims none. jwt 3.2.0 already defaults its algorithm list to ['HS256'], so this argument restates the default rather than creating it, and a default is something that can change under you across a major version while an explicit argument is not.

The lookup is find_by(id:), not find. A perfectly valid, unexpired token that names a user who has since been deleted resolves to nil, and comes back to the client as a 401 rather than a 500.

The exp claim is only checked when the token carries one

The jwt gem's expiration verifier returns without doing anything at all when the payload has no exp key:

return unless context.payload.key?('exp')

raise JWT::ExpiredSignature, 'Signature has expired' if context.payload['exp'].to_i <= (Time.now.to_i - leeway)

A token issued without an exp claim is therefore never rejected on age. verify_expiration defaults to true in jwt 3.2.0, which reads like a promise that tokens expire, and it is not one: the flag decides whether the claim is checked, not whether the claim is required. An issuer that forgets to write exp produces tokens that live until the signing key changes.

Two defences exist against that and this codebase uses the first. Every token in the app comes from one place, Api::Auth::IssueToken, which always writes exp. The second is the gem's required_claims option, which turns a missing claim into a JWT::MissingRequiredClaim and therefore, through the rescue above, into a nil; it is not passed here.

Verification order is the other half of this. JWT::Decode#decode_segments validates the segment count, then the algorithm, then the key, then the signature, and only then runs the claim verifiers. A forged token never reaches the expiry check, which is the right order: a token's claims should mean nothing until the signature says they are yours. The comparison itself is Time.now.to_i against the integer exp, with a default leeway of zero seconds.

The signing key behind API tokens, and rotating it on its own

Api::Auth.secret is the one key every token is signed and verified with, and it resolves through a deliberate fallback:

def jwt_secret = Rails.application.credentials.jwt_secret.presence || Rails.application.secret_key_base

A dedicated credential wins when it is set, and secret_key_base is the fallback. The fallback is what lets a fresh install issue tokens with no setup at all, since every Rails app already has a secret_key_base.

Setting credentials.jwt_secret buys one thing, and it is the whole reason for the indirection: it separates the lifetime of API tokens from the lifetime of everything else Rails signs with secret_key_base. In this app that "everything else" includes the signed session cookie and every generates_token_for token, the password reset link among them. Rotating secret_key_base to invalidate outstanding API tokens would also sign out every browser session and kill every unused reset link sitting in an inbox. With jwt_secret set, changing it invalidates API tokens and touches nothing else.

Rotation is also the only revocation this design has, and it is indiscriminate. It invalidates every token held by every client at the same instant. There is no way to kill one.

The login endpoint: authenticate_by, and the 401 that says nothing

def create
  user = User.authenticate_by(email_address: params[:email_address], password: params[:password])

  if user&.confirmed?
    render json: {
      token: Api::Auth::IssueToken.new(user).call,
      user: Api::V1::UserSerializer.new(user)
    }, status: :created
  else
    render json: { error: I18n.t("api.errors.invalid_credentials") }, status: :unauthorized
  end
end

User.authenticate_by is the same Active Record method the HTML login uses, and it is what makes a credentials endpoint safe to expose without a gem. It digests the submitted password whether or not a record was found: when find_by misses, it still calls new(passwords) before returning nil. The Rails documentation illustrates the effect with three timings from one example, 373.4ms for a correct login, 373.9ms for a wrong password and 373.6ms for an unknown address. What one line of has_secure_password gives you takes the method and its digest comparison apart in more detail.

One shortcut is worth knowing before leaning on those timings: authenticate_by returns immediately, with no query executed at all, when a password argument is nil or empty. A request that posts no password is answered without the database being touched. It also raises ArgumentError when given no password attribute or no finder attribute, so a typo in a keyword is a 500 rather than a quiet nil.

The failure branch is uninformative on purpose. A wrong password, an unknown address and a correct password on an unconfirmed account all produce 401 and the same string, "Invalid email address or password." Distinguishing them would be friendlier to a confused user and would also confirm to an attacker that a given address has an account here. skip_before_action :authenticate_api_user!, only: :create is what lets this one action run without a token.

The two routes, the singular resource, and the JSON that comes back

The API declares two routes, and both are singular resources:

namespace :api do
  namespace :v1 do
    resource :session, only: :create   # POST /api/v1/session -> token
    resource :user, only: %i[show update] # GET/PATCH /api/v1/user (token holder)
  end
end

resource singular rather than resources plural is what gives the profile endpoint the path /api/v1/user, with no id segment. An id would be a lie: Api::V1::UsersController reads and writes current_user and never a record named by params, so /api/v1/users/:id would advertise an authorisation decision the controller does not actually make.

Updates go through the Rails 8 strong-parameters form, params.expect(user: %i[name company_name]). expect renders a 400 response on a missing key rather than raising; expect! is the variant that raises an unhandled exception, documented as being for debugging a client library that is sending malformed params. At a public boundary the first behaviour is the one you want.

The response shape is a plain Ruby object with an as_json, not a serializer gem:

def as_json(*)
  {
    id: user.id,
    email_address: user.email_address,
    name: user.name,
    company_name: user.company_name,
    role: user.role,
    confirmed: user.confirmed?,
    onboarded: user.onboarded?
  }
end

render json: calls to_json on whatever object it is handed, so anything answering as_json is all the plumbing needed. Writing the seven fields out by hand costs a line each and buys the guarantee that a new column on users cannot appear in a public response because somebody ran a migration.

The feature gate that answers 404 before the authentication callback

The whole JSON API sits behind a feature flag, and the flag is checked before the token is:

include FeatureGated

gated_by :api

gated_by installs a prepend_before_action that runs head :not_found if Feature.disabled?(feature). Prepending is the entire point. A plain before_action would queue behind authenticate_api_user!, and a caller with no token would receive a 401 from a module that is switched off, which confirms the module exists. Prepended, a disabled API is simply absent: 404, with or without a token. The concern states the intent in its own comment, that "a disabled feature is 'not found' even to anonymous callers, rather than bouncing them to a login page".

The api feature is registered as Definition.new("api", true), so it ships enabled and an operator switches it off from the admin. Overrides live on a features jsonb column and anything unset falls back to the definition's default.

One interaction catches people, and it is the useful kind. Api::V1::SessionsController opts out of authentication for its create action with skip_before_action :authenticate_api_user!, only: :create, and that skip cannot touch the gate: gated_by installs an anonymous block, which is a separate callback with no name to name in a skip. So POST /api/v1/session answers 404 while the feature is off, which is the correct answer. A login endpoint still minting tokens for a disabled API would be a hole in the switch.

What a stateless token costs: no revocation, no refresh, no logout

A JWT cannot be taken back, and this API does not pretend otherwise. There is no jti claim, no denylist, no refresh token and no logout endpoint anywhere in the API namespace; the route file declares two endpoints and neither destroys anything. An issued token is valid until its exp passes, and the only lever is rotating the signing key, which invalidates every token held by everyone.

Sessions as database rows sit at the other end of that trade, and a trade is what it is rather than a mistake on either side. A session row can be destroyed and the credential is dead on the next request. A JWT needs no lookup at all, which is what makes it worth carrying when the holder is a mobile client or a server you do not operate. Adding revocation means adding the lookup back: a jti claim, a store of revoked ids, and a read of that store on every request, at which point the honest question is whether the token is still buying anything a session row would not.

Two further gaps are worth naming as absences rather than decisions. Nothing in the API namespace is rate limited, although ActionController::API does include the RateLimiting module and the HTML login carries rate_limit to: 10, within: 3.minutes, so the capability is present and unused. And there is no CORS configuration anywhere in this repository, which stays invisible right up until a browser client calls the API from another origin.

More on Rails 8 authentication

← All Rails 8 authentication articles