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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions