Block Disposable Email Signups in Rails With Devise
Devise should not create a user and only afterward find that the signup used a disposable address. Put the verification call in a custom registrations controller, before super reaches Devise’s persistence path.
POST /users
-> custom RegistrationsController
-> one email-verification request
-> block: add an email error and stop
-> review or allow: hand control back to Devise
-> API failure: return 503 and create nothing
This keeps network I/O out of the User model. Model validations may run more than once, which can otherwise produce repeated API calls and repeated credit use.
Generate the Devise controller
Starting from a Rails application that already uses Devise:
bin/rails generate devise:controllers users -c registrations
Point registration routes to the generated controller in config/routes.rb:
Rails.application.routes.draw do
devise_for :users, controllers: {
registrations: "users/registrations"
}
end
The current Devise documentation supports scoped controller generation and the controllers route option. If uncertain addresses must confirm ownership before signing in, keep the Devise :confirmable module enabled on User.
Store the project key in Rails credentials
Create an emailverifier.dev account and project, then copy the API key. Open the encrypted credentials editor:
bin/rails credentials:edit
Add:
emailverifier_api_key: your-project-key
Rails encrypts config/credentials.yml.enc. Keep config/master.key or RAILS_MASTER_KEY outside version control, as required by the current Rails security guide.
Create the one-request client
Create app/services/email_verifier_client.rb:
require "json"
require "net/http"
class EmailVerifierClient
class Error < StandardError; end
ENDPOINT = URI("https://emailverifier.dev/api/v1/verify")
def initialize(api_key: Rails.application.credentials.emailverifier_api_key!)
@api_key = api_key
end
def verify(email)
request = Net::HTTP::Post.new(ENDPOINT)
request["Content-Type"] = "application/json"
request["X-API-Key"] = @api_key
request.body = JSON.generate(email: email)
response = Net::HTTP.start(
ENDPOINT.host,
ENDPOINT.port,
use_ssl: true,
open_timeout: 4,
read_timeout: 4
) { |http| http.request(request) }
raise Error, "verification request failed" unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)
rescue JSON::ParserError, Timeout::Error, SocketError, SystemCallError => error
raise Error, error.message
end
end
verify sends one Net::HTTP::Post and does not retry automatically. The four-second connection and read timeouts prevent the registration request from waiting indefinitely.
Stop blocked addresses before Devise persists them
Replace app/controllers/users/registrations_controller.rb with:
class Users::RegistrationsController < Devise::RegistrationsController
def create
email = sign_up_params[:email].to_s.strip.downcase
report = EmailVerifierClient.new.verify(email)
if report.fetch("action") == "block"
self.resource = resource_class.new(sign_up_params)
resource.errors.add(
:email,
report["suggestion"].present? \
? "may contain a spelling mistake" \
: "cannot be used for this signup"
)
clean_up_passwords(resource)
set_minimum_password_length
respond_with(resource)
return
end
if report.fetch("action") == "review"
flash.now[:notice] = "Confirm your email before signing in."
end
# Devise builds and persists the user only after verification reaches here.
super
rescue EmailVerifierClient::Error, KeyError
render plain: "Email verification is temporarily unavailable.",
status: :service_unavailable
end
end
Both review and allow continue to Devise. With :confirmable, an uncertain address can be created in an unconfirmed state rather than rejected. A disposable or confirmed invalid result stays on the form with an email error.
Prove that a blocked signup creates no user
Create test/controllers/users/registrations_controller_test.rb:
require "test_helper"
class Users::RegistrationsControllerTest < ActionDispatch::IntegrationTest
test "blocked verification result does not create a user" do
fake = Object.new
calls = 0
fake.define_singleton_method(:verify) do |_email|
calls += 1
{
"status" => "risky",
"action" => "block",
"signals" => ["disposable_address"],
"suggestion" => nil
}
end
EmailVerifierClient.stub(:new, fake) do
assert_no_difference("User.count") do
post user_registration_path, params: {
user: {
email: "person@example.com",
password: "a-secure-test-password",
password_confirmation: "a-secure-test-password"
}
}
end
end
assert_equal 1, calls
assert_response :unprocessable_content
end
end
Run bin/rails test test/controllers/users/registrations_controller_test.rb. The assertion covers the two important facts: no user row is written and exactly one verification call occurs.
Read the possible results correctly
The verification API returns deliverable, risky, undeliverable, or unknown as its technical status. Its application recommendation is allow, review, or block.
HTTP failures remain a separate layer. Invalid request data can return 400; a missing or invalid key can return 401; exhausted project credits can return 402; rate limiting can return 429. The controller converts dependency failures to 503 so they cannot masquerade as an email decision.
Do not add the same remote call as a User validation after installing this controller. One registration boundary is enough. For the policy behind disposable, catch-all, and role-address signals, use the disposable signup policy and role-address guide.