• Home
  • About
  • Portfolio
  • Contact
CodeCurious
  • Home
  • About
  • Portfolio
  • Contact
Go Back

Rails API Authentication With JWT: Complete Guide

Learn how to secure a Rails API with JWT authentication, token verification, authorization, and security best practices.

Jean Emmanuel Cadet
By Jean Emmanuel Cadet • Ruby on Rails Developer

Last updated : Aug 10, 2026 • 27 min read

Rails API Authentication with JWT: Complete Guide

Last updated : Aug 10, 2026 • 27 min read

Share with friends

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.


What Is JWT Authentication?

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.


How JWT Authentication Works

Before writing any code, it helps to see the full authentication flow laid out step by step.

  1. The client sends login credentials, typically an email and password, to a login endpoint.
  2. Rails verifies the credentials against the stored user record.
  3. If the credentials are valid, Rails generates a signed JWT containing the user's identifier and an expiration time.
  4. The client stores the token, usually in memory or in a secure storage mechanism appropriate to the platform.
  5. On every subsequent request, the client sends the token in the Authorization header.
  6. Rails receives the request, extracts the token, and verifies its signature and expiration.
  7. If the token is valid, Rails identifies the authenticated user from the claims inside the token.
  8. Rails then checks whether that user is authorized to perform the requested action before doing anything else.

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.


JWT vs Session-Based Authentication

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:

  • Mobile applications, which have no browser cookie jar
  • Single-page applications that talk to an API on a different domain
  • Distributed systems where you want to avoid a shared session store
  • Public APIs consumed by third-party clients

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.


Setting Up a Rails API

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.


Choosing a JWT Library

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.


Creating User Authentication

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.


Generating JWT Tokens

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 }

Verifying JWT Tokens

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.


Building Authentication Concern or Service

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.


Protecting API Endpoints

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"
}

Authorization After Authentication

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.


JWT Payload Design

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 identifier
  • exp: expiration time, as a Unix timestamp
  • iat: issued-at time, as a Unix timestamp

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


Token Expiration

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.


Refresh Tokens

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.


Logout and Token Revocation

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.


JWT Security Best Practices

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.


Common JWT Vulnerabilities and Mistakes

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.


Error Handling

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.


Testing JWT Authentication

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.


Testing with curl

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.


JWT Authentication with a Frontend or Mobile App

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.


Performance Considerations

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.


Common Mistakes When Building Rails JWT Authentication

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.


Real-World Rails API Example

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.


Frequently Asked Questions

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.


Conclusion

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.

💌 Don’t miss out! Join my newsletter for web development tips, tutorials, and insights delivered straight to your inbox.

Thanks for reading & Happy coding! 🚀

Follow me on:

Code. Learn. Grow.

A friendly newsletter sharing dev tips, lessons, and wins from my journey.

    Enter valid email address

    Services Tailored to Your Needs


    coding

    Web & Mobile Development

    Custom websites and mobile apps built to be fast, modern, and user-friendly. From sleek landing pages to full-scale applications, I deliver solutions that engage your audience and grow your business.

    API development

    Seamlessly connect your systems with secure, scalable APIs. I design and integrate APIs that improve efficiency, reliability, and flexibility for your business processes.

    Database design and management

    Reliable database solutions tailored to your needs. I design, optimize, and maintain databases that ensure performance, security, and scalability for your applications.

    You might also like…

    How to Build a JSON API using Ruby on Rails
    Ruby On Rails

    How To Build A JSON API Using Ruby On Rails

    By Jean Emmanuel Cadet
    Published on: Aug 07, 2026
    How Rails Routing Works: A Complete Guide (Rails 8)
    Ruby On Rails

    How Rails Routing Works: A Complete Guide (Rails 8)

    By Jean Emmanuel Cadet
    Published on: Aug 05, 2026
    Active Job in Rails: A Beginner's Guide
    Ruby On Rails

    Active Job In Rails: A Beginner's Guide

    By Jean Emmanuel Cadet
    Published on: Aug 03, 2026
    CodeCurious

    Designed for those who view software as architecture and code as literature.

    Legal

    Terms & Conditions Privacy Policy Disclaimer

    CodeCurious © 2025 - 2026. All rights reserved. | Made with ♥ by @jecode93