A Ruby gem that handles JWT authentication for Rails APIs. It covers the usual stuff — signup, login, refresh tokens, password reset, lockout — plus some extras like magic links, RBAC, session tracking, hooks, and RS256.
- Signup and login with JWT
- Refresh tokens (rotated on every use)
- Token revocation
- Password reset
- Account lockout after failed attempts
authenticate_user!controller concern- Role-based access control
- Passwordless magic link login
- Per-device session tracking
- Hooks for auth events (signup, login, logout, etc.)
- RS256 support (asymmetric keys)
# Gemfile
gem 'api_authentication_gem'bundle installRun this migration once. You only need the tables for the features you actually use.
class CreateApiAuthTables < ActiveRecord::Migration[7.0]
def change
create_table :api_auth_refresh_tokens do |t|
t.integer :user_id, null: false
t.string :token, null: false
t.datetime :expires_at, null: false
t.datetime :revoked_at
t.timestamps
end
add_index :api_auth_refresh_tokens, :token, unique: true
add_index :api_auth_refresh_tokens, :user_id
create_table :api_auth_revoked_tokens do |t|
t.string :jti, null: false
t.datetime :expires_at, null: false
t.timestamps
end
add_index :api_auth_revoked_tokens, :jti, unique: true
create_table :api_auth_password_resets do |t|
t.string :identifier, null: false
t.string :token, null: false
t.datetime :expires_at, null: false
t.datetime :used_at
t.timestamps
end
add_index :api_auth_password_resets, :token, unique: true
add_index :api_auth_password_resets, :identifier
create_table :api_auth_lockouts do |t|
t.string :identifier, null: false
t.integer :failed_attempts, default: 0, null: false
t.datetime :locked_until
t.timestamps
end
add_index :api_auth_lockouts, :identifier, unique: true
# only needed if you use magic links
create_table :api_auth_magic_links do |t|
t.string :identifier, null: false
t.string :token, null: false
t.datetime :expires_at, null: false
t.datetime :used_at
t.timestamps
end
add_index :api_auth_magic_links, :token, unique: true
add_index :api_auth_magic_links, :identifier
# only needed if you use session tracking
create_table :api_auth_sessions do |t|
t.integer :user_id, null: false
t.string :jti, null: false
t.string :ip_address
t.string :user_agent
t.datetime :expires_at, null: false
t.datetime :revoked_at
t.timestamps
end
add_index :api_auth_sessions, :jti, unique: true
add_index :api_auth_sessions, :user_id
end
endrails db:migrate# config/initializers/api_auth.rb
ApiAuthenticationGem.configure do |config|
config.secret_key = ENV["API_AUTH_SECRET_KEY"]
config.user_class = "User"
config.token_expiry = 15 * 60 # 15 min
config.min_password_length = 8
config.refresh_tokens_enabled = true
config.refresh_token_expiry = 30 * 24 * 60 * 60 # 30 days
config.revocation_enabled = true
config.lockout_enabled = true
config.max_login_attempts = 5
config.lockout_duration = 15 * 60
# optional features
config.magic_link_enabled = false
config.session_tracking_enabled = false
# hooks — all optional
config.on_signup = ->(user) { puts "signed up: #{user.email}" }
config.on_login = ->(user) { puts "logged in: #{user.email}" }
config.on_login_failure = ->(email) { puts "failed login: #{email}" }
config.on_logout = ->(jti) { puts "logged out: #{jti}" }
config.on_password_reset = ->(user) { puts "password reset: #{user.email}" }
endrails generate model User email:string:uniq password_digest:string role:string
rails db:migrateclass User < ApplicationRecord
has_secure_password
validates :email, presence: true, uniqueness: true
endclass AuthController < ApplicationController
def signup
result = ApiAuthenticationGem::Auth.signup(email: params[:email], password: params[:password])
if result[:error]
render json: result, status: :unprocessable_entity
else
render json: result, status: :created
end
end
def login
result = ApiAuthenticationGem::Auth.login(
email: params[:email],
password: params[:password],
ip_address: request.remote_ip,
user_agent: request.user_agent
)
result[:error] ? render(json: result, status: :unauthorized) : render(json: result)
end
def refresh
result = ApiAuthenticationGem::Auth.refresh(refresh_token: params[:refresh_token])
result[:error] ? render(json: result, status: :unauthorized) : render(json: result)
end
def logout
token = request.headers["Authorization"]&.split(" ")&.last
render json: ApiAuthenticationGem::Auth.revoke(token: token)
end
def forgot_password
result = ApiAuthenticationGem::Auth.request_password_reset(email: params[:email])
# send result[:reset_token] by email — don't expose it in the response
render json: { message: result[:message] }
end
def reset_password
result = ApiAuthenticationGem::Auth.reset_password(
reset_token: params[:token],
new_password: params[:password]
)
result[:error] ? render(json: result, status: :unprocessable_entity) : render(json: result)
end
# magic links
def send_magic_link
result = ApiAuthenticationGem::Auth.send_magic_link(email: params[:email])
# send result[:magic_token] by email — don't expose it in the response
render json: { message: result[:message] }
end
def verify_magic_link
result = ApiAuthenticationGem::Auth.verify_magic_link(token: params[:token])
result[:error] ? render(json: result, status: :unauthorized) : render(json: result)
end
# session tracking
def sessions
authenticate_user!
render json: ApiAuthenticationGem::Auth.active_sessions(user_id: @current_user.id)
end
def revoke_session
authenticate_user!
render json: ApiAuthenticationGem::Auth.revoke_session(jti: params[:jti])
end
def revoke_all_sessions
authenticate_user!
render json: ApiAuthenticationGem::Auth.revoke_all_sessions(user_id: @current_user.id)
end
endRails.application.routes.draw do
post "signup", to: "auth#signup"
post "login", to: "auth#login"
post "refresh", to: "auth#refresh"
delete "logout", to: "auth#logout"
post "forgot_password", to: "auth#forgot_password"
post "reset_password", to: "auth#reset_password"
post "magic_link", to: "auth#send_magic_link"
post "magic_link/verify", to: "auth#verify_magic_link"
get "sessions", to: "auth#sessions"
delete "sessions/:jti", to: "auth#revoke_session"
delete "sessions", to: "auth#revoke_all_sessions"
endInclude the concern and call authenticate_user!:
class ApplicationController < ActionController::API
include ApiAuthenticationGem::Concerns::Authenticatable
end
class PostsController < ApplicationController
before_action :authenticate_user!
def index
render json: current_user.posts
end
endAdd a role column to users, then use authorize_role! inside any action:
class AdminController < ApplicationController
before_action :authenticate_user!
before_action -> { authorize_role!("admin") }
def dashboard
render json: { message: "welcome #{current_user.email}" }
end
endReturns HTTP 403 if the role doesn't match:
{ "error": "Forbidden", "required": ["admin"], "current": "viewer" }# step 1 — request a link
result = ApiAuthenticationGem::Auth.send_magic_link(email: "user@example.com")
# deliver result[:magic_token] by email
# step 2 — user clicks the link
result = ApiAuthenticationGem::Auth.verify_magic_link(token: params[:token])
# => { token: "...", user: ..., refresh_token: "..." }Tokens are single-use. Requesting a new one invalidates the previous one.
# list active sessions
ApiAuthenticationGem::Auth.active_sessions(user_id: user.id)
# revoke one session (sign out a specific device)
ApiAuthenticationGem::Auth.revoke_session(jti: "some-jti")
# sign out everywhere
ApiAuthenticationGem::Auth.revoke_all_sessions(user_id: user.id)If you want asymmetric keys instead of a shared secret:
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pemApiAuthenticationGem.configure do |config|
config.algorithm = "RS256"
config.private_key = OpenSSL::PKey::RSA.new(File.read("private.pem"))
config.public_key = OpenSSL::PKey::RSA.new(File.read("public.pem"))
config.user_class = "User"
end| Result | Keys |
|---|---|
| Success | message, user, token, refresh_token |
| Blank email | error: "Email is required" |
| Weak password | error: "Password must be at least N characters" |
| Duplicate email | error: "Email has already been taken" |
| Result | Keys |
|---|---|
| Success | token, user, refresh_token |
| Wrong credentials | error: "Invalid email or password" |
| Locked | error: "Account locked. Try again in N minute(s)." |
| Result | Keys |
|---|---|
| Success | token, refresh_token, user |
| Expired | error: "Refresh token has expired" |
| Revoked | error: "Refresh token has been revoked" |
| Result | Keys |
|---|---|
| Success | message: "Token revoked successfully" |
| Invalid | error: "Invalid token" |
Returns { message:, reset_token: } for known emails, same message for unknown ones (no enumeration).
| Result | Keys |
|---|---|
| Success | message: "Password has been reset successfully" |
| Bad token | error: "Invalid or expired reset token" |
Returns { message:, magic_token: } for known emails, same message for unknown ones.
| Result | Keys |
|---|---|
| Success | token, user, refresh_token |
| Bad token | error: "Invalid or expired magic link" |
All return { error: } when session_tracking_enabled is false.
Returns the JWT payload hash, or nil if invalid/expired/revoked.
| Claim | Value |
|---|---|
sub |
User ID |
iat |
Issued at |
exp |
Expires at |
jti |
Unique token ID |
ApiAuthenticationGem::RevokedToken.cleanup!Run this periodically to prune expired entries from the revoked tokens table.
bundle exec rspec
# 186 examples, 0 failuresMIT