Limit Spam Sign Ups in Rails Apps

If you launch a Rails app to the world, sooner than later, you will see them: spam sign ups (along with requests to typical WordPress URLS). The reasons bots sign up vary from: exploiting free trials and referral programs, (competitive) data scraping or testing stolen credit cards.

Your first sign ups will most likely not be potential customers, but bots. šŸ„² Sorry, thatā€™s how it is. But it is easy, at least for the first years of your business, to fight them with these techniques.

This article builds on top of this one: Add Sign Up to Rails 8ā€™ Authentication.

Letā€™s look at the Signup class from that article:

# app/models/signup.rb
class Signup
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :email_address, :string
  attribute :password, :string

  validates :email_address, presence: true
  validates :password, length: 8..128

  def save
    if valid?
      User.create!(email_address: email_address, password: password).tap do |user|
        create_workspace_for user
        send_welcome_email_to user
      end
    end
  end

  # ā€¦
end

I like classes (form objects) to ā€œquackā€ like Rails, so letā€™s extend it with a custom validator:

# app/models/signup.rb
class Signup
  # ā€¦

  validates :email_address, presence: true
  validates :password, length: 8..128

  validates :email_address, is_not_spam: true

  def save
  # ā€¦
end

This is_not_spam validator can then be defined like so:

# app/validators/is_not_spam_validator.rb
class IsNotSpamValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, email_address)
    # ā€¦
  end
end

There are two common types of emails bots use:

Letā€™s add a validation for disposable email providers first:

# app/validators/is_not_spam_validator.rb
class IsNotSpamValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, email_address)
    return if email_address.blank?

    if spam? email_address
      record.errors.add(attribute, options[:message] || "appears suspicious")
    end
  end

  private

  def spam?(email_address)
    local_part, domain = email_address.split("@", 2)

    disposable?(domain)
  end

  def disposable?(domain) = disposable_email_providers.include?(domain)

  def disposable_email_providers = File.read("config/disposable_email_providers.txt").split("\n")
end

I think this is all really good to follow, right? Whatā€™s in config/disposable_email_providers.txt? A list of disposable domains courtesy of this repo. Copy it and paste it in the text file (or create a little script that does it for you).

Next is the issue with Gmail treating johnsmith@gmail.com and j.o.h.n.s.m.i.t.h@gmail.com the same. Extend the spam? method:

# app/validators/is_not_spam_validator.rb
class IsNotSpamValidator < ActiveModel::EachValidator
  private

  MAXIMUM_DOTS = 3

  def spam?(email_address)
    local_part, domain = email_address.split("@", 2)

    disposable?(domain) || excessive_dots_in?(local_part)
  end

  def excessive_dots_in?(local_part) = local_part.count(".") >= MAXIMUM_DOTS
end

This is not specifically checking for excessive dots Ɣnd Gmail, but normally more than 3 dots is not common for any email domain.

With these two checks in place, you are already in a good place to fight spam. But there is one more, easy-to-add, piece I add from the start. A honeypot.

For this honeypot, I always use the invisible_captha gem. This is how I use it:

  1. bundle add invisible_captha
  2. in SignupsController: invisible_captcha only: %w[create], timestamp_enabled: false
  3. in app/views/signups/new.html.erb: <%= invisible_captcha %>

And done. Check out the invisible_captcha repo for more details and documentation.

With these three checks, you surely will see almost no automated sign ups in your app. At least in the early stages of your app. šŸ˜¬

Is there something you add to fight spam or anything I missed? Let me know.

Published at . Have suggestions or improvements on this content? Do reach out.