Skip to content

Conversation

@natevick
Copy link

@natevick natevick commented Aug 14, 2025

Summary

  • Fixes email enumeration vulnerabilities in both password reset and registration flows
  • Makes secure behavior the default for password reset (breaking change)
  • Adds paranoid mode support for registration to prevent email enumeration
  • Updates locale messages to use consistent, vague language that doesn't reveal email existence

Problem

Devise had two email enumeration vulnerabilities:

  1. Password Reset: Previously exposed whether an email existed by returning different error messages for existing vs non-existing accounts
  2. Registration: Showed "Email has already been taken" error when attempting to register with an existing email

Both vulnerabilities allow attackers to enumerate valid user emails.

Solution

Password Reset (Breaking Change - Secure by Default)

  • Changed default behavior to always return success with vague message
  • No longer dependent on paranoid mode for basic security
  • Message: "If your email address exists in our database, you will receive a password recovery link..."

Registration (Opt-in via Paranoid Mode)

  • When Devise.paranoid = true, duplicate registration attempts:
    • Show same success message as new registrations
    • Send "already registered" email to existing user with sign-in/reset links
    • Redirect to sign-in page instead of showing validation errors
  • Default behavior unchanged for backward compatibility

Breaking Changes

  1. send_reset_password_instructions no longer returns errors for non-existent emails by default
  2. Applications relying on explicit "not found" errors will need updates

Changes Made

  1. Modified send_reset_password_instructions in lib/devise/models/recoverable.rb
  2. Updated PasswordsController#create to always show success
  3. Added paranoid mode handling to RegistrationsController#create
  4. Created new mailer method and template for "already registered" emails
  5. Updated locale messages for consistent vague language
  6. Updated all affected tests

Test Plan

  • Unit tests for recoverable model
  • Integration tests for password reset flow
  • Integration tests for registration flow with paranoid mode
  • All tests passing
  • Manual testing of both flows
  • Verify emails sent correctly

Security Impact

This significantly improves security by preventing email enumeration attacks, a common vulnerability used in reconnaissance phases of attacks.

🤖 Generated with Claude Code

Previously, the password reset functionality exposed whether an email
existed in the system by returning different responses for existing vs
non-existing accounts. This is a security vulnerability that allows
attackers to enumerate valid user emails.

This commit makes the following changes:
- Always return a success response with a vague message for password reset requests
- Remove dependency on paranoid mode for this security feature
- Update locale messages to use consistent, non-revealing language
- Update tests to reflect the new secure-by-default behavior

The new behavior always shows: "If your email address exists in our
database, you will receive a password recovery link at your email
address in a few minutes."

This is a breaking change but improves security by default.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
@gregmolnar
Copy link
Contributor

How do you fix the same problem at registration? I usually recommend rate limiting for these, so even if your response indicates the existence of a user, it is difficult to enumerate a large list.

@natevick
Copy link
Author

That is a good question. I would likely need a different solution, which I have not considered yet.

Rate limiting does the trick to stop or make enumeration untenible. It still exposes data unnecessarily, which gets flagged on every pentest, which then has to be dealt with either through explanation or fixing the issue on a per install basis.

@gregmolnar
Copy link
Contributor

Those pentesters should focus on bigger issues :) I would put such thing maybe as informal on a report and if they don't mention the sign up flow having the same problem, I am not sure what are they doing.

@natevick
Copy link
Author

Yes, they should. I don't disagree, but it has been on multiple reports for multiple companies that I've been involved with over the years.

This PR came out of using a new Saas build on Rails using Devise, so I decided to stop wishing someone open a PR and just did it. I'm happy to work on the sign-up flow as well.

@natevick
Copy link
Author

Also, I could put this behind the paranoid flag.

@gregmolnar
Copy link
Contributor

Yes, they should. I don't disagree, but it has been on multiple reports for multiple companies that I've been involved with over the years.

This PR came out of using a new Saas build on Rails using Devise, so I decided to stop wishing someone open a PR and just did it. I'm happy to work on the sign-up flow as well.

I am not against this change, I just wanted to point out that this doesn't solve the actual problem, just partially mitigates it.
Other than adding a captcha or rate-limiting to the sign up flow, I am not sure anything else could be done there to prevent this on a scale.
And I know some security companies put these as issues on reports, but I don't think they should(and I am a pentester myself).
With this solution, a timing based attack is still possible as the non existing user will likely return a faster response.

When paranoid mode is enabled, registration attempts with existing
emails now:
- Show the same success message as new registrations
- Send an "already registered" email to the existing user
- Redirect to sign-in page instead of showing validation errors

This prevents attackers from discovering which emails have accounts
by attempting to register with them.

Changes:
- Add paranoid mode check to RegistrationsController#create
- Create new mailer method and template for "already registered" emails
- Update tests to verify the new secure behavior
- Fix test expectations for default non-enumerable password reset

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
@natevick natevick changed the title Fix email enumeration vulnerability in password reset Fix email enumeration vulnerabilities in password reset and registration flows Aug 14, 2025
Generate a dummy token when user not found to make timing analysis
harder. This ensures similar computational work is performed whether
the user exists or not, making it more difficult to determine email
existence through response time analysis.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
@gregmolnar
Copy link
Contributor

I have an idea to resolve this during sign up, but I think that would be too big of a change in devise.
The flow would be something like this:
Sign up page with an email field only and once it is submitted, the feedback would be "We will email you a link to sign up" even for existing accounts. For existing accounts the app can decide to send an email asking them to log in or reset their password as they already have an account, for non-existing ones you would send a link to a sign up form with a short-lived token that's verified.

@natevick
Copy link
Author

I like that flow. Could it be behind the paranoid flag? I did something like that on my commits, but changed it in the paranoid flag.

if recoverable.persisted?
recoverable.send_reset_password_instructions
else
# Perform similar work to mitigate timing attacks
Copy link

@chaadow chaadow Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i like the idea and i was about to do the same. But to properly simulate, we need to also simulate sending an email

I had the same idea as @gregmolnar in his comment about the sign up strategy. Basically here is the flow:

  • I take whatever params[:email] the user sends.
  • then I don't even check for its existence, I just do a MaybeSendResetPasswordInstructions.perform_later(params[:email])
  • And I redirect the user with a basic notice message "if your email exists in our database you ll get an email"

then obviously my job delegates to devise's Recoveral send_reset_password_instructions API.

The issue here is that the email sending is done in a synchronous way, so it's either a POST request to an email provider such as Sendgrid, or an SMTP call ( basically a network call )

So here i dont know maybe do sleep 0.5 or something. Or add a config in Recoverable which is config.send_emails_in_background or similar. if it's true, then we queue a job, if not we sleep for a fixed amount to simulate a network call

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know what you think, but this timing issue was reported by a pentester as well, and i was shocked about the difference between 40ms when the user does not exist, and 600ms to 1000ms when it exists.

I directly remembered that Devise NEVER sends their email in a background job ( I believe there is an open issue about that )

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably this could help if it gets merged soon: #5610

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm open to making changes, my biggest concern is that the work will never get merged.

# Perform similar work to mitigate timing attacks
# Generate a token even though we won't use it
Devise.friendly_token(20)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think what's missing is to simulate up to 50ms for an UPDATE statement that is done here:
https://github.com/heartcombo/devise/pull/5790/changes#diff-32eda6752ef9bcf49e99b0bc6be379576f4ba872944770a3ef8c66abd2d84b0bR92-R94

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants