From 401s to JWT Bliss: Debugging Devise‑JWT on a Rails 8 API‑only App
Jul 24, 2025“Postman logs me in, cURL gives ‘You need to sign in’, and somehow I can hit endpoints with no token at all—what on earth is going on?”
I spent two days chasing those puzzles. Below is everything I learned, condensed into one checklist you can drop into your own Rails 8 API‑only project.
Table of contents
- Gemfile & migration
- User model with
JTIMatcher
- JWT‑aware
devise.rb
- Failure app for uniform 401 JSON
- Routes in one line
- ApplicationController guard
- Custom Sessions & Registrations controllers
- cURL cookbook
- Common pitfalls & their fixes
- RSpec tests
1 Gemfile & migration
# Gemfile
gem 'devise'
gem 'devise-jwt'
bundle install
db/migrate/xxxx_add_jti_to_users.rb
class AddJtiToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :jti, :string, null: false, default: -> { 'gen_random_uuid()' }
add_index :users, :jti, unique: true
end
end
rails db:migrate
2 User model + JTI revocation
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:jwt_authenticatable, jwt_revocation_strategy: self
include Devise::JWT::RevocationStrategies::JTIMatcher
def self.jwt_revoked?(payload, user)
user.jti != payload['jti']
end
def self.revoke_jwt(_payload, user)
Rails.logger.debug 'revoke_jwt TRIGGERED'
user.update_column(:jti, SecureRandom.uuid)
end
end
3 Devise initializer
Devise.setup do |config|
config.skip_session_storage = [:http_auth, :params_auth, :token_auth, :cookie]
config.navigational_formats = []
config.jwt do |jwt|
jwt.secret = Rails.application.credentials.devise_jwt_secret_key
jwt.dispatch_requests = [
['POST', %r{^/api/v1/sessions/sign_in$}],
['POST', %r{^/api/v1/users$}]
]
jwt.revocation_requests = [
['DELETE', %r{^/api/v1/sessions/sign_out$}]
]
jwt.expiration_time = 1.day.to_i
end
config.warden do |manager|
manager.failure_app = ApiFailureApp
end
end
4 JSON failure app
class ApiFailureApp < Devise::FailureApp
def respond
self.status = 401
self.content_type = 'application/json'
self.response_body = {
error: {
code: 'UNAUTHORIZED',
message: 'Invalid or missing JWT token'
}
}.to_json
end
end
5 Single devise_for
line (keeps mapping :user
)
Rails.application.routes.draw do
devise_for :users,
path: 'api/v1',
path_names: {
sign_in: 'sessions/sign_in',
sign_out: 'sessions/sign_out',
registration: 'users'
},
controllers: {
sessions: 'api/v1/sessions',
registrations: 'api/v1/registrations'
}
namespace :api do
namespace :v1 do
resources :users
# … other resources …
end
end
end
Run rails r 'puts Devise.mappings.keys'
→ [:user]
.
6 Application‑wide guard
class ApplicationController < ActionController::API
include Devise::Controllers::Helpers
before_action :authenticate_user!, unless: :devise_controller?
rescue_from JWT::DecodeError, with: :render_unauthorized
private
def render_unauthorized(_e = nil)
render json: { error: { code: 'UNAUTHORIZED',
message: 'Invalid or missing JWT token' } },
status: :unauthorized
end
end
7 Controllers
Sessions
module Api
module V1
class SessionsController < Devise::SessionsController
respond_to :json
wrap_parameters false
skip_before_action :verify_signed_out_user, only: :destroy
def respond_with(resource, _opts = {})
render json: {
message: 'Logged in successfully.',
user: { id: resource.id, email: resource.email }
}, status: :ok
end
def respond_to_on_destroy
render json: { status: 200, message: 'Logged out successfully.' }
end
end
end
end
Registrations (no auto‑login)
module Api
module V1
class RegistrationsController < Devise::RegistrationsController
respond_to :json
wrap_parameters false
def sign_up(_scope, _resource); end # skip auto-login
private
def respond_with(resource, _opts = {})
if resource.persisted?
render json: {
message: 'Signed up successfully.',
user: { email: resource.email }
}, status: :ok
else
render json: { errors: resource.errors.full_messages },
status: :unprocessable_entity
end
end
def sign_up_params
params.require(:user)
.permit(:email, :password, :password_confirmation)
end
end
end
end
8 cURL cookbook
# Sign‑up
curl -X POST http://localhost:3000/api/v1/users -H "Content-Type: application/json" -d '{"user":{"email":"alice@example.com",
"password":"password123",
"password_confirmation":"password123"}}'
# Sign‑in
curl -X POST http://localhost:3000/api/v1/sessions/sign_in -H "Content-Type: application/json" -d '{"user":{"email":"alice@example.com","password":"password123"}}' -i
# (copy token from Authorization header)
# Protected endpoint
curl http://localhost:3000/api/v1/users -H "Authorization: Bearer <TOKEN>"
# Sign‑out
curl -X DELETE http://localhost:3000/api/v1/sessions/sign_out -H "Authorization: Bearer <TOKEN>"
# Token now invalid
curl http://localhost:3000/api/v1/users -H "Authorization: Bearer <TOKEN>"
9 Pitfalls & fixes
-
Mapping key mismatch → check
Devise.mappings.keys
.[:user]
means helpers areauthenticate_user!
. Anything else means you must callauthenticate_<scope>_user!
. -
Postman works, cURL fails → Postman was sending a cookie session. Fix:
skip_session_storage = [:cookie]
. -
Double‑slash paths block JWT dispatch → use
path: 'api/v1'
(no leading/
) and update regexes. -
“Not enough or too many segments” → handled by failure app +
rescue_from JWT::DecodeError
.
And that’s all: Rails 8 + Devise + JWT, with uniform JSON errors, cookie‑free, and cURL‑/Postman‑compatible.
10 RSpec test suite
1 Factory
# spec/factories/users.rb
FactoryBot.define do
factory :user do
email { Faker::Internet.unique.email }
password { 'password123' }
end
end
2 Spec helper for JWT header
# spec/support/jwt_helpers.rb
module JwtHelpers
def auth_header_for(user)
post '/api/v1/sessions/sign_in',
params: { user: { email: user.email, password: 'password123' } }.to_json,
headers: { 'Content-Type' => 'application/json' }
token = response.headers['Authorization'].split.last
{ 'Authorization' => "Bearer #{token}" }
end
end
RSpec.configure { |c| c.include JwtHelpers, type: :request }
3.1 Request specs
# spec/requests/auth_flow_spec.rb
require 'rails_helper'
RSpec.describe 'Auth flow', type: :request do
describe 'sign‑up → sign‑in → protected → sign‑out' do
let(:user_attrs) do
{ email: 'alice@example.com',
password: 'password123',
password_confirmation: 'password123' }
end
it 'issues and revokes JWT' do
# 1) sign‑up (no auto‑login)
post '/api/v1/users',
params: { user: user_attrs }.to_json,
headers: { 'Content-Type' => 'application/json' }
expect(response).to have_http_status(:ok)
# 2) sign‑in (get token in header)
post '/api/v1/sessions/sign_in',
params: { user: user_attrs.slice(:email, :password) }.to_json,
headers: { 'Content-Type' => 'application/json' }
expect(response).to have_http_status(:ok)
token = response.headers['Authorization'].split.last
auth = { 'Authorization' => "Bearer #{token}" }
# 3) hit protected endpoint
get '/api/v1/users', headers: auth
expect(response).to have_http_status(:ok)
# 4) sign‑out
delete '/api/v1/sessions/sign_out', headers: auth
expect(response).to have_http_status(:ok)
# 5) token should now be invalid
get '/api/v1/users', headers: auth
expect(response).to have_http_status(:unauthorized)
end
end
end
3.2 Bad token spec
require 'rails_helper'
RSpec.describe 'JWT failure cases', type: :request do
let(:headers) { { 'Authorization' => 'Bearer bad.token' } }
it 'rejects malformed token' do
get '/api/v1/users', headers: headers
expect(response).to have_http_status(:unauthorized)
body = JSON.parse(response.body)
expect(body.dig('error', 'code')).to eq('UNAUTHORIZED')
end
end
4 Model spec
# spec/models/user_spec.rb
require 'rails_helper'
RSpec.describe User, type: :model do
describe '.revoke_jwt / .jwt_revoked?' do
let(:user) { create(:user) }
let(:old_jti) { user.jti }
it 'marks existing token invalid by changing jti' do
payload = { 'jti' => old_jti }
described_class.revoke_jwt(payload, user)
expect(user.reload.jti).not_to eq(old_jti)
expect(described_class.jwt_revoked?(payload, user)).to be true
end
end
end