JwtAuthEngine

Ruby Rails CI License: MIT

A mountable Rails engine that provides stateless JWT authentication endpoints for API-only Rails applications. It authenticates against your host application's existing model — no separate user table required.


Table of Contents


Features

  • 🔐 Stateless JWT access & refresh token pair
  • 🧩 Plugs into any ActiveRecord model (User, Account, Admin::User, etc.)
  • ⚙️ Fully configurable identifier field (email, username, etc.)
  • ⚙️ Fully configurable password field (password, pin, etc.)
  • 🏗️ Install generator that scaffolds migration, initializer, and model concern
  • 🔒 Case-insensitive identifier lookup for string/text columns
  • 🛡️ Global exception handling scoped only to engine endpoints
  • 📦 Zero host-app pollution — uses isolate_namespace

Requirements

Dependency Version
Ruby >= 3.0.0
Rails >= 6.1.0
bcrypt >= 3.1.18
jwt >= 2.9.0

Installation

1. Add the gem

Add this line to your application's Gemfile:

gem 'jwt_auth_engine'

2. Install dependencies

bundle install

3. Run the install generator

bundle exec rails generate jwt_auth_engine:install

The generator accepts options to customize the auth model, identifier field, and password field. See Generator Options.

This generates:

File Purpose
config/initializers/jwt_auth_engine.rb Engine configuration
db/migrate/<timestamp>_add_jwt_auth_engine_columns_to_<table>.rb Adds identifier + password digest columns
app/models/concerns/jwt_auth_engine/auth_model_concern.rb Model behavior (has_secure_password, validations)

The generator also attempts to inject include JwtAuthEngine::AuthModelConcern into your model file automatically.

4. Run migration

bundle exec rails db:migrate

5. Set your JWT secret key

Open the generated initializer and set config.jwt_secret_key to a secure random string.

⚠️ The engine will raise JwtAuthEngine::MissingSecretKey at runtime if this value is nil.

Generate a secret:

bundle exec rails secret

You can source it any way that suits your setup:

# From Rails credentials
config.jwt_secret_key = Rails.application.credentials.dig(:jwt_auth_engine, :secret_key)

# From an environment variable
config.jwt_secret_key = ENV['JWT_SECRET_KEY']

# From Rails secrets
config.jwt_secret_key = Rails.application.secrets.jwt_secret_key

Configuration

The install generator creates an initializer with sensible defaults:

# config/initializers/jwt_auth_engine.rb
JwtAuthEngine.configure do |config|
  config.auth_model           = 'User'      # Your ActiveRecord model class name
  config.identifier_field     = :email      # Field used for login/signup lookup
  config.password_field       = :password   # Base attribute for has_secure_password
  config.jwt_secret_key       = nil         # REQUIRED: Set to a secure random string
  config.access_token_expiry  = 8.hours     # Access token expiry duration
  config.refresh_token_expiry = 7.days      # Refresh token expiry duration
end

Generator Options

All options are optional — defaults are applied if not provided.
E.g., to use an Account model with username as the identifier and pin as the password field:

bundle exec rails generate jwt_auth_engine:install \
  --auth-model=Account \
  --identifier-field=username \
  --password-field=pin
Option Default Description
--auth-model User ActiveRecord model class name
--identifier-field email Login/signup lookup field
--password-field password Password attribute (digest column = <field>_digest, required by bcrypt)

Mounting the Engine

Add to your host app's routes:

# config/routes.rb
Rails.application.routes.draw do
  # other routes...
  mount JwtAuthEngine::Engine, at: '/auth'
end

All engine endpoints will be available under /auth/ path.


API Endpoints

You may see JWT Auth Engine API Collection for detailed information on endpoints, request/response shapes, and example payloads.

A summary of endpoints is provided below.

Public Endpoints

Method Path Description
GET /auth/ping Health check — returns pong!
POST /auth/signup Register a new account and returns new access and refresh tokens
POST /auth/login Authenticate and returns new access and refresh tokens

Protected Endpoints (require access token)

Method Path Description
GET /auth/authenticated_ping Verify token is valid
DELETE /auth/logout Stateless logout (client discards tokens)
POST /auth/change_password Change password for authenticated account
GET /auth/me Retrieve authenticated account profile

Token Endpoints (require refresh token)

Method Path Description
POST /auth/refresh_token Exchange refresh token for a new token pair

Send the refresh token as Authorization: Bearer <refresh_token> — not the access token.


Authentication

How It Works

  1. On login/signup, the engine issues a token pair (access + refresh)
  2. The client sends the access token as Authorization: Bearer <token> on protected requests
  3. The engine decodes the token, extracts the model ID, and loads the authenticated record
  4. When the access token expires, use the refresh token at /auth/refresh_token to get a new pair

JWT Payload Structure

{
  "user_id": 1,
  "email": "user@example.com",
  "exp": 1717200000,
  "token_type": "access"
}

Payload keys are dynamic: user_id becomes account_id if auth_model = 'Account' and email becomes username if identifier_field = :username.

Token Expiry Defaults

Token Default Expiry
Access token 8 hours
Refresh token 7 days

Customization

Auth Model Concern

The generator creates app/models/concerns/jwt_auth_engine/auth_model_concern.rb in your host app. This file is yours to customize:

  • Modify validations
  • Change normalization behavior
  • Add additional callbacks

If the generator can't find your model file, include the concern manually:

class User < ApplicationRecord
  include JwtAuthEngine::AuthModelConcern
end

The generated concern includes a before_validation callback that strips and downcases string identifiers. You can customize or remove this in the generated concern file to match your identifier semantics.


Response Shapes

All engine responses follow a consistent JSON shape, scoped only to engine endpoints (no impact on host app error handling).

HTTP Status Shape
200 { "success": true, "data": { ... } }
201 { "success": true, "data": { ... } }
401 { "success": false, "errors": "..." }
422 { "success": false, "errors": [...] }
500 { "success": false, "errors": { "message": "...", "backtrace": [...] } }

Custom Error Classes

Error When Raised
JwtAuthEngine::MissingSecretKey jwt_secret_key is not configured
JwtAuthEngine::AuthModelNotFound Configured model class cannot be resolved
JwtAuthEngine::InvalidAuthModel Model does not inherit from ActiveRecord::Base
JwtAuthEngine::TokenExpired JWT has passed its expiry time
JwtAuthEngine::InvalidToken JWT cannot be decoded
JwtAuthEngine::InvalidTokenType Token type does not match expected (access vs refresh)

Testing

The gem uses RSpec for testing and SimpleCov for code coverage enforcement. Tests run against a minimal dummy Rails app (spec/dummy/) with an in-memory SQLite database.

Coverage Thresholds

Metric Minimum Required
Line coverage 95%
Branch coverage 90%

SimpleCov will fail the test suite if coverage drops below these thresholds.

Running Tests

# Run the full test suite
bundle exec rspec

# Run with verbose output
bundle exec rspec --format documentation

# Run a specific spec file
bundle exec rspec spec/services/jwt_auth_engine/login_service_spec.rb

# Run a specific example by line number
bundle exec rspec spec/requests/jwt_auth_engine/sessions_spec.rb:12

After each run, a coverage report is generated at coverage/index.html.

Test Structure

spec/
├── spec_helper.rb              # SimpleCov setup + coverage thresholds
├── rails_helper.rb             # Boots dummy app, DatabaseCleaner, JwtAuthEngine config
├── dummy/                      # Minimal Rails API app for integration testing
├── lib/                        # Unit tests for core library modules
├── services/                   # Unit tests for service objects
└── requests/                   # Integration tests for all API endpoints

Multi-Version Testing with Appraisal

The gem supports Rails 6.1 through 8.1. We use Appraisal with a boundary strategy: minimum and latest patch for each supported Rails minor line.

Setup

# Install base dependencies
bundle install

# Generate appraisal gemfiles (run after editing Appraisals file)
bundle exec appraisal generate

Listing Available Appraisals

bundle exec appraisal list

Running Tests Against a Specific Boundary

bundle exec appraisal rails-6.1-min rspec
bundle exec appraisal rails-7.2-max rspec
bundle exec appraisal rails-8.1-max rspec

Running Tests Against ALL Rails Versions

bundle exec appraisal rspec

RVM Isolated Gemsets (One Per Appraisal)

If you use RVM, this project includes a helper that keeps dependencies isolated with one gemset per appraisal gemfile.

# Show all appraisal targets
bin/appraisal_rvm list

# Create Ruby + gemset + install dependencies for one appraisal
bin/appraisal_rvm bootstrap rails-7.2-max

# Run specs for one appraisal in its dedicated gemset
bin/appraisal_rvm run rails-7.2-max

# Pre-install all appraisals (can take time)
bin/appraisal_rvm bootstrap-all

Note: Rails and Ruby compatibility is constrained by both Rails requirements and this gem's minimum Ruby requirement (>= 3.0.0).

Compatibility Matrix

Rails Line Tested Boundaries Effective Min Ruby
Rails 6.1.x 6.1.0 and 6.1.7.10 3.0.0
Rails 7.0.x 7.0.0 and 7.0.10 3.0.0
Rails 7.1.x 7.1.0 and 7.1.6 3.0.0
Rails 7.2.x 7.2.0 and 7.2.3.1 3.1.0
Rails 8.0.x 8.0.0 and 8.0.5 3.2.0
Rails 8.1.x 8.1.0 and 8.1.3 3.2.0

CI (GitHub Actions)

We run a two-tier matrix:

  • .github/workflows/ci.yml: required PR/push checks with boundary coverage.
  • .github/workflows/compatibility-full.yml: scheduled/manual broad matrix across Ruby 3.0-3.5 and all Rails boundary appraisals.

This is a standard professional setup: fast required feedback plus deeper periodic compatibility surveillance.


Contributing

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/my-feature)
  3. Commit your changes (git commit -am 'Add my feature')
  4. Push to the branch (git push origin feature/my-feature)
  5. Open a Pull Request

Please read CONTRIBUTING.md for details on the code of conduct and contribution process.


License

This gem is available as open source under the terms of the MIT License.