emailverifier.dev
Posts

Block Disposable Email Signups in Rails With Devise

| 4 min read | Usama Ejaz
Rails Devise signup cover with a database boundary stopping one disposable envelope.

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.

Continue reading