🌐 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.
- 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.
- 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/profileand 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
/upsafelisted: ordinary API300/60s/IP; refresh20/60s/IP; sign-in5/60s/IPand10/60s/email; registration10/hour/IP; password reset5/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.
- 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
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.
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 tokens are carried over two transports:
- Browser clients use the signed HttpOnly, Secure, SameSite=Lax
refresh_tokencookie. Do not put the raw token inlocalStorageorsessionStorage. - Native / mobile / CLI clients read the raw token from the JSON body
(
refresh_token) and send it back via therefresh_tokenparam or theX-Refresh-Tokenheader.
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.
The application requires PostgreSQL 17 (local development uses the pg adapter).
- Install and start PostgreSQL 17, then create the local roles and databases. On
Debian/Ubuntu,
apt-get install postgresql-17is 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- Copy the local environment sample and prepare the database.
cp .env.sample .env
bin/setup- Start the application.
bin/dev-
Call the API on
http://localhost:4000by default. If you setPORTin your shell or.env, use that value instead. -
Use the snippets in
manual/as copy/paste references for auth and user-management requests:
manual/registration.shmanual/session.shmanual/password.shmanual/user.sh
This flow is intended for a clean local checkout and matches the routes covered by the auth integration tests.
-
Start the app with
bin/devand keep it running onhttp://localhost:4000unless you have overriddenPORT. -
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 .- Fetch the confirmation token from the local database.
bin/rails runner 'puts User.find_by!(email: "user@example.com").confirmation_token'- Confirm the account.
curl -sS "http://localhost:4000/users/confirmation?confirmation_token=<token>" | jq .- 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")- Read the primary profile endpoint with that JWT.
curl -sS http://localhost:4000/user/profile \
-H "${JWT_AUTH_HEADER:-Authorization}: Bearer ${TOKEN}" | jq .- Sign out and revoke the token.
curl -sS -X DELETE http://localhost:4000/users/sign_out \
-H "${JWT_AUTH_HEADER:-Authorization}: Bearer ${TOKEN}" | jq .- Optionally inspect the broader request references in
manual/session.sh,manual/registration.sh,manual/password.sh, andmanual/user.shfor invalid-token, expired-token, password-reset, and admin/user-management examples.
Copy .env.sample to .env for local development with PostgreSQL:
cp .env.sample .envRecommended 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_testThe 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.
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=AuthorizationClient:
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-AuthorizationClient:
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.
Generate a coverage report locally with SimpleCov by running the test suite with COVERAGE=1:
COVERAGE=1 bin/rails testWhen 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/....
The route contract below reflects config/routes.rb and the current controller implementation.
| 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 |
| 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.
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.
| 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 |
| Method | Path | Purpose |
|---|---|---|
| GET | / |
Root welcome endpoint |
| GET | /up |
Health check for uptime/load balancers |
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:
422withuser: nullplustoken_info - Malformed token:
401with{ "error": "Invalid token" }
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!"
}
}'Use the confirmation link generated by Devise, for example:
curl "http://localhost:4000/users/confirmation?confirmation_token=<token>"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).
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.
curl -X DELETE http://localhost:4000/users/sign_out \
-H "${JWT_AUTH_HEADER:-Authorization}: Bearer <jwt_token>"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:
The docs/ folder contains deeper implementation and operations notes for the current authentication stack:
- docs/ACCESS_CONTROL.md - Authorization rules for guest, self-service, and admin flows
- docs/JWT_LIFECYCLE.md - JWT issuance, profile-token metadata, revocation, and cleanup
- docs/RATE_LIMITING.md - Current Rack::Attack thresholds, error responses, and proxy considerations
- docs/DEPLOYMENT.md - Kamal, Docker, environment variables, health checks, and PostgreSQL persistence
- docs/DEPLOYMENT.vi.md - Vietnamese deployment guide
- deploy/modal/README.md and Vietnamese guide - provider-neutral Modal recipe (
https://<your-modal-url>) - deploy/beam/README.md and deploy/huggingface/README.md - additional verified deployment recipes
- docs/RELEASE_PROCESS.md and Vietnamese guide - release process
- docs/releases/v1.0.0-acceptance.md - v1.0.0 acceptance record
- CHANGELOG.md - release history
Project improvement artifacts are tracked in:
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).
The favicon files are served from public/:
favicon.icofavicon.pngfavicon.svgandroid-chrome-192x192.pngandroid-chrome-512x512.pngapple-touch-icon.pngapple-touch-icon-precomposed.png
They are derived from a Flaticon sticker design. See public/license.md for the source, attribution, and license details.
This project is licensed under the MIT License.
See the LICENSE file for details.