This guide uses Ruby's own Net::HTTP, with no gem: one connection per thread, kept open between calls, so a call costs one round trip and not a new TLS handshake.
The tag
In the application layout:
<%# app/views/layouts/application.html.erb, in <head> %> <script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
The call
A module in app/lib, which Rails loads by itself. Each timeout bounds one step (connecting, writing, reading), so a call that is slow at every step can take a little longer than the timeout:
# app/lib/korzen.rb require "net/http" require "json" module Korzen URL = URI("https://korzen.dev/v1/decide") TIMEOUT = 0.2 # The headers Bot Protection reads: those one browser build sends alike, and Accept-Language. FORWARDED = %w[accept accept-encoding accept-language sec-ch-ua sec-ch-ua-mobile sec-ch-ua-platform sec-fetch-dest sec-fetch-mode sec-fetch-site upgrade-insecure-requests].freeze # The answer for this request, or nil on any error: then go on as on allow (fail open). def self.decide(request, path) body = { ip: request.remote_ip, user_agent: request.user_agent, headers: FORWARDED.filter_map { |h| [h, request.headers[h]] if request.headers[h] }.to_h, header_order: header_order(request), token: request.cookies["_kz"], path: path, method: "POST" } res = http.post(URL.path, body.to_json, "Authorization" => "Bearer #{ENV.fetch("KORZEN_API_KEY")}", "Content-Type" => "application/json") res.is_a?(Net::HTTPOK) ? JSON.parse(res.body) : nil rescue StandardError Thread.current[:korzen]&.finish rescue nil Thread.current[:korzen] = nil nil end # The order the request's headers came in, as Puma keeps it in the Rack env: at most 64 # names, lower-cased, "_" as "-". def self.header_order(request) request.env.keys.grep(/\AHTTP_|\ACONTENT_(TYPE|LENGTH)\z/).first(64).map { |k| k.delete_prefix("HTTP_").downcase.tr("_", "-") } end # One connection per thread, kept open between calls. def self.http Thread.current[:korzen] ||= Net::HTTP.start(URL.host, URL.port, use_ssl: true, keep_alive_timeout: 30, open_timeout: TIMEOUT, read_timeout: TIMEOUT, write_timeout: TIMEOUT) end end
header_order is the order the request's headers came in: a script that copies a browser's values seldom copies their order. Puma keeps it in the Rack env's HTTP_* keys, and moves a name with _ last. Behind nginx, a load balancer or Cloudflare, it is the order that hop forwards them in, not the browser's.
Then, in the controller that creates the account. With Devise, put the same lines in your own Users::RegistrationsController#create before super:
# app/controllers/registrations_controller.rb class RegistrationsController < ApplicationController def create decision = Korzen.decide(request, "/signup") if decision&.dig("action") == "block" flash.now[:alert] = "We could not create your account." return render :new, status: :unprocessable_entity end # "challenge": your CAPTCHA, or an email or phone check, before going on @user = User.create!(user_params.merge(korzen_request_id: decision&.dig("request_id"))) redirect_to welcome_path end end
The visitor's IP
request.remote_ip takes the visitor's IP from X-Forwarded-For, skipping the private addresses Rails trusts as proxies (10.0.0.0/8, 192.168.0.0/16 and the like). A load balancer on a private network needs nothing more. Behind Cloudflare, whose addresses are public, add Cloudflare's ranges to config.action_dispatch.trusted_proxies, or send request.headers["CF-Connecting-IP"] if your server only takes traffic from Cloudflare.
Apply the action
Read only action. On block, answer with the same error the action shows when it fails for any other reason, so a bot learns nothing. On challenge, use the check you already have (a CAPTCHA, an email or phone code); with none, go on. The account starts in monitor mode, where action is always allow: nothing changes for visitors until you turn on block mode.
Keep the request_id
Every answer has a request_id. Keep it with what the action created, in a column of the account or the order, or in a log line with their id. It names that decision when you ask us about it, and lets you find out later which answer let a fake account in.
Test it on your machine
Run bin/rails server and open http://localhost:3000. Pages on localhost always accept the tag and are never listed as a domain. Locally the IP is 127.0.0.1 or ::1, and that is fine. In Docker on your machine, the app sees the bridge's address instead, such as 172.17.0.1: the challenge page still brings you back to localhost, since the tag saw your page there.
- Set
KORZEN_API_KEYin the environment (or your.env, with dotenv) and open the sign-up page. In the browser's developer tools, the_kzcookie appears after a few seconds. - Sign up. The decision shows on the dashboard's Decisions page, under the path you sent.
- Check fail open: set a wrong key and sign up again. The call answers
401, and the sign-up goes through.
Common mistakes
request.ipinstead ofrequest.remote_ip. It does not skip the proxies.- A new
Net::HTTP.startper call. Each opens a TLS connection to Germany: one or two more round trips. - Behind Cloudflare without its ranges in
trusted_proxies. Every visitor then has a Cloudflare address. - Calling
/v1/decidefrom the browser. The API key would reach every visitor; the call belongs on your server. - No timeout, or an error that stops the sign-up. Any answer other than
200, and no answer in time, means: let the visitor through.