devise-api
Token-based API authentication for Devise. Opaque access + refresh tokens, one devise :api module, zero Warden strategies to write.
devise-api is a Rails engine that plugs into Devise's own extension mechanism. Add :api to your Devise model and you get sign-up, sign-in, token refresh, revocation, and an authenticated info endpoint — plus controller helpers (authenticate_devise_api_token!, current_devise_api_user) for protecting the rest of your API.
Highlights
- 🔑 Opaque access + refresh tokens stored in your database — revocable at any time, no JWT invalidation headaches
- 🔁 Refresh token rotation with reuse detection (a replayed rotated token revokes the whole token family)
- 🧩 Plays well with Devise modules —
lockable,confirmable,trackableare detected and honored automatically - ⚙️ Fully configurable — token TTLs and generators, paranoid mode, header/params extraction, per-action callbacks, and swappable base classes for the token model and controller
- 🧱 Service objects built on dry-monads — every endpoint delegates to a composable, overridable service
- 📚 Documented for humans and AI agents —
docs/holds contractual architecture, API, and configuration references
Table of contents
- How it works
- Requirements
- Quick start
- Endpoints
- Protecting your own endpoints
- Response payloads
- Configuration
- Security checklist
- Devise module compatibility
- Customization
- Documentation
- Development
- Contributing
- License
How it works
A client signs in once, then uses a short-lived access token per request and a longer-lived refresh token to get new access tokens without re-sending credentials:
sequenceDiagram
autonumber
participant Client
participant API as Your Rails API
participant DB as devise_api_tokens
Client->>API: POST /users/tokens/sign_in (email + password)
API->>DB: create token pair
API-->>Client: 200 { token, refresh_token, expires_in, resource_owner }
loop While access token is valid
Client->>API: GET /your/endpoints (Authorization: Bearer <access token>)
API-->>Client: 200 your data
end
Client->>API: GET /your/endpoints (expired access token)
API-->>Client: 401 { "error": "expired_token" }
Client->>API: POST /users/tokens/refresh (Authorization: Bearer <refresh token>)
API->>DB: mint new pair (rotation: revoke presented token)
API-->>Client: 200 { token, refresh_token, ... }
Client->>API: POST /users/tokens/revoke (Authorization: Bearer <access token>)
API->>DB: mark revoked
API-->>Client: 204 No Content
Tokens are opaque random strings (Devise.friendly_token by default) persisted in a devise_api_tokens table with a polymorphic resource_owner, so one table serves any number of Devise scopes (User, Customer, …). A token is active only while it is neither expired nor revoked:
stateDiagram-v2
[*] --> Active: sign_up / sign_in / refresh
Active --> Expired: access_token.expires_in elapses
Active --> Revoked: POST /tokens/revoke
Active --> Revoked: rotation on refresh
Expired --> [*]: refresh (mints a new pair)
Revoked --> [*]
note right of Revoked
Reuse detection: presenting a rotated
refresh token again revokes the
entire token family
end note
For the full component map and request lifecycle diagrams, see docs/architecture.md.
Requirements
| Dependency | Version |
|---|---|
| Ruby | >= 2.7 |
| Rails | >= 6.0 |
| Devise | >= 4.7.2 |
Quick start
1. Install the gem
bundle add devise-api
Or track main from your Gemfile:
gem 'devise-api', github: 'nejdetkadir/devise-api', branch: 'main'
2. Generate the migration and locales
rails generate devise_api:install
rails db:migrate
This copies a migration for the devise_api_tokens table and the locale file config/locales/devise_api.en.yml into your app.
3. Add the :api module to your Devise model
class User < ApplicationRecord
devise :database_authenticatable,
:registerable,
:recoverable,
:rememberable,
:validatable,
:api # <--- add this
end
That's it — your existing devise_for :users in config/routes.rb now draws the token endpoints automatically.
4. Try it
curl -X POST http://localhost:3000/users/tokens/sign_in \
-H 'Content-Type: application/json' \
-d '{ "email": "test@example.com", "password": "123456" }'
{
"token": "ACCESS_TOKEN",
"refresh_token": "REFRESH_TOKEN",
"expires_in": 3600,
"token_type": "Bearer",
"resource_owner": { "id": 1, "email": "test@example.com", "created_at": "...", "updated_at": "..." }
}
Endpoints
Drawn under /<scope>/tokens for every Devise scope whose model includes :api (examples use devise_for :users):
| Verb | Path | Purpose | Auth |
|---|---|---|---|
POST |
/users/tokens/sign_up |
Register and get a token pair | — |
POST |
/users/tokens/sign_in |
Authenticate and get a token pair | — |
POST |
/users/tokens/refresh |
Exchange a refresh token for a new pair | refresh token |
POST |
/users/tokens/revoke |
Revoke the presented token | access token |
GET |
/users/tokens/info |
Current resource owner details | access token |
All tokens — including the refresh token for /refresh — travel in the same slot: the Authorization: Bearer <token> header and/or an access_token param, depending on authorization.location (see Configuration).
# Sign up
curl -X POST http://localhost:3000/users/tokens/sign_up \
-H 'Content-Type: application/json' \
-d '{ "email": "test@example.com", "password": "123456" }'
# Refresh (note: the REFRESH token goes in the Authorization header)
curl -X POST http://localhost:3000/users/tokens/refresh \
-H 'Authorization: Bearer REFRESH_TOKEN'
# Revoke
curl -X POST http://localhost:3000/users/tokens/revoke \
-H 'Authorization: Bearer ACCESS_TOKEN'
# Info
curl http://localhost:3000/users/tokens/info \
-H 'Authorization: Bearer ACCESS_TOKEN'
Route paths and the controller are customizable through the standard devise_for options:
# config/routes.rb
Rails.application.routes.draw do
devise_for :customers, controllers: { tokens: 'customers/api/tokens' }
end
Protecting your own endpoints
The gem mixes three helpers into every controller:
| Helper | Returns |
|---|---|
authenticate_devise_api_token! |
Renders a 401 error response unless a valid, active access token is presented |
current_devise_api_token |
The active Devise::Api::Token (or nil) |
current_devise_api_user |
The token's resource owner (or nil) — works for any scope, despite the name |
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
skip_before_action :verify_authenticity_token, raise: false
before_action :authenticate_devise_api_token!
def index
render json: current_devise_api_user.orders, status: :ok
end
def show
order = current_devise_api_token.resource_owner.orders.find(params[:id])
render json: order, status: :ok
end
end
Response payloads
Success (sign_in 200, sign_up 201, refresh 200):
{
"token": "...",
"refresh_token": "...",
"expires_in": 3600,
"token_type": "Bearer",
"resource_owner": { "id": 1, "email": "...", "created_at": "...", "updated_at": "..." }
}
info returns just the resource_owner object; revoke returns 204 No Content.
Errors are consistent JSON with a symbolic type and human-readable descriptions (translated via i18n):
{
"error": "expired_token",
"error_description": ["Your token has expired. Please sign in again."]
}
Common error types: invalid_authentication (401), invalid_token (401), expired_token (401), revoked_token (401), expired_refresh_token (401), invalid_refresh_token (400), sign_up_disabled (400), resource_owner_create_error (422). The complete catalog — every type, status, and trigger — lives in docs/api-reference.md.
Configuration
Everything is configured on a single global inside Devise.setup. All values shown are the defaults:
# config/initializers/devise.rb
Devise.setup do |config|
config.api.configure do |api|
# Access token
api.access_token.expires_in = 1.hour
api.access_token.expires_in_infinite = ->(_resource_owner) { false }
api.access_token.generator = ->(_resource_owner) { Devise.friendly_token(60) }
# Refresh token
api.refresh_token.enabled = true
api.refresh_token.expires_in = 1.week
api.refresh_token.expires_in_infinite = ->(_resource_owner) { false }
api.refresh_token.generator = ->(_resource_owner) { Devise.friendly_token(60) }
api.refresh_token.rotation_enabled = false # recommended: true (see Security checklist)
# Sign up
api.sign_up.enabled = true
api.sign_up.extra_fields = [] # e.g. %i[first_name last_name] — writable at sign-up AND echoed in responses
# Error responses
api.error_response.verbose_account_state = true # false hides lockable/confirmable details from errors
api.paranoid = false # true makes unknown accounts indistinguishable from wrong passwords
# Token extraction
api..key = 'Authorization'
api..scheme = 'Bearer'
api..location = :both # :header, :params, or :both (params win)
api..params_key = 'access_token'
# Base classes (string names, constantized lazily — point at your own subclasses)
api.base_token_model = 'Devise::Api::Token'
api.base_controller = '::DeviseController'
# Lifecycle hooks (all default to no-ops)
api.before_sign_in = ->(params, request, resource_class) {}
api.before_sign_up = ->(params, request, resource_class) {}
api.before_refresh = ->(token, request) {}
api.before_revoke = ->(token, request) {}
api.after_successful_sign_in = ->(resource_owner, token, request) {}
api.after_successful_sign_up = ->(resource_owner, token, request) {}
api.after_successful_refresh = ->(resource_owner, token, request) {}
api.after_successful_revoke = ->(resource_owner, token, request) {}
end
end
Settings are read at use time (never cached at boot), so changes take effect immediately — handy in tests. The full reference with types, defaults, and exactly which code consumes each setting is in docs/configuration.md.
Security checklist
Recommended production settings and guardrails:
- ✅ Send tokens in the
Authorizationheader only. The defaultauthorization.location = :bothalso accepts tokens as query/body params, and URLs leak into server logs, browser history, andRefererheaders. Setapi.authorization.location = :headerunless you need params support. - ✅ Enable refresh token rotation (
api.refresh_token.rotation_enabled = true). Each refresh then revokes the presented refresh token, and replaying a rotated token revokes the entire token family (reuse detection). - ✅ Enable paranoid mode (
api.paranoid = true) to prevent account enumeration — unknown emails and wrong passwords return the same genericinvalid_authenticationerror. - ✅ Rate limit the token endpoints. The gem does not throttle
sign_in/sign_up/refresh; put rack-attack or an equivalent in front of them. Deviselockableonly slows per-account brute force. - ⚠️ Audit
sign_up.extra_fields. Every listed field is mass-assignable at sign-up and echoed in every token/info response — never list privileged fields like:roleor:admin. - ⚠️ Keep token values out of logs. The gem adds
access_token,refresh_token, andprevious_refresh_tokentofilter_parametersand filters the token model's#inspect, but raw SQL logging (e.g. debug log level in production) can still print token values.
The full threat-model review is in docs/analysis/security-review.md.
Devise module compatibility
devise-api feature-detects the other modules on your model and adapts:
| Module | Behavior |
|---|---|
trackable |
sign_in/sign_up update the tracked fields (sign-in count, IPs, timestamps) |
lockable |
Failed sign-ins increment failed_attempts; lock state is reported in the error payload (unless paranoid/quiet); a successful sign-in resets the counter |
confirmable |
Unconfirmed users can sign up (they get tokens plus a confirmable notice in the response) but cannot sign in until confirmed |
Customization
Override the responses
Prepend a decorator module to TokenResponse or ErrorResponse:
# app/lib/devise/api/responses/token_response_decorator.rb
module Devise::Api::Responses::TokenResponseDecorator
def body
default_body.merge({ roles: resource_owner.roles })
end
end
# config/initializers/devise.rb
require 'devise/api/responses/token_response_decorator'
Devise::Api::Responses::TokenResponse.prepend Devise::Api::Responses::TokenResponseDecorator
Swap the base classes
base_token_model and base_controller are stored as class names and resolved lazily, so you can subclass without load-order problems:
# app/models/api_token.rb
class ApiToken < Devise::Api::Token
belongs_to :organization, optional: true
end
# config/initializers/devise.rb
Devise.setup do |config|
config.api.configure do |api|
api.base_token_model = 'ApiToken'
api.base_controller = 'Api::BaseController'
end
end
Hook into the lifecycle
The before_* / after_successful_* callbacks (see Configuration) are handy for audit logging, analytics, or sending welcome emails:
api.after_successful_sign_up = lambda { |resource_owner, _token, _request|
WelcomeMailer.with(user: resource_owner).welcome.deliver_later
}
before_* return values are ignored — raise, or use a before_action in a subclassed controller, if you need to halt a request.
Build your own services
Every endpoint delegates to a service object built on dry-monads, dry-types, and dry-initializer. Inherit from Devise::Api::BaseService to compose your own:
# app/services/devise/api/tokens_service/v2/create.rb
module Devise::Api::TokensService::V2
class Create < Devise::Api::BaseService
option :params, type: Types::Hash, reader: true
option :resource_class, type: Types::Class, reader: true
def call
# ...
Success(resource)
end
end
end
# app/controllers/api/v1/tokens_controller.rb
def create
result = Devise::Api::TokensService::V2::Create.new(params: params, resource_class: Customer).call
if result.success?
render json: result.success, status: :created
else
render json: result.failure, status: :unprocessable_entity
end
end
Service contracts (inputs, success/failure values, composition) are documented in docs/services.md, and all supported customization points in docs/extending.md.
Documentation
The docs/ directory is the source of truth for how the gem works internally — written for contributors and AI coding agents, and kept in sync with the code by convention:
| Document | Contents |
|---|---|
| architecture.md | Component map, boot sequence, request lifecycle (with diagrams) |
| api-reference.md | Every endpoint, payload, and the full error catalog |
| configuration.md | Every setting: type, default, and where it is consumed |
| data-model.md | devise_api_tokens schema, token state machine, refresh chains |
| services.md | Service-object contracts and composition |
| extending.md | Supported customization points |
| testing.md / development.md | Test layout, dummy app, CI, release process |
| analysis/ | Security review and vetted known-issues backlog |
Development
bin/setup # install dependencies
bundle exec rake # what CI runs: RSpec + RuboCop
bundle exec rake rspec # tests only
bin/console # interactive prompt
Tests run against the dummy Rails app in spec/dummy. To install the gem locally run bundle exec rake install; to release, bump version.rb and run bundle exec rake release.
Contributing
Bug reports and pull requests are welcome on GitHub. Please read docs/README.md for the ground rules (docs are contractual — behavior changes must update the matching document) and check the known-issues backlog before "fixing" surprising code.
This project is intended to be a safe, welcoming space for collaboration; contributors are expected to follow the code of conduct.
License
The gem is available as open source under the terms of the MIT License.