Main  |  Other posts

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

  1. Gemfile & migration
  2. User model with JTIMatcher
  3. JWT‑aware devise.rb
  4. Failure app for uniform 401 JSON
  5. Routes in one line
  6. ApplicationController guard
  7. Custom Sessions & Registrations controllers
  8. cURL cookbook
  9. Common pitfalls & their fixes
  10. 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 are authenticate_user!. Anything else means you must call authenticate_<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