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)
- Add
has_secure_password to User.
- Rename
encrypted_password → password_digest (or configure has_secure_password :digest_column).
- Replace
Devise::SessionsController with a plain SessionsController that calls User.authenticate_by(email:, password:) (Rails 7.1 method, constant-time, case-insensitive).
- Implement
current_user and authenticate_user! in ApplicationController (a before_action that checks session[:user_id]).
- Remove
devise_for from routes and replace with plain resource routes for sessions.
Phase 2 — Password reset (recoverable)
- Use
User.generates_token_for(:password_reset, expires_in: 6.hours) { password_salt.last(10) } — token is automatically invalidated once the password changes.
- Implement
PasswordResetsController with new, create, edit, update actions.
- Send reset email via an existing ActionMailer or a new
UserMailer.
Phase 3 — Email confirmation (confirmable)
- Use
generates_token_for(:email_confirmation, expires_in: 2.days) { email } — token is automatically invalidated once the email is confirmed.
- Implement
ConfirmationsController with create (resend) and update (confirm) actions.
- Retain the
confirmed_at and unconfirmed_email columns; remove Devise-managed columns (confirmation_token, confirmation_sent_at).
Phase 4 — Account locking (lockable)
- Retain
failed_attempts and locked_at columns.
- Implement incrementing
failed_attempts in the sessions create action (reset on success, lock after 5).
- Use
generates_token_for(:unlock, expires_in: 1.day) for the unlock email link.
- Implement
UnlocksController.
Phase 5 — Remember me (rememberable)
- Retain
remember_created_at column (useful for auditing) or drop if not needed.
- Use a signed, permanent cookie with
cookies.signed.permanent[:user_id] in the sessions controller; clear on sign out.
Phase 6 — Session timeout (timeoutable)
- Store
session[:last_active_at] on every request.
- 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)
- Retain all tracking columns (
sign_in_count, current_sign_in_at, last_sign_in_at, current_sign_in_ip, last_sign_in_ip).
- Update them in the sessions
create action (three lines of code).
Phase 8 — Cleanup
- Remove
devise and devise-i18n from the Gemfile.
- Delete
config/initializers/devise.rb.
- Remove
app/views/devise/ and replace with plain views under app/views/sessions/, app/views/password_resets/, etc.
- Update i18n keys from
devise.* to custom keys.
- 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.
- 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.
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
Usermodel uses seven Devise modules:database_authenticatablerecoverablerememberableconfirmablelockabletrackablesign_in_count,current/last_sign_in_at/iptimeoutableRoutes use
devise_for :userswith a customSessionsController < Devise::SessionsControllerthat 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_confirmationwriters,password_reset_token(Rails 7.1+),generates_token_for(:password_reset, expires_in: 6.hours)— coversdatabase_authenticatableandrecoverablecompletely.generates_token_for(Rails 7.1) — signed, expiring tokens suitable for confirmation and unlock links, replacingconfirmableandlockabletoken generation.config.force_ssl+config.session_store— session security handled by the framework.ActiveSupport::SecureRandom,MessageVerifier— token signing without a gem.rails generate authentication) is a useful reference implementation even if not used directly.The
encrypted_passwordcolumn in theuserstable can be renamed topassword_digest(the columnhas_secure_passwordexpects) in a single migration with no data loss.Proposed migration plan
Phase 1 — Password authentication (
database_authenticatable)has_secure_passwordtoUser.encrypted_password→password_digest(or configurehas_secure_password :digest_column).Devise::SessionsControllerwith a plainSessionsControllerthat callsUser.authenticate_by(email:, password:)(Rails 7.1 method, constant-time, case-insensitive).current_userandauthenticate_user!inApplicationController(abefore_actionthat checkssession[:user_id]).devise_forfrom routes and replace with plain resource routes for sessions.Phase 2 — Password reset (
recoverable)User.generates_token_for(:password_reset, expires_in: 6.hours) { password_salt.last(10) }— token is automatically invalidated once the password changes.PasswordResetsControllerwithnew,create,edit,updateactions.UserMailer.Phase 3 — Email confirmation (
confirmable)generates_token_for(:email_confirmation, expires_in: 2.days) { email }— token is automatically invalidated once the email is confirmed.ConfirmationsControllerwithcreate(resend) andupdate(confirm) actions.confirmed_atandunconfirmed_emailcolumns; remove Devise-managed columns (confirmation_token,confirmation_sent_at).Phase 4 — Account locking (
lockable)failed_attemptsandlocked_atcolumns.failed_attemptsin the sessionscreateaction (reset on success, lock after 5).generates_token_for(:unlock, expires_in: 1.day)for the unlock email link.UnlocksController.Phase 5 — Remember me (
rememberable)remember_created_atcolumn (useful for auditing) or drop if not needed.cookies.signed.permanent[:user_id]in the sessions controller; clear on sign out.Phase 6 — Session timeout (
timeoutable)session[:last_active_at]on every request.before_action :check_session_timeout!inApplicationControllerthat signs out and redirects if more than 1 hour has elapsed.Phase 7 — Tracking (
trackable)sign_in_count,current_sign_in_at,last_sign_in_at,current_sign_in_ip,last_sign_in_ip).createaction (three lines of code).Phase 8 — Cleanup
deviseanddevise-i18nfrom theGemfile.config/initializers/devise.rb.app/views/devise/and replace with plain views underapp/views/sessions/,app/views/password_resets/, etc.devise.*to custom keys.config.logout_link_path,config.current_user_method) to use the new routes/helpers — these are already using the standardcurrent_userhelper so minimal change is needed.unlock_tokencan be replaced by the generated token).What this is NOT
Notes
password_digestcolumn (fromhas_secure_password) was dropped in migration20180525141138in favour of Devise'sencrypted_password. The rename migration reverses that decision with no data loss.user{id}@example.comfor users created without email) is unrelated to Devise and can stay as-is.devise-i18ntranslations 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.