Rails Fraud Detection Without a Ruby SDK

No Ruby SDK, and for a one-endpoint API that is fine. A Net::HTTP client, a controller concern, per-action strictness, and a fail-open path.

Railway tracks running to the horizon across a snowy prairie at dusk
Photo: CPR line at dusk near Tompkins, Canada by Kabelleger / David Gubler, CC BY-SA 4.0, cropped. Via Wikimedia Commons.
On this page
  1. Step 1: Add the script to the page with your form
  2. Step 2: Send the check from your server
  3. The controller concern
  4. Per-action strictness
  5. Step 3: Submit your form once
  6. Do not do this in a background job
  7. Testing
  8. The short version

There is no Ruby SDK. For an API with one endpoint that matters, that is less of an obstacle than it sounds: the wrapper is a few dozen lines of Net::HTTP, and writing it yourself means the timeout and the failure mode are decisions you made rather than defaults you inherited.

What follows is the shape we would actually deploy in a Rails app: the form markup, a client object, a controller concern, and different strictness per action. It follows the three steps the Maskbreak console lists (add the script to the page with your form, send the check from your server, submit your form once) and starts in watch mode, where every check is logged and nothing is refused.

Step 1: Add the script to the page with your form

Load the script in your layout and put class="monocle-enriched" on the form itself:

erb
<%# app/views/layouts/application.html.erb, in <head> %>
<script async src="https://maskbreak.com/assets/sentinel.js"></script>

<%# app/views/registrations/new.html.erb %>
<%= form_with url: registrations_path, class: "monocle-enriched" do |f| %>
  <%= f.email_field :email, required: true %>
  <%= f.password_field :password, required: true %>
  <%= f.submit "Create account" %>
<% end %>

The script adds three hidden fields to the form: monocle (the network token), sentinel_fp (the browser check id) and sentinel_tz (the time zone). They arrive as ordinary top-level params. With Turbo Drive, a page reached by a Turbo visit is swapped in without a full page load, so the script never fills the form on it and the post arrives without monocle. Make the page with the form load fully: put <meta name="turbo-visit-control" content="reload"> in that page's <head>, or data-turbo="false" on the links that lead to it.

If you set a Content Security Policy in config/initializers/content_security_policy.rb, add https://maskbreak.com, https://mcl.maskbreak.com, https://*.mcl.maskbreak.com, https://mesu.maskbreak.com and the string "'wasm-unsafe-eval'" to script_src, the mcl hosts (https and wss) and mesu to connect_src, and https://mesu.maskbreak.com plus :blob to worker_src.

Step 2: Send the check from your server

One class, one method, standard library only. It sends whatever evidence arrived, including none: a check without a token or a browser check id is how the console tells you which half is missing. It never raises.

ruby
# app/services/maskbreak.rb
require "net/http"
require "json"

class Maskbreak
  ENDPOINT = URI("https://maskbreak.com/v1/evaluate").freeze
  MODE = ENV.fetch("MASKBREAK_MODE", "watch") # "watch" or "enforce"

  def initialize(api_key: ENV.fetch("MASKBREAK_API_KEY", ""), timeout: 3)
    @api_key = api_key
    @timeout = timeout
  end

  # Returns the verdict (a Hash), or nil when there is none. Never raises.
  def check(params, email: nil, account_id: nil)
    body = {
      token: params[:monocle].presence || params[:token].presence || "",
      fingerprintEventId: params[:sentinel_fp].presence || params[:fingerprintEventId].presence,
      tz: params[:sentinel_tz].presence || params[:tz].presence,
      email: email,
      accountId: account_id
    }.compact

    req = Net::HTTP::Post.new(ENDPOINT)
    req["Authorization"] = "Bearer #{@api_key}"
    req["Content-Type"]  = "application/json"
    req.body = JSON.generate(body)

    res = Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, use_ssl: true,
                          open_timeout: @timeout, read_timeout: @timeout) { |http| http.request(req) }
    result = res.is_a?(Net::HTTPSuccess) ? JSON.parse(res.body) : nil
    Rails.logger.warn("maskbreak HTTP #{res.code}") unless result
    result
  rescue StandardError => e   # timeouts, refused connections, TLS errors, a body that is not JSON
    Rails.logger.warn("maskbreak unavailable: #{e.class}")
    nil
  ensure
    # One line per check. Never log the key, the token or form data.
    Rails.logger.info("maskbreak mode=#{MODE} decision=#{result&.dig('decision').inspect} " \
                      "reasons=#{result&.dig('reasons').inspect} " \
                      "device_evidence=#{result&.dig('device_evidence').inspect} " \
                      "degraded=#{result&.dig('degraded').inspect}")
  end

  # A complete, production allow: what a strict action needs in enforce mode.
  def self.clean_allow?(v)
    v.present? && v["decision"] == "allow" && v["device_evidence"] == "ok" &&
      !v["degraded"] && !v["test"]
  end
end

Both timeouts are set explicitly: Ruby's defaults are measured in tens of seconds, which is not a number you want in front of a checkout button. StandardError covers Net::OpenTimeout and Net::ReadTimeout along with every other way the call can fail, so a Maskbreak problem never becomes an error page of yours. Put your live key in MASKBREAK_API_KEY (Rails credentials or your host's secrets), never in the repository.

Try it

Wire it into your own app: a free key returns decision, risk_score and reasons for every visit, 10,000 checks a month on the Free plan, no card.

Get an API key

The controller concern

Rails already has the right shape for this: a concern with a before_action, included where it is needed and nowhere else. In watch mode it records the verdict and never renders anything.

ruby
# app/controllers/concerns/fraud_screened.rb
module FraudScreened
  extend ActiveSupport::Concern

  included do
    attr_reader :verdict
  end

  class_methods do
    # screen_with strict: true  → enforce mode continues only on a clean allow
    # screen_with               → enforce mode refuses only a block
    def screen_with(strict: false, **options)
      before_action(**options) { screen!(strict: strict) }
    end
  end

  private

  def screen!(strict:)
    email = params[:email].is_a?(String) ? params[:email] : nil
    @verdict = Maskbreak.new.check(params, email: email, account_id: current_user&.id&.to_s)
    return unless Maskbreak::MODE == "enforce"

    if @verdict&.dig("decision") == "block"
      render json: { error: "Request declined" }, status: :forbidden
    elsif strict && !Maskbreak.clean_allow?(@verdict)
      render json: { error: "Additional verification required" }, status: :conflict
    end
  end
end

Per-action strictness

Now the policy is one readable line per controller, and it lives next to the actions it governs.

ruby
class WithdrawalsController < ApplicationController
  include FraudScreened
  screen_with strict: true, only: %i[create]   # money leaving: nothing but a clean allow
end

class RegistrationsController < ApplicationController
  include FraudScreened
  screen_with only: %i[create]                 # signup: blocks only
end

class CheckoutsController < ApplicationController
  include FraudScreened
  screen_with only: %i[create]

  def create
    return step_up! if Maskbreak::MODE == "enforce" && verdict&.dig("decision") == "review"
    charge!
  end
end

The reason signup is not strict: a plain VPN returns review, and VPN users are customers. Refusing them at registration is a cost you never see, because the person who could not sign up does not file a support ticket. The hard signals (proxy, Tor, automation, emulator, browser tampering) are the ones that return block.

Step 3: Submit your form once

Run the app on your computer or deploy it, open the page in an ordinary browser, wait a moment, and submit the form. The first check then appears in the Maskbreak console (if half of it did not arrive, the console says which half), and your log shows one maskbreak line. degraded=true means no network token arrived (the script was blocked, the class is missing, or a Turbo visit swapped the page in); device_evidence="not_sent" means no browser check id arrived; "ok" with degraded not true is a complete check. When the lines look right after a few days, set MASKBREAK_MODE=enforce and redeploy.

Do not do this in a background job

The temptation in Rails is to push the network call into Sidekiq and keep the request fast. It does not work for a gate: by the time the job runs, the account exists, the order is placed, or the withdrawal is queued. The verdict has to be in the request path if it is going to change what happens.

What does belong in a job is everything after the decision: writing the verdict to your own analytics table, updating a risk queue, sending the alert. Decide inline, record asynchronously. Watch mode is the honest way to measure the latency first: the call is inline, and nothing is acted on until you switch.

Testing

The public sandbox key returns fixed answers for the test tokens (test_clean allow, test_vpn review, test_proxy and test_tor block), each marked test: true. Request specs then cover the block path:

ruby
# spec/requests/withdrawals_spec.rb
before do
  stub_const("Maskbreak::MODE", "enforce")
  allow(ENV).to receive(:fetch).and_call_original
  allow(ENV).to receive(:fetch).with("MASKBREAK_API_KEY", "").and_return("sk_test_sandbox")
end

it "refuses a withdrawal from a proxy" do
  post "/withdrawals", params: { amount: 100, monocle: "test_proxy" }
  expect(response).to have_http_status(:forbidden)
end

it "holds the withdrawal when the vendor is down" do
  allow_any_instance_of(Maskbreak).to receive(:check).and_return(nil)
  post "/withdrawals", params: { amount: 100 }
  expect(response).to have_http_status(:conflict)
end

That second test documents your actual policy for money leaving: no verdict, no payout without a human. On signup the same outage lets the request through. Write both down as assertions so nobody quietly changes them in six months.

The short version

  • Script in the layout, class: "monocle-enriched" on the form, three hidden fields forwarded as token, fingerprintEventId and tz.
  • No gem needed. A few dozen lines of Net::HTTP, both timeouts set explicitly, every error rescued.
  • Call every time, even without a token, and log one line. Watch mode never refuses.
  • A concern plus before_action keeps the policy readable and per-controller.
  • In enforce mode: strict on money, blocks only on signup, review to a step-up rather than a refusal.
  • Decide inline; write the audit trail from a job.

Questions people ask

Is there a Ruby SDK for Maskbreak?
No. The official SDKs are Node, Python and PHP. For Ruby the integration is a small Net::HTTP wrapper — about sixty lines including error mapping — which is enough for an API with one endpoint that matters, and it leaves the timeout and failure policy in your own code.
Should the fraud check run in a Sidekiq job?
Not as a gate. By the time a background job runs, the account already exists or the order is already placed, so the verdict cannot change the outcome. Make the call inline with a short timeout, and use a job for what comes after the decision: analytics writes, risk queues, alerts.
Why does my log show degraded=true on every check?
The network token is not reaching your server. Check, in order: the script tag is on the page with the form; class "monocle-enriched" sits on the form element itself, not on an input; a Content-Security-Policy is not blocking maskbreak.com or its hosts (the browser console shows CSP blocks); and the page was not reached by a Turbo visit, which swaps the page in without the token.
What is the right timeout for a fraud check in Rails?
Short: the guide uses three seconds on both open_timeout and read_timeout. Ruby's defaults are measured in tens of seconds, which is long enough for a vendor slowdown to become an outage of your own checkout. Pair the short timeout with a rescue that lets the request continue.

Paste it in, then watch the verdicts

The public sk_test_sandbox key returns the documented allow, review and block shapes with no account, so the failure path is testable before you go live. SDKs for Node, Python and PHP, or plain HTTP. The API reference and the Free plan cover the rest.

Get started freeRead the API docs