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:
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 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
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
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:
returnunlesscontext.payload.key?('exp')raiseJWT::ExpiredSignature,'Signature has expired'ifcontext.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:
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
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:
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:
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:
includeFeatureGatedgated_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.
A JSON API in Rails 8 does not need an authentication gem.
ActionController::API,User.authenticate_byand thejwtgem 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::APIdrops from the controller stackActionController::APIisActionController::Basewith the browser half removed. ItsMODULESlist in Action Pack includesStrongParameters,RateLimiting,Renderers::All,ConditionalGetand 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,requestandrender 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::APIis 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 eitherrenderorredirect_toin all actions, otherwise it will return204 No Content." A forgottenrenderin an API controller is a silent 204, which a client reads as success.The API base controller, and why it skips
ApplicationControllerThe API base controller inherits
ActionController::APIdirectly instead of descending fromApplicationController:The inheritance line is the decision, and it is the one most often got wrong.
ApplicationControlleron 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_actionand two lines.VerifyTokenreturns a user or nil, and nil becomeshead :unauthorized.headrather thanrenderis 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
Authorizationheader without a helperThe bearer token is extracted by hand, with an anchored regular expression and no Action Pack helper in sight:
request.authorizationis plain Action Dispatch, and it does slightly more than read one header. It tries four in order:HTTP_AUTHORIZATION,X-HTTP_AUTHORIZATION,X_HTTP_AUTHORIZATIONandREDIRECT_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, whoseauthenticate_with_http_tokenparses the same header againstTOKEN_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 acceptsToken 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.
matchreturns nil, the safe navigation carries the nil through,VerifyTokenreceives nil and returns nil, and the callback turns that into the same 401 as a forged token.Issuing the token: the
subclaim, theexpclaim and HS256Api::Auth::IssueTokenbuilds a two-claim payload and signs it with HS256.subholds the user id,expholds a Unix timestamp, and the constructor defaultsexpires_in:to24.hours, so a token issued at login stops verifying a day later with nothing stored anywhere to make that happen.The
subclaim 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:suboption, 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
Api::Auth::VerifyTokenreturns 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,Base64DecodeErrorandMissingRequiredClaimall inherit fromJWT::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 ownalgheader against the allowed list before it does anything else, which is what stops a caller submitting a token whose header claimsnone. 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:), notfind. 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
expclaim is only checked when the token carries oneThe jwt gem's expiration verifier returns without doing anything at all when the payload has no
expkey:A token issued without an
expclaim is therefore never rejected on age.verify_expirationdefaults 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 writeexpproduces 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 writesexp. The second is the gem'srequired_claimsoption, which turns a missing claim into aJWT::MissingRequiredClaimand therefore, through the rescue above, into a nil; it is not passed here.Verification order is the other half of this.
JWT::Decode#decode_segmentsvalidates 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 isTime.now.to_iagainst the integerexp, with a default leeway of zero seconds.The signing key behind API tokens, and rotating it on its own
Api::Auth.secretis the one key every token is signed and verified with, and it resolves through a deliberate fallback:A dedicated credential wins when it is set, and
secret_key_baseis the fallback. The fallback is what lets a fresh install issue tokens with no setup at all, since every Rails app already has asecret_key_base.Setting
credentials.jwt_secretbuys 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 withsecret_key_base. In this app that "everything else" includes the signed session cookie and everygenerates_token_fortoken, the password reset link among them. Rotatingsecret_key_baseto invalidate outstanding API tokens would also sign out every browser session and kill every unused reset link sitting in an inbox. Withjwt_secretset, 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 nothingUser.authenticate_byis 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: whenfind_bymisses, it still callsnew(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 ofhas_secure_passwordgives you takes the method and its digest comparison apart in more detail.One shortcut is worth knowing before leaning on those timings:
authenticate_byreturns 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 raisesArgumentErrorwhen 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: :createis 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:
resourcesingular rather thanresourcesplural is what gives the profile endpoint the path/api/v1/user, with no id segment. An id would be a lie:Api::V1::UsersControllerreads and writescurrent_userand never a record named by params, so/api/v1/users/:idwould 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]).expectrenders 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:render json:callsto_jsonon whatever object it is handed, so anything answeringas_jsonis all the plumbing needed. Writing the seven fields out by hand costs a line each and buys the guarantee that a new column onuserscannot 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:
gated_byinstalls aprepend_before_actionthat runshead :not_found if Feature.disabled?(feature). Prepending is the entire point. A plainbefore_actionwould queue behindauthenticate_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
apifeature is registered asDefinition.new("api", true), so it ships enabled and an operator switches it off from the admin. Overrides live on afeaturesjsonb column and anything unset falls back to the definition's default.One interaction catches people, and it is the useful kind.
Api::V1::SessionsControlleropts out of authentication for its create action withskip_before_action :authenticate_api_user!, only: :create, and that skip cannot touch the gate:gated_byinstalls an anonymous block, which is a separate callback with no name to name in a skip. SoPOST /api/v1/sessionanswers 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
jticlaim, 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 itsexppasses, 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
jticlaim, 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::APIdoes include theRateLimitingmodule and the HTML login carriesrate_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.