Skip to content
Finowl

Ruby · Email breach checks

Check email breaches in Ruby with net/http

A breach lookup in Ruby is one POST to /v1/breaches/email followed by a walk over the breaches array. Each entry is a hash with string keys, so you read breach["Title"], breach["BreachDate"] and breach["DataClasses"] straight from the parsed JSON. The script prints every breach for an address, marks entries whose IsVerified flag is false, and lists the data classes that leaked, which is what you need to decide whether to prompt a password reset.

Error handling lives in a case statement on the response class. Net::HTTPBadRequest means the address failed Finowl's validation, Net::HTTPUnauthorized points at the API key, and Net::HTTPTooManyRequests either sleeps for the Retry-After seconds or stops when the error code is quota_exceeded. In a Rails app, move the method into a service object and call it from Active Job so signups never wait on the network.

Steps

  1. 1

    Create an API key in the Finowl dashboard at https://app.finowl.co and export it as FINOWL_API_KEY.

  2. 2

    Save the script as email_breaches.rb; it only requires net/http and json from the standard library.

  3. 3

    Run ruby email_breaches.rb someone@example.com to check the address passed as the first argument.

  4. 4

    Inspect DataClasses on each breach to decide whether the user needs a password reset or just a notice.

  5. 5

    Move email_breaches into a service object or job class once the output looks right.

Check email breaches in Ruby with net/http
require "json"
require "net/http"

ENDPOINT = URI("https://api.finowl.co/v1/breaches/email")

def email_breaches(email, attempts: 3)
  attempts.times do
    response = Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, use_ssl: true,
                               open_timeout: 5, read_timeout: 15) do |http|
      request = Net::HTTP::Post.new(ENDPOINT)
      request["Authorization"] = "Bearer #{ENV.fetch("FINOWL_API_KEY")}"
      request["Content-Type"] = "application/json"
      request.body = JSON.generate(email: email)
      http.request(request)
    end

    case response
    when Net::HTTPSuccess
      return JSON.parse(response.body)
    when Net::HTTPBadRequest
      raise ArgumentError, "Finowl rejected #{email.inspect} as invalid_input"
    when Net::HTTPUnauthorized
      raise "FINOWL_API_KEY is missing or invalid"
    when Net::HTTPTooManyRequests
      code = JSON.parse(response.body).dig("error", "code")
      raise "Finowl credits are used up" if code == "quota_exceeded"

      sleep [response["Retry-After"].to_i, 1].max
    else
      raise "Finowl returned HTTP #{response.code}; not retrying"
    end
  end
  raise "Still rate limited after #{attempts} attempts"
end

result = email_breaches(ARGV.fetch(0, "alex@example.com"))

if result["breached"]
  puts "#{result["email"]} appears in #{result["breaches"].size} breach(es):"
  result["breaches"].each do |breach|
    note = breach["IsVerified"] ? "" : " (unverified)"
    puts "- #{breach["Title"]}, #{breach["BreachDate"]}#{note}"
    puts "  exposed: #{breach["DataClasses"].join(", ")}"
  end
else
  puts "No known breaches for #{result["email"]}"
end

IN PRODUCTION

Production tips

  • Normalize addresses with strip and downcase before the lookup, and cache each result for a while so repeated logins from the same person do not spend a fresh credit every time.

  • Treat entries with IsVerified set to false as weaker signals. Mention them to users with softer wording, or leave them out of automated actions such as forced password resets.

  • Send email lookups from Sidekiq or Active Job rather than the request cycle. A job can sleep on Retry-After without holding a Puma thread, and slow responses never turn into slow signups.

Each lookup costs 1 credit, with 100 free credits every month. See pricing and error codes.

A FEW GOOD QUESTIONS

Common questions

Check email breaches in Ruby with net/http

Grab an API key, paste the example and run it. 100 free credits every month.