Rails API Authentication With JWT: Complete Guide
Learn how to secure a Rails API with JWT authentication, token verification, authorization, and security best practices.
• 27 min read
• 27 min read
Learn how to secure a Rails API with JWT authentication, token verification, authorization, and security best practices.
• 27 min read
• 27 min read
If you are building an API with Ruby on Rails, you eventually have to answer a simple but important question: how do you know who is making each request? A public blog might not care. A banking API absolutely does. Almost every real application sits somewhere between those extremes, and that is where authentication comes in.
Authentication confirms that a client is who they claim to be. Authorization is a related but different process that decides what an authenticated client is allowed to do. Keeping these separate saves you from confusing bugs later. A user can be fully authenticated and still be forbidden from deleting someone else's blog post. That is authorization at work, not authentication.
JSON Web Tokens, or JWTs, are one popular approach for authenticating API clients. They are not automatically the best choice for every project, but they work well for stateless APIs supporting mobile apps, single-page applications, or third-party clients. Rails session-based authentication, relying on cookies and server-side storage, works beautifully for traditional server-rendered apps but grows awkward once you have a React frontend on a different domain or a mobile app with no browser cookie jar.
In this article, you will build a complete JWT authentication system for a Rails 8 API: a User model with secure password handling, token generation and verification with the jwt gem, a reusable authentication layer, protected endpoints, authorization on top of authentication, and tests that prove it works. You will also learn what belongs in a JWT payload and how to reason about expiration, refresh tokens, and revocation.
If you have not built a Rails JSON API before, read How to Build a JSON API Using Ruby on Rails first, since this article builds directly on that foundation.
A JSON Web Token is a compact, URL-safe string representing a set of claims, pieces of information about a user or session. A JWT is not encrypted by default. It is encoded and signed, meaning anyone can read the contents, but only the server holding the secret key can verify the token hasn't been tampered with.
A JWT has three parts, separated by dots: a header, a payload, and a signature.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiIsImV4cCI6MTcxMDAwMDAwMH0.4f8f1e2b9c...
Header. Describes the token itself, typically the signing algorithm and token type. Decoded:
{
"alg": "HS256",
"typ": "JWT"
}
Payload. Contains the actual claims, such as the user's ID, an expiration timestamp, and anything else you choose to include. Decoded:
{
"sub": "42",
"exp": 1710000000,
"iat": 1709996400
}
Signature. Generated by combining the encoded header, encoded payload, and a secret key, then running them through the signing algorithm. The server uses this signature to verify the token was issued by itself and has not been modified in transit.
Because the payload is only encoded, anyone can decode a JWT and read its contents with nothing more than a text editor. This trips up developers new to JWT: the signature protects against tampering, not against being read. Never put a password or other sensitive data inside a JWT payload.
Tokens are generated after a successful login using a secret key only the server knows, and verified on every request by recalculating the signature. JWTs suit APIs because they are self-contained and stateless, which makes them a natural fit for distributed systems, mobile clients, and APIs consumed by multiple frontends.
Before writing any code, it helps to see the full authentication flow laid out step by step.
Authorization header.Here is a simple diagram of that flow:
Client Rails API
| |
|--- POST /login (email/pw) ----->|
| |--- verify credentials
|<---- 200 OK { token: "..." } ---|
| |
|--- GET /profile ---------------->|
| Authorization: Bearer <jwt> |
| |--- verify signature
| |--- check expiration
| |--- identify current_user
| |--- check authorization
|<---- 200 OK { user data } ------|
Notice that steps 7 and 8 are distinct. Identifying the user is authentication. Deciding whether that user can do what they are asking to do is authorization. You will build both, and you will keep them in separate layers of your code.
Rails has excellent built-in support for session-based authentication using cookies. So why reach for JWT at all? It depends on what you are building.
Session-based authentication stores a session identifier in a cookie and keeps the actual session data on the server, often in a database, Redis, or an encrypted cookie store. This works well when Rails is also rendering the views, since the browser handles cookies automatically. It is also easy to revoke: destroy the session record, and the user is logged out immediately. The tradeoff is that sessions are inherently stateful, which can complicate scaling across multiple servers, and cookies do not always translate cleanly to mobile apps or single-page applications hosted on a different domain than the API.
JWT flips this around. The server stores nothing about the session; everything needed to authenticate a request travels with the request itself, inside the token. This makes JWT a natural fit for:
The tradeoff with JWT is revocation. Because a signed token stays valid until it expires, with nothing tracked server-side, there is no simple "log this token out" switch the way there is with a session. You will look at strategies for handling this later, but it is worth knowing up front: stateless authentication is a genuine tradeoff, not a strictly better replacement for sessions.
For traditional Rails apps with server-rendered views, sessions are usually the simpler, more secure choice. For APIs serving a mobile app, a JavaScript frontend, or external clients, JWT is often the better fit.
Start by generating a new API-only Rails application. The --api flag strips out middleware and generators only useful for full-stack apps with views.
rails new jwt_api_demo --api
cd jwt_api_demo
If you have not built a JSON API in Rails before, the JSON API article on CodeCurious walks through controllers, serializers, and routes for an API-only app. This article assumes that foundation and focuses on authentication.
Next, generate the User model. You will use has_secure_password, which relies on bcrypt, so add it to your Gemfile if it is not already there.
# Gemfile
gem "bcrypt", "~> 3.1.7"
Run bundle install, then generate the model:
rails generate model User email:string password_digest:string
rails db:migrate
Add a uniqueness constraint and a database-level index on email before running the migration, since you will look up users by email on every login:
class CreateUsers < ActiveRecord::Migration[8.0]
def change
create_table :users do |t|
t.string :email, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email, unique: true
end
end
Now set up routes for registration, login, and a protected profile endpoint:
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
post "signup", to: "registrations#create"
post "login", to: "sessions#create"
get "profile", to: "users#show"
end
end
end
Namespacing your API under api/v1 from the start makes it easier to introduce breaking changes later without disrupting existing clients.
There are two general approaches to JWT in Rails: a small, focused gem like jwt that you wire up yourself, or a full authentication library bundling JWT with other features. The jwt gem does one thing well, encoding and decoding tokens without making assumptions about your models or routes, which gives you full control and a clear mental model of what your code is doing. Full authentication libraries make sense when you need a lot of functionality quickly, such as OAuth or password reset flows, and are comfortable adopting the library's conventions, at the cost of less control.
This article uses the jwt gem directly, since it gives you the clearest picture of how JWT actually works.
Add it to your Gemfile:
# Gemfile
gem "jwt"
Run bundle install to finish setup.
When evaluating any JWT library, check how actively it is maintained, whether it defaults to a secure signing algorithm, whether it makes expiration and claim validation easy, and whether its documentation clearly explains encoding versus encryption. A library that glosses over that distinction is a red flag.
With the User model and migration in place, add validations and the has_secure_password macro:
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
validates :email, presence: true, uniqueness: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 8 }, if: -> { password.present? }
end
has_secure_password adds password validation, hashing, and authentication automatically, as long as your table has a password_digest column. It gives you virtual password and password_confirmation attributes, plus a user.authenticate(password) method that returns the user if the password is correct and false otherwise.
Passwords should never be stored in plain text. A compromised database with plain text passwords hands an attacker instant access to every account, and since people reuse passwords across services, the damage extends beyond your own app. has_secure_password uses bcrypt to hash passwords with a salt, so even identical passwords produce different digests, and reversing a bcrypt hash is computationally impractical.
Now build the registration endpoint:
# app/controllers/api/v1/registrations_controller.rb
module Api
module V1
class RegistrationsController < ApplicationController
def create
user = User.new(user_params)
if user.save
token = JsonWebToken.encode(user_id: user.id)
render json: { token: token, user: { id: user.id, email: user.email } },
status: :created
else
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(:email, :password, :password_confirmation)
end
end
end
end
Notice the response only includes the user's id and email, never the password digest. The JsonWebToken class is built next.
Create a small, reusable service for encoding and decoding tokens, so you never repeat token handling code across controllers.
# app/lib/json_web_token.rb
class JsonWebToken
ALGORITHM = "HS256"
def self.encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
payload[:iat] = Time.current.to_i
JWT.encode(payload, secret_key, ALGORITHM)
end
def self.decode(token)
decoded = JWT.decode(token, secret_key, true, algorithm: ALGORITHM)
HashWithIndifferentAccess.new(decoded[0])
rescue JWT::ExpiredSignature, JWT::DecodeError
nil
end
def self.secret_key
Rails.application.credentials.jwt_secret_key || Rails.application.secret_key_base
end
end
The secret key is pulled from Rails credentials rather than hardcoded anywhere. Set it with:
rails credentials:edit
And add a line like:
jwt_secret_key: a_long_random_string_generated_securely
Never commit secrets into source code, and never fall back to a weak or predictable default in production. The HS256 algorithm is explicitly specified rather than left to chance, which matters for security reasons covered later.
Every payload includes an exp claim and an iat claim. The exp claim lets decode automatically reject expired tokens by passing true as the third argument to JWT.decode, telling the gem to verify the signature and validate registered claims like expiration.
Try it in the Rails console:
token = JsonWebToken.encode(user_id: 1)
# => "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE3MTAwODY0MDAsImlhdCI6MTcxMDAwMDAwMH0.xxxx"
JsonWebToken.decode(token)
# => { "user_id" => 1, "exp" => 1710086400, "iat" => 1710000000 }
Token verification happens on every authenticated request. The client sends the token in the Authorization header using the Bearer scheme:
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxfQ.xxxx
Your Rails app needs to extract that header, pull out the token, decode it, and verify both the signature and the expiration before trusting anything inside it. Here is the extraction logic, folded into an authentication concern next:
def extract_token_from_header
header = request.headers["Authorization"]
header&.split(" ")&.last
end
If the header is missing, extract_token_from_header returns nil. If the token was tampered with or signed with a different secret, JWT.decode raises JWT::DecodeError, which JsonWebToken.decode rescues into nil. An expired token raises JWT::ExpiredSignature, handled the same way. This lets controller code treat "missing," "invalid," and "expired" uniformly as "no valid user found," while you can still return more specific error messages for debugging.
Rather than repeating token extraction and verification in every controller, build a shared concern any controller can include:
# app/controllers/concerns/authenticatable.rb
module Authenticatable
extend ActiveSupport::Concern
included do
before_action :authenticate_user!
end
def authenticate_user!
render json: { error: "Unauthorized" }, status: :unauthorized unless current_user
end
def current_user
@current_user ||= user_from_token
end
private
def user_from_token
token = extract_token_from_header
return nil unless token
decoded = JsonWebToken.decode(token)
return nil unless decoded
User.find_by(id: decoded[:user_id])
end
def extract_token_from_header
header = request.headers["Authorization"]
header&.split(" ")&.last
end
end
Any controller including this concern automatically requires a valid token before any action runs, and gains access to current_user. Public controllers, like registration and login, simply do not include it.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
end
# app/controllers/api/v1/users_controller.rb
module Api
module V1
class UsersController < ApplicationController
include Authenticatable
def show
render json: { id: current_user.id, email: current_user.email }
end
end
end
end
This keeps authentication logic completely separated from business logic. The UsersController does not know or care how tokens are decoded; it just asks for current_user and trusts the concern to provide it, or halt the request with a 401 if it cannot.
With the pieces in place, you now have a clear split between public and protected endpoints.
Public endpoints, which do not include the Authenticatable concern:
# app/controllers/api/v1/sessions_controller.rb
module Api
module V1
class SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
token = JsonWebToken.encode(user_id: user.id)
render json: { token: token, user: { id: user.id, email: user.email } }, status: :ok
else
render json: { error: "Invalid email or password" }, status: :unauthorized
end
end
end
end
end
Protected endpoints, which include Authenticatable:
# app/controllers/api/v1/users_controller.rb
module Api
module V1
class UsersController < ApplicationController
include Authenticatable
def show
render json: { id: current_user.id, email: current_user.email }
end
end
end
end
Here is the full request and response flow for logging in and then fetching a protected profile:
// POST /api/v1/login
// Request
{
"email": "[email protected]",
"password": "supersecret123"
}
// Response (200 OK)
{
"token": "eyJhbGciOiJIUzI1NiJ9...",
"user": {
"id": 7,
"email": "[email protected]"
}
}
// GET /api/v1/profile
// Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
// Response (200 OK)
{
"id": 7,
"email": "[email protected]"
}
Attempting the same request without a token, or with an expired one, returns a 401 instead:
// Response (401 Unauthorized)
{
"error": "Unauthorized"
}
Authentication alone does not tell you whether a user should be allowed to do something, only who they are. Authorization decides what they can do.
Imagine your blog API has an articles resource, and only the author, or an admin, should be able to update or delete it. Add a role column to users and a simple check in the controller:
rails generate migration AddRoleToUsers role:string
class AddRoleToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :role, :string, null: false, default: "member"
end
end
# app/controllers/api/v1/articles_controller.rb
module Api
module V1
class ArticlesController < ApplicationController
include Authenticatable
before_action :set_article, only: [:update, :destroy]
def update
authorize_article!
if @article.update(article_params)
render json: @article
else
render json: { errors: @article.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
authorize_article!
@article.destroy
head :no_content
end
private
def set_article
@article = Article.find(params[:id])
end
def authorize_article!
return if current_user.role == "admin" || @article.user_id == current_user.id
render json: { error: "Forbidden" }, status: :forbidden
end
def article_params
params.require(:article).permit(:title, :body)
end
end
end
end
Notice the distinction in status codes. If current_user is missing entirely, Authenticatable returns 401, since the request has no valid identity attached at all. If current_user is present but not allowed to edit this article, authorize_article! returns 403, since the server knows exactly who is asking and has decided they are not permitted. Authentication answers "who are you." Authorization answers "are you allowed to do this." Keeping these as separate checks, with separate status codes, makes your API's behavior easier to reason about and debug.
It is tempting to cram useful information into a JWT payload so you do not have to look it up again later. Resist that for anything sensitive. Because payloads are only encoded, not encrypted, anyone who intercepts a token, or simply pastes it into a decoder, can read every claim inside it.
Standard claims worth knowing:
sub: the subject of the token, typically the user's identifierexp: expiration time, as a Unix timestampiat: issued-at time, as a Unix timestampBeyond these, keep your payload minimal. A user's ID is fine, a role might be acceptable for lightweight authorization checks, but a password, a recovery-flow email, or anything that should not be casually readable does not belong there:
{
"user_id": 7,
"exp": 1710086400,
"iat": 1710000000
}
If your frontend needs more user information, fetch it through an authenticated API call instead of encoding it into the token. This keeps tokens small, which helps performance too, since every request transmits the full token.
Tokens should always expire. A JWT that never expires is effectively a permanent credential that, once leaked, grants indefinite access to whoever holds it.
Short-lived access tokens limit the damage of a leak. If a token is valid for fifteen minutes to an hour, an attacker who intercepts it has only a small window before it becomes worthless. The tradeoff is more frequent reauthentication, which is where refresh tokens come in, covered next.
In the JsonWebToken.encode method built earlier, expiration defaults to 24 hours:
def self.encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
payload[:iat] = Time.current.to_i
JWT.encode(payload, secret_key, ALGORITHM)
end
For a simple API without refresh tokens, a shorter expiration, such as one or two hours, paired with a straightforward re-login flow, is often a better balance than a 24 hour token. There is no universally correct expiration time; a low-risk internal tool might tolerate a longer-lived token, while a financial API should lean much shorter.
Handling an expired token client-side is simple: the server returns a 401, and the client redirects to login or triggers a refresh if one is implemented.
A refresh token is a longer-lived credential used to obtain a new access token without requiring the user to log in again. The server issues a short-lived access token and a longer-lived refresh token at login. The client uses the access token for regular requests, and when it expires, sends the refresh token to a dedicated endpoint for a new one.
Refresh tokens need secure storage, ideally somewhere not accessible to JavaScript, to limit exposure to cross-site scripting. Many teams rotate refresh tokens on every use to limit the damage if one is stolen, and give them their own revocation strategy, usually a database table you can check against, since they need to be genuinely revocable in a way stateless access tokens are not.
Refresh tokens are useful for a smooth experience without forcing frequent re-logins, but they are not mandatory for every Rails API. A simple internal tool or early-stage product might be well served by one moderately short-lived JWT and a plain re-login flow. Add refresh token complexity when your requirements actually call for it, not by default.
Stateless JWT authentication has a real limitation: once issued, a token remains valid until it expires, since the server does not track individual tokens anywhere. There is no built-in equivalent of destroying a session record. A few common strategies address this, each with tradeoffs:
Short token lifetimes. The simplest approach: quick expiration keeps the window for a "logged out" token small. No instant revocation, but limited exposure.
Token denylist. A database table or store like Redis holding revoked tokens, checked on every request. Adds a lookup, reintroducing some statefulness, but gives genuine, immediate revocation.
Token versioning. A token_version column on users, included in every payload. Incrementing it after a password change or suspected compromise revokes all of that user's tokens at once, lighter weight than a full denylist.
Refresh token revocation. Storing refresh tokens server-side gives a natural revocation point: revoke the refresh token, and no new access tokens can be issued without re-authenticating.
Server-side session tracking when necessary. If you need instant, reliable revocation for every token, a hybrid approach or traditional sessions may serve you better than fighting JWT's stateless design.
Pick the strategy that matches your actual security requirements. Not every application needs instant revocation. A short-lived token with a straightforward re-login flow is a reasonable choice for a lot of real-world APIs.
A working JWT implementation is not automatically a secure one. Keep these practices in mind:
Always use HTTPS in production; JWTs sent over plain HTTP can be intercepted, defeating the purpose of signing them at all. Use a strong, randomly generated secret key stored via Rails credentials or environment variables, never hardcoded, and rotate it if you suspect exposure. Use a secure, well-understood signing algorithm such as HS256, and explicitly specify it when decoding rather than trusting whatever the token's header claims. Always validate expiration, and any other claims your application depends on, such as audience or issuer.
Keep access tokens short-lived, and avoid sensitive information in payloads, since anyone with the token can read them. If you use refresh tokens, protect and store them carefully, and give yourself a way to revoke them. Be thoughtful about where you store tokens on the client: local storage is common but exposes tokens to any JavaScript on the page, including scripts injected through a cross-site scripting vulnerability. Understand these tradeoffs rather than defaulting to convenience.
Implement authorization as a separate, explicit layer, never an afterthought bolted onto authentication. Rate-limit your login endpoint to slow brute-force attempts, and log security events like failed logins without ever logging full tokens, since logs can become a leak source themselves.
Even experienced developers make mistakes implementing JWT. Watch for these specifically.
Weak secrets. A short, guessable, or reused secret key undermines every token you issue. Generate a long, random secret specifically for signing.
Accepting unexpected algorithms. Misconfigured libraries can accept whatever algorithm a token claims, including none, which disables verification entirely. Always explicitly specify the expected algorithm when decoding.
Never expiring tokens. A token without an exp claim, or with an unreasonably distant one, is a long-lived credential waiting to be leaked.
Trusting payload data without verification. Never use claims from a payload without first verifying the signature; decoding without verification is trivial, and an attacker can put anything into an unverified payload.
Storing sensitive data in tokens. Worth repeating: payloads are readable, not secret.
Missing authorization checks. A valid token only proves identity. Skipping authorization afterward is one of the most common real-world API security gaps.
Poor refresh-token handling. Storing refresh tokens insecurely, or failing to rotate or revoke them, turns a convenience into a liability.
Logging access tokens. Tokens that end up in logs or error trackers leak just as easily as tokens sent insecurely.
Forgetting HTTPS. The single most common oversight, and the easiest to prevent.
Consistent, predictable JSON error responses make your API easier for clients to work with. Handle these cases explicitly:
// Missing or invalid token (401)
{
"error": "Unauthorized"
}
// Invalid login credentials (401)
{
"error": "Invalid email or password"
}
// Forbidden action, valid token but insufficient permissions (403)
{
"error": "Forbidden"
}
// Validation failure on signup (422)
{
"errors": ["Email has already been taken", "Password is too short (minimum is 8 characters)"]
}
Use 200 OK for successful reads and updates, 201 Created for successful resource creation such as signup, 401 Unauthorized when the request has no valid authenticated identity at all, 403 Forbidden when the identity is known but not permitted, and 422 Unprocessable Entity for validation failures.
The distinction between 401 and 403 matters more than it might seem. A 401 says "you need to authenticate, or your authentication failed." A 403 says "we know who you are, and the answer is still no." Mixing these up leads to confusing client-side error handling and harder debugging for anyone integrating with your API.
Request specs are the right tool for testing authentication end to end, since they exercise the full stack from routing through controllers and responses. Here is a solid starting set:
# spec/requests/api/v1/sessions_spec.rb
require "rails_helper"
RSpec.describe "Api::V1::Sessions", type: :request do
let!(:user) { User.create!(email: "[email protected]", password: "password123") }
describe "POST /api/v1/login" do
it "returns a token for valid credentials" do
post "/api/v1/login", params: { email: "[email protected]", password: "password123" }
expect(response).to have_http_status(:ok)
expect(json_response["token"]).to be_present
end
it "rejects invalid credentials" do
post "/api/v1/login", params: { email: "[email protected]", password: "wrongpassword" }
expect(response).to have_http_status(:unauthorized)
end
end
end
# spec/requests/api/v1/users_spec.rb
require "rails_helper"
RSpec.describe "Api::V1::Users", type: :request do
let!(:user) { User.create!(email: "[email protected]", password: "password123") }
describe "GET /api/v1/profile" do
it "returns the user with a valid token" do
token = JsonWebToken.encode(user_id: user.id)
get "/api/v1/profile", headers: { "Authorization" => "Bearer #{token}" }
expect(response).to have_http_status(:ok)
expect(json_response["email"]).to eq(user.email)
end
it "rejects requests with no token" do
get "/api/v1/profile"
expect(response).to have_http_status(:unauthorized)
end
it "rejects requests with an invalid token" do
get "/api/v1/profile", headers: { "Authorization" => "Bearer invalid.token.here" }
expect(response).to have_http_status(:unauthorized)
end
it "rejects requests with an expired token" do
token = JsonWebToken.encode({ user_id: user.id }, 1.hour.ago)
get "/api/v1/profile", headers: { "Authorization" => "Bearer #{token}" }
expect(response).to have_http_status(:unauthorized)
end
end
end
A small helper makes these specs easier to write across your suite:
# spec/support/json_helper.rb
module JsonHelper
def json_response
JSON.parse(response.body)
end
end
RSpec.configure do |config|
config.include JsonHelper, type: :request
end
These specs cover the core cases: successful login, failed login, a valid protected request, a missing token, an invalid token, and an expired token. Add authorization specs on top, testing that a user cannot update or delete another user's resources, following the same pattern.
Manual testing with curl is a fast way to sanity-check your endpoints while developing, before writing formal specs.
Register a new user:
curl -X POST http://localhost:3000/api/v1/signup \
-H "Content-Type: application/json" \
-d '{"user": {"email": "[email protected]", "password": "supersecret123", "password_confirmation": "supersecret123"}}'
Log in and capture the token:
curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "supersecret123"}'
Call a protected endpoint using the token from the login response:
curl http://localhost:3000/api/v1/profile \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo3fQ.xxxx"
Confirm that an invalid token is correctly rejected:
curl http://localhost:3000/api/v1/profile \
-H "Authorization: Bearer not.a.real.token"
You should see a 401 response with a clear error message in each failure case, confirming your authentication layer behaves as expected before you build out more of your API.
From the client's perspective, working with a JWT-authenticated Rails API follows a consistent pattern regardless of platform. The client sends a login request and receives an access token, which it stores somewhere, typically in memory for the app session, or a more persistent but still reasonably secure store if it needs to stay logged in across restarts. Every subsequent request attaches the token in the Authorization header. When the server responds with a 401, the client either redirects to login again or, if refresh tokens are implemented, silently requests a new access token.
This holds whether the client is a React app using fetch or axios, a Next.js app handling auth server-side, a Flutter app using secure device storage, or anything else making HTTP requests. Storage and refresh details vary by platform, but the contract stays the same: send credentials, receive a token, attach it to future requests, handle expiration gracefully.
JWT authentication is stateless, removing the need for a server-side session lookup on every request. That said, JWT does not automatically make your API faster. Verifying a signature has a small but real cryptographic cost per request, negligible but not zero. Identifying the current user still typically requires a database lookup, since you want to confirm the user still exists and is in good standing rather than trusting the payload blindly. Keeping payloads small matters too, since the token travels with every request, and a bloated payload adds unnecessary bytes at scale.
If your application relies heavily on current_user data beyond an ID, consider caching frequently accessed attributes rather than bloating the token or hitting the database repeatedly within a single request.
A few patterns show up repeatedly in Rails JWT implementations that a bit of upfront planning avoids: putting authentication logic directly inside every controller instead of a shared concern, which duplicates code and means a bug fix has to be applied in several spots; using long-lived tokens for convenience, trading security for a marginal UX improvement; storing secrets in source code, still one of the most common and damaging mistakes in any authentication system; and returning more user information than necessary in responses, such as password digests or internal flags.
Also watch for confusing authentication with authorization, treating "is this token valid" as equivalent to "should this action be allowed," which rarely holds once you have more than one type of user; skipping request tests for authentication flows, letting bugs slip into exactly the code you can least afford bugs in; and overengineering refresh-token systems for applications that do not actually need them.
Pulling everything together, here is the complete flow for a small Rails blog API with JWT authentication protecting article management.
A user registers:
// POST /api/v1/signup
{
"user": {
"email": "[email protected]",
"password": "strongpassword1",
"password_confirmation": "strongpassword1"
}
}
// Response (201 Created)
{
"token": "eyJhbGciOiJIUzI1NiJ9...",
"user": { "id": 12, "email": "[email protected]" }
}
The user creates an article using the token from signup:
// POST /api/v1/articles
// Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
{
"article": {
"title": "Understanding Rails Concerns",
"body": "Concerns let you extract shared behavior..."
}
}
// Response (201 Created)
{
"id": 3,
"title": "Understanding Rails Concerns",
"body": "Concerns let you extract shared behavior...",
"user_id": 12
}
A different user attempts to update that article and is correctly blocked:
// PATCH /api/v1/articles/3
// Authorization: Bearer <a different user's token>
{
"article": { "title": "Hijacked Title" }
}
// Response (403 Forbidden)
{
"error": "Forbidden"
}
The ArticlesController built earlier, with the Authenticatable concern for authentication and authorize_article! for authorization, handles this entire flow correctly, and the request specs confirm it stays correct as the codebase evolves. This is the complete picture: registration issues a token, authentication verifies it on every subsequent request, and authorization decides what the authenticated user is actually allowed to do.
What is JWT authentication in Rails? An approach to verifying API clients using signed JSON Web Tokens instead of server-side sessions, sent with every request after login.
How do I implement JWT authentication in Rails? Add the jwt gem, build a service class to encode and decode tokens, add login and registration endpoints that issue tokens, and build a shared authentication concern that verifies tokens on protected endpoints.
Is JWT better than Rails sessions? Neither is universally better. JWT fits stateless APIs for mobile apps or separate frontends; sessions fit server-rendered apps well and offer simpler revocation.
Where should I store a JWT? It depends on your platform and threat model, from in-memory storage to secure device storage to HTTP-only cookies. Understand the tradeoffs rather than defaulting to convenience.
How long should a JWT last? No single correct answer. Short-lived tokens, fifteen minutes to a few hours, are generally safer; balance that against how disruptive frequent re-authentication is for users.
Can JWT tokens be revoked? Not natively, since JWT is stateless. Add revocation with short expiration, a denylist, or token versioning.
Should I use refresh tokens? Only if your app genuinely benefits from short-lived access tokens paired with an uninterrupted experience. Simpler apps often get by with one moderately short-lived token and a re-login flow.
Is JWT secure? It can be, with a strong secret, an explicit signing algorithm, short expiration, HTTPS, and proper authorization checks. It can also be insecure if any of those pieces are missing.
What is the difference between authentication and authorization? Authentication confirms who a client is. Authorization determines what they are allowed to do. A request can be fully authenticated and still denied by authorization.
Can I use JWT with a Rails API-only application? Yes, it is a common, well-suited approach for apps built with the --api flag.
JWT authentication gives Rails developers a stateless, flexible way to secure an API, working particularly well for mobile clients, single-page applications, and third-party integrations. Throughout this article, you built a complete system: a User model with securely hashed passwords, a reusable service for encoding and decoding tokens, a shared authentication concern, and a clear separation between authentication and authorization enforced through distinct HTTP status codes.
None of this replaces good judgment about the rest of your application's security. JWT is one tool among several, not a complete solution on its own. Pair it with HTTPS, properly stored secrets, sensible expiration, thoughtful authorization checks, rate limiting, careful validation, and a test suite that actually exercises your authentication flows.
Choose your authentication architecture based on the real needs of your API and its clients, not on what feels trendy. Sometimes that means JWT. Sometimes a traditional Rails session is genuinely the simpler, more secure choice. Understanding both, and why, is what lets you make that call with confidence.