JwtAuthEngine
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
- Requirements
- Installation
- Configuration
- Mounting the Engine
- API Endpoints
- Authentication
- Customization
- Response Shapes
- Testing
- Contributing
- License
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::MissingSecretKeyat runtime if this value isnil.
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
- On login/signup, the engine issues a token pair (access + refresh)
- The client sends the access token as
Authorization: Bearer <token>on protected requests - The engine decodes the token, extracts the model ID, and loads the authenticated record
- When the access token expires, use the refresh token at
/auth/refresh_tokento 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
- Fork the repository
- Create your feature branch (
git checkout -b feature/my-feature) - Commit your changes (
git commit -am 'Add my feature') - Push to the branch (
git push origin feature/my-feature) - 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.