Skip to content

Repository files navigation

Rails 8 API Authentication with JWT

Ruby 3.x Ruby 4.x Rails 8.1 CI CircleCI License: MIT

🌐 Language / Ngôn ngữ: English | Tiếng Việt

Production-style Rails 8 authentication API with Devise/JWT, refresh-token rotation and revocation, rate limiting, Solid Queue, PostgreSQL, and security-focused CI. It is portable across Rails and PostgreSQL hosting environments; the public demonstration below is one accepted deployment, not a provider requirement.

Live Public Demo

  • Base URL: https://dangkhoa2016--rails-8-api-authentication-rails-api.modal.run
  • Health: GET https://dangkhoa2016--rails-8-api-authentication-rails-api.modal.run/up

This is a public, production-style demonstration hosted on Modal. The application remains provider-independent and compatible with Rails/PostgreSQL deployments; the accepted demo uses PostgreSQL on Neon. Modal runs it with min_containers=0, so scale-to-zero cold starts are expected. It makes no high-availability, SLA, multi-tenant, or volumetric-DDoS claim. No secrets, tokens, or administrator credentials are published.

Features

  • Devise/JWT access tokens, denylist-backed revocation, refresh-token rotation, reuse detection, and token-family revocation.
  • Browser refresh through a signed HttpOnly cookie, plus raw refresh-token support for native, mobile, and CLI clients.
  • User registration with required username, email confirmation, password reset, and self-service account update or deletion.
  • Profile lookup with token metadata via /user/profile and compatibility aliases /user/me, /user/whoami.
  • User active/inactive status — deactivated accounts are automatically blocked from signing in.
  • Admin-only user listing, user creation, role updates, user deletion, account status toggling (lock/unlock), and email force-confirmation.
  • Rack::Attack rate limits, with /up safelisted: ordinary API 300/60s/IP; refresh 20/60s/IP; sign-in 5/60s/IP and 10/60s/email; registration 10/hour/IP; password reset 5/hour/IP.
  • Automated JWT denylist and refresh-token cleanup through Active Job, Solid Queue, and a Rake task.
  • PostgreSQL with Solid Cache, Solid Queue, and Solid Cable; portable Docker and Kamal deployment guidance with verified deployment recipes.
  • Security-focused CI: Brakeman, RuboCop, the full Rails test suite, and dedicated authentication regression coverage.

Technologies Used

  • Rails 8 — Full-featured MVC framework
  • Devise — Flexible authentication solution
  • devise-jwt — JWT token authentication for Devise
  • Puma — Application web server
  • PostgreSQL 17 — Database
  • Solid Cache, Solid Queue, Solid Cable — Rails 8 default adapters
  • Rack::CORS — Cross-Origin Resource Sharing
  • Rack::Attack — Rate limiting on auth endpoints
  • Docker + Kamal — Containerized deployment
  • Thruster — Asset caching and X-Sendfile acceleration
  • dotenv — Environment variable management
  • Brakeman — Static security analysis
  • RuboCop — Linting and style enforcement
  • SimpleCov — Code coverage

Ruby Version Matrix

The repository runs on three supported Ruby versions:

Runtime Role
3.3 Default for local development and the container image
3.2 Minimum supported version
4.0 Additional tested version (CI only)

The CI pipeline runs the full test suite on 3.2, 3.3, and 4.0; the container defaults to 3.3 and the RUBY_VERSION build arg can override it.

The repository intentionally does not commit Gemfile.lock. Every Ruby version resolves its own dependency set, so the lockfile is generated by the local bundler, by the Docker build, or by CI — and never shared between runtimes. This keeps the project portable across the support matrix without pinning a single resolution.

Gemfile constrains Rails to ~> 8.1.3 (shown here as the broader Rails 8.1 release line) and keeps json below version 3. CI verifies Ruby 3.2, 3.3, and 4.0 against fresh dependency resolution.

Dependency Security

bundle-audit currently reports CVE-2026-54659 in pagy (a path traversal via the locale option). The patched releases (>= 43.5.6) require Ruby >= 3.3 and cannot run on the minimum supported 3.2.2, so the fix is not backportable to the current support matrix.

The vulnerable code path is not reachable in this application: pagination in UsersController#index calls pagy(..., limit:, max_limit:) only and never passes a user-controlled locale:. CI resolves fresh dependency sets per runtime, so the audit_gems job (Ruby 3.3) automatically picks a patched pagy. This risk is accepted and tracked; revisit when Ruby 3.2 support is dropped.

Refresh Token Transport

Refresh tokens are carried over two transports:

  • Browser clients use the signed HttpOnly, Secure, SameSite=Lax refresh_token cookie. Do not put the raw token in localStorage or sessionStorage.
  • Native / mobile / CLI clients read the raw token from the JSON body (refresh_token) and send it back via the refresh_token param or the X-Refresh-Token header.

All refresh-token traffic must use TLS in production; raw tokens are never persisted or logged — only their SHA-256 digest is stored. See docs/JWT_LIFECYCLE.md for the full policy.

Quick Start

The application requires PostgreSQL 17 (local development uses the pg adapter).

  1. Install and start PostgreSQL 17, then create the local roles and databases. On Debian/Ubuntu, apt-get install postgresql-17 is one option; Docker works too:
docker run -d --name rails-auth-pg \
  -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres \
  -p 127.0.0.1:5432:5432 postgres:17
  1. Copy the local environment sample and prepare the database.
cp .env.sample .env
bin/setup
  1. Start the application.
bin/dev
  1. Call the API on http://localhost:4000 by default. If you set PORT in your shell or .env, use that value instead.

  2. Use the snippets in manual/ as copy/paste references for auth and user-management requests:

  • manual/registration.sh
  • manual/session.sh
  • manual/password.sh
  • manual/user.sh

Local Auth Quick Start

This flow is intended for a clean local checkout and matches the routes covered by the auth integration tests.

  1. Start the app with bin/dev and keep it running on http://localhost:4000 unless you have overridden PORT.

  2. Register a new user in a separate terminal.

curl -sS -X POST http://localhost:4000/users \
  -H "Content-Type: application/json" \
  -d '{
    "user": {
      "email": "user@example.com",
      "username": "user1",
      "password": "Password1!",
      "password_confirmation": "Password1!"
    }
  }' | jq .
  1. Fetch the confirmation token from the local database.
bin/rails runner 'puts User.find_by!(email: "user@example.com").confirmation_token'
  1. Confirm the account.
curl -sS "http://localhost:4000/users/confirmation?confirmation_token=<token>" | jq .
  1. Sign in and capture the JWT from the configured response header (default Authorization, see JWT transport header).
TOKEN=$(curl -is -X POST http://localhost:4000/users/sign_in \
  -H "Content-Type: application/json" \
  -d '{
    "user": {
      "email": "user@example.com",
      "password": "Password1!"
    }
  }' | tr -d '\r' | sed -n "s/^$(echo "${JWT_AUTH_HEADER:-Authorization}" | tr '[:upper:]' '[:lower:]'): Bearer //p")
  1. Read the primary profile endpoint with that JWT.
curl -sS http://localhost:4000/user/profile \
  -H "${JWT_AUTH_HEADER:-Authorization}: Bearer ${TOKEN}" | jq .
  1. Sign out and revoke the token.
curl -sS -X DELETE http://localhost:4000/users/sign_out \
  -H "${JWT_AUTH_HEADER:-Authorization}: Bearer ${TOKEN}" | jq .
  1. Optionally inspect the broader request references in manual/session.sh, manual/registration.sh, manual/password.sh, and manual/user.sh for invalid-token, expired-token, password-reset, and admin/user-management examples.

Environment

Copy .env.sample to .env for local development with PostgreSQL:

cp .env.sample .env

Recommended local settings for development:

RAILS_ENV=development
RAILS_LOG_TO_STDOUT=true
PORT=4000
RAILS_MAX_THREADS=3
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=rails_8_api_authentication_development
POSTGRES_TEST_DB=rails_8_api_authentication_test

The connection defaults (POSTGRES_HOST=127.0.0.1, POSTGRES_PORT=5432, POSTGRES_USER=postgres) match a local PostgreSQL 17 instance or the Docker container above; .env.sample ships values for local development only and must never be reused for production. Statement timeouts are controlled with POSTGRES_STATEMENT_TIMEOUT (default 5000ms).

If you do not set PORT, bin/dev boots on 4000 locally. The shipped .env.sample sets PORT=4000, so copying it unchanged moves local development to http://localhost:4000. The full variable reference — including production secrets, Puma concurrency, mailer, admin seed, CORS, and the manual JWT token slot — is documented in .env.sample.

For browser clients running on a different origin, the CORS config allows requests from CORS_ALLOWED_ORIGINS and exposes the configured JWT response header (expose: [JWT_AUTH_HEADER] in config/initializers/cors.rb), so browser clients can read the JWT from the sign-in response.

JWT transport header

The access JWT is returned and accepted in a configurable HTTP header. The default preserves the standard convention:

Environment JWT_AUTH_HEADER Client header
Local / Docker Authorization Authorization: Bearer <JWT>
Standard reverse proxy Authorization Authorization: Bearer <JWT>
Beam.cloud workaround X-Authorization X-Authorization: Bearer <JWT>
# Default / normal hosting providers
JWT_AUTH_HEADER=Authorization

Client:

Authorization: Bearer <JWT>

During testing on the Beam Pod URL used for this project, requests carrying the Rails JWT in the standard Authorization header were intercepted before reaching Rails. Configuring Rails to use X-Authorization avoids that header collision. This is a provider/deployment-specific transport workaround, not a change to JWT cryptography.

# Beam.cloud workaround
JWT_AUTH_HEADER=X-Authorization

Client:

X-Authorization: Bearer <JWT>

The JWT itself, its signing secret, and its claims are identical in both modes; only the HTTP transport header changes. JWT_AUTH_HEADER is read by both the Devise JWT strategy (config/initializers/devise.rb) and the CORS expose-header config, so it only needs to be set in one place. The standard Authorization header remains the default and recommended value when the hosting provider forwards it unchanged.

Code Coverage

Generate a coverage report locally with SimpleCov by running the test suite with COVERAGE=1:

COVERAGE=1 bin/rails test

When COVERAGE=1 is set, the test suite runs without Rails parallel workers so the SimpleCov report stays accurate.

The report is written to public/coverage. While the Rails server is running in development, open http://localhost:4000/coverage to view the latest generated report. This development-only endpoint redirects to the static HTML report.

Internally, the app redirects /coverage to /coverage/ before the static file server handles the request. The trailing slash matters because the generated SimpleCov HTML references assets with relative paths such as ./assets/....

Current Route Contract

The route contract below reflects config/routes.rb and the current controller implementation.

Authentication Routes

Method Path Purpose
POST /users Register a new account
POST /users/sign_in Sign in and receive JWT in the Authorization response header
POST /users/tokens/refresh Refresh access JWT using a valid refresh token
DELETE /users/sign_out Sign out and revoke the current token & refresh token
GET /users/confirmation Confirm email via Devise confirmable flow
POST /users/password Send password reset instructions
GET /users/password/edit API-only password-reset instructions for compatibility; no form or mutation
PUT/PATCH /users/password Reset password with a token
PUT/PATCH /users Update the current signed-in account
DELETE /users Delete the current signed-in account

Profile Routes

Method Path Purpose
GET /user/profile Primary profile endpoint
GET /user/me Compatibility alias
GET /user/whoami Compatibility alias

All three profile routes hit the same controller action and return the same payload shape.

Password reset (API-native)

This API-only service does not ship a browser password-reset form. POST /users/password sends an email containing the raw reset token, the canonical JSON payload, and a copy/paste curl command for PUT /users/password. The mutation request must use reset_password_token, password, and password_confirmation inside user.

GET /users/password/edit?reset_password_token=<TOKEN> is only a compatibility instruction endpoint for someone who reaches a conventional Devise reset URL. It returns plain-text API instructions with no-store and does not validate, consume, or mutate the token. PUT /users/password remains the mutation authority.

Admin and User Management Routes

Method Path Purpose
GET /users List users, admin only
POST /users/create Create a user as admin
GET /users/:id View a user; admin or self
PUT/PATCH /users/:id Update a user; admin or self
DELETE /users/:id Delete a user; admin or self
PUT /users/:id/status Set active/inactive status; admin only
PUT /users/:id/confirm_by_admin Force-confirm email; admin only

Utility Routes

Method Path Purpose
GET / Root welcome endpoint
GET /up Health check for uptime/load balancers

Request Format Notes

Devise endpoints expect payloads nested under the user key. For the registration endpoint POST /users, the username field is required.

Example sign-up request:

{
  "user": {
    "email": "user@example.com",
    "username": "user1",
    "password": "Password1!",
    "password_confirmation": "Password1!"
  }
}

Example sign-in request:

{
  "user": {
    "email": "user@example.com",
    "password": "Password1!"
  }
}

Self-service account updates on PUT /users or PATCH /users must include current_password. Admin-managed updates on PUT /users/:id or PATCH /users/:id go through UsersController and do not require current_password.

Profile lookup also has two different unauthenticated failure modes:

  • Missing, expired, or revoked token: 422 with user: null plus token_info
  • Malformed token: 401 with { "error": "Invalid token" }

Example Flow

1. Register

curl -X POST http://localhost:4000/users \
  -H "Content-Type: application/json" \
  -d '{
    "user": {
      "email": "user@example.com",
      "username": "user1",
      "password": "Password1!",
      "password_confirmation": "Password1!"
    }
  }'

2. Confirm Email

Use the confirmation link generated by Devise, for example:

curl "http://localhost:4000/users/confirmation?confirmation_token=<token>"

3. Sign In

curl -i -X POST http://localhost:4000/users/sign_in \
  -H "Content-Type: application/json" \
  -d '{
    "user": {
      "email": "user@example.com",
      "password": "Password1!"
    }
  }'

The JWT is returned in the configured response header (JWT_AUTH_HEADER, default Authorization).

4. Read Profile

curl http://localhost:4000/user/profile \
  -H "${JWT_AUTH_HEADER:-Authorization}: Bearer <jwt_token>"

/user/me and /user/whoami are compatibility aliases for the same response.

5. Sign Out

curl -X DELETE http://localhost:4000/users/sign_out \
  -H "${JWT_AUTH_HEADER:-Authorization}: Bearer <jwt_token>"

Manual References

These are additional request examples and reference material. They include sample output blocks and should be treated as notes rather than shell scripts to execute verbatim:

Additional Documentation

The docs/ folder contains deeper implementation and operations notes for the current authentication stack:

Improvement Planning

Project improvement artifacts are tracked in:

Related Projects

This project has a sibling Node.js implementation that covers similar authentication concepts (JWT, role-based access control, token revocation) on a different stack:

  • dangkhoa2016/Nodejs-API-Authentication — A production-ready REST API for authentication and user management, built with Hono, Sequelize, bcryptjs, JWT, SQLite (dev), and Postgres (prod).

Favicon

The favicon files are served from public/:

  • favicon.ico
  • favicon.png
  • favicon.svg
  • android-chrome-192x192.png
  • android-chrome-512x512.png
  • apple-touch-icon.png
  • apple-touch-icon-precomposed.png

They are derived from a Flaticon sticker design. See public/license.md for the source, attribution, and license details.

License

This project is licensed under the MIT License.

See the LICENSE file for details.

About

Production-style Rails 8 authentication API with Devise/JWT, refresh-token rotation and revocation, rate limiting, Solid Queue, PostgreSQL, and security-focused CI.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages