Skip to content

Replace Devise with Rails built-in authentication #836

@rewritten

Description

@rewritten

Summary

Part of #835 (supply chain risk reduction). Devise was added to this project in 2013, at a time when Rails lacked most of the authentication primitives needed to build a secure login system. Rails 7.2 has since closed that gap almost entirely. This issue proposes migrating away from Devise and relying exclusively on Rails built-ins, which would reduce the dependency surface, make the auth flow easier to reason about, and remove a large category of magic from the codebase.

What Devise currently provides

The User model uses seven Devise modules:

Module What it does
database_authenticatable bcrypt password hashing, sign-in/sign-out
recoverable password-reset tokens, email
rememberable "remember me" cookie
confirmable email confirmation on signup / on email change
lockable locks account after N failed attempts, unlock via email
trackable records sign_in_count, current/last_sign_in_at/ip
timeoutable auto-logout after 1 h of inactivity

Routes use devise_for :users with a custom SessionsController < Devise::SessionsController that only suppresses flash messages. All other controllers use Devise helpers (current_user, authenticate_user!, user_signed_in?).

What Rails 7.2 already provides natively

  • has_secure_password — bcrypt hashing, authenticate, password/password_confirmation writers, password_reset_token (Rails 7.1+), generates_token_for(:password_reset, expires_in: 6.hours) — covers database_authenticatable and recoverable completely.
  • generates_token_for (Rails 7.1) — signed, expiring tokens suitable for confirmation and unlock links, replacing confirmable and lockable token generation.
  • config.force_ssl + config.session_store — session security handled by the framework.
  • ActiveSupport::SecureRandom, MessageVerifier — token signing without a gem.
  • The authentication generator added in Rails 8 (rails generate authentication) is a useful reference implementation even if not used directly.

The encrypted_password column in the users table can be renamed to password_digest (the column has_secure_password expects) in a single migration with no data loss.

Proposed migration plan

Phase 1 — Password authentication (database_authenticatable)

  1. Add has_secure_password to User.
  2. Rename encrypted_passwordpassword_digest (or configure has_secure_password :digest_column).
  3. Replace Devise::SessionsController with a plain SessionsController that calls User.authenticate_by(email:, password:) (Rails 7.1 method, constant-time, case-insensitive).
  4. Implement current_user and authenticate_user! in ApplicationController (a before_action that checks session[:user_id]).
  5. Remove devise_for from routes and replace with plain resource routes for sessions.

Phase 2 — Password reset (recoverable)

  1. Use User.generates_token_for(:password_reset, expires_in: 6.hours) { password_salt.last(10) } — token is automatically invalidated once the password changes.
  2. Implement PasswordResetsController with new, create, edit, update actions.
  3. Send reset email via an existing ActionMailer or a new UserMailer.

Phase 3 — Email confirmation (confirmable)

  1. Use generates_token_for(:email_confirmation, expires_in: 2.days) { email } — token is automatically invalidated once the email is confirmed.
  2. Implement ConfirmationsController with create (resend) and update (confirm) actions.
  3. Retain the confirmed_at and unconfirmed_email columns; remove Devise-managed columns (confirmation_token, confirmation_sent_at).

Phase 4 — Account locking (lockable)

  1. Retain failed_attempts and locked_at columns.
  2. Implement incrementing failed_attempts in the sessions create action (reset on success, lock after 5).
  3. Use generates_token_for(:unlock, expires_in: 1.day) for the unlock email link.
  4. Implement UnlocksController.

Phase 5 — Remember me (rememberable)

  1. Retain remember_created_at column (useful for auditing) or drop if not needed.
  2. Use a signed, permanent cookie with cookies.signed.permanent[:user_id] in the sessions controller; clear on sign out.

Phase 6 — Session timeout (timeoutable)

  1. Store session[:last_active_at] on every request.
  2. Add a before_action :check_session_timeout! in ApplicationController that signs out and redirects if more than 1 hour has elapsed.

Phase 7 — Tracking (trackable)

  1. Retain all tracking columns (sign_in_count, current_sign_in_at, last_sign_in_at, current_sign_in_ip, last_sign_in_ip).
  2. Update them in the sessions create action (three lines of code).

Phase 8 — Cleanup

  1. Remove devise and devise-i18n from the Gemfile.
  2. Delete config/initializers/devise.rb.
  3. Remove app/views/devise/ and replace with plain views under app/views/sessions/, app/views/password_resets/, etc.
  4. Update i18n keys from devise.* to custom keys.
  5. Update Active Admin config (config.logout_link_path, config.current_user_method) to use the new routes/helpers — these are already using the standard current_user helper so minimal change is needed.
  6. Drop obsolete Devise-specific columns if unused (e.g. unlock_token can be replaced by the generated token).

What this is NOT

  • This is not a proposal to remove email confirmation, account locking, or any current security feature.
  • This is not a big-bang rewrite; each phase can be a standalone PR with its own tests.

Notes

  • The existing password_digest column (from has_secure_password) was dropped in migration 20180525141138 in favour of Devise's encrypted_password. The rename migration reverses that decision with no data loss.
  • The dummy-email logic (user{id}@example.com for users created without email) is unrelated to Devise and can stay as-is.
  • devise-i18n translations will need to be ported; most strings already have equivalents in the existing locale files.

Happy to open a draft PR for Phase 1 as a starting point for discussion.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions