Invisible honeypot spam protection for Hanami, Sinatra, Roda, Rails, and any other Rack-based Ruby app. Think of it as a framework-agnostic invisible_captcha with a first-class Hanami adapter and an extra layer of JavaScript-based input detection.
Otori (囮) is the Japanese word for "decoy", historically a bird used in hunting to draw others in.
This is a Ruby companion to the Crystal lucky_honeypot shard. It combines three classic techniques into one gem:
- Invisible fields. Bots fill out every field, including ones hidden with CSS.
- Timing checks. Bots submit forms instantly, humans need more time.
- Input signals. Bots don't tend to trigger mouse, touch, scroll, keyboard, or focus events.
When either of the first two checks fail, the submission is quietly rejected. The bot thinks it succeeded and moves on. The third one can be used to reject or flag submissions at a chosen human rating threshold.
Note
The original repository is hosted at Codeberg. The GitHub repo is just a mirror.
- Frameworks: Hanami, Sinatra, Roda, Rails, any Rack app
- Hidden field + timing check + JS input signals
- Active (new in 2026)
- Frameworks: Rails only
- Hidden field + timing check
- Actively maintained
- Frameworks: any Rack app
- Hidden field only
- Dormant since 2016
If you're on Rails and don't need the JS signals layer, invisible_captcha is
the more battle-tested choice. Otori exists for everything else.
Add this to your Gemfile:
gem "otori"Then run bundle install. Ruby 3.2 or newer is required.
The gem ships a framework-agnostic core plus an opt-in Hanami adapter. The core API is three calls:
Otori.field("user[website]", session: request.session)
Otori.signals_field
Otori.caught?("user[website]", params: request.params, session: request.session)field renders the invisible input and stores a load timestamp in the
session. signals_field renders a hidden input plus the JavaScript that
tracks human input. caught? checks the submitted form and returns true
when the request looks like a bot.
Field names use standard HTML bracket notation, so "user[website]" lives
under params[:user][:website] once submitted. Flat names like "note" work
too. Pick whichever fits the surrounding form, the more believable the
honeypot looks next to the real fields, the better.
The Hanami adapter is loaded explicitly so the base gem stays dependency-free:
require "otori/hanami"Mix the helpers into your views:
# app/views/helpers.rb
module MyApp
module Views
module Helpers
include Otori::Hanami::Helpers
end
end
endIn a form template:
<%= honeypot_field("user[website]") %>
<%= honeypot_signals %>The helpers mark their output safe via String#html_safe, which Hanami View
provides out of the box, so the HTML flows through ERB without escaping.
Guard the receiving action with the honeypot DSL method:
# app/actions/sign_ups/create.rb
module MyApp
module Actions
module SignUps
class Create < MyApp::Action
include Otori::Hanami::Action
honeypot "user[website]"
def handle(request, response)
# ...
end
end
end
end
endWhen the honeypot is tripped, the action halts with 204 No Content by
default. To customize the response, pass a block:
honeypot "user[website]" do |_request, response|
response.flash[:info] = "Moving on..."
response.redirect_to "/", status: 303
endMultiple honeypots are supported, each with its own timing and handler:
honeypot "user[website]", wait: 5
honeypot "note" do |_req, _res|
halt 422
endTo act on the input-signals rating, evaluate it inside handle:
def handle(request, response)
rating = Otori.signals_rating(request.params.to_h)
halt 204 if rating < 0.4
# ...
endThere is no adapter to require, the core API is enough. In a Sinatra app:
require "otori"
enable :sessions
get "/sign_up" do
erb :sign_up
end
post "/sign_up" do
halt 204 if Otori.caught?(
"user[website]",
params: params,
session: session
)
# ...
endIn the view:
<%= Otori.field("user[website]", session: session) %>
<%= Otori.signals_field %>Rails is well served by invisible_captcha when all you need is a hidden field and a timing check. Use this gem in Rails if you want the input-signals rating on top.
# app/helpers/application_helper.rb
module ApplicationHelper
def honeypot_field(name, **attrs)
Otori.field(name, session: session, **attrs).html_safe
end
def honeypot_signals(**attrs)
Otori.signals_field(**attrs).html_safe
end
end# app/controllers/sign_ups_controller.rb
class SignUpsController < ApplicationController
before_action :check_honeypot, only: :create
def create
# ...
end
private
def check_honeypot
return unless Otori.caught?(
"user[website]",
params: params.to_unsafe_h,
session: session
)
head :no_content
end
endOtori.configure do |c|
# Required delay (in seconds) between page load and form submission.
c.default_delay = 2.0
# Disables the submission delay entirely. Useful in tests.
c.disable_delay = false
# Name of the hidden input that carries the signals payload.
c.signals_input_name = "honeypot_signals"
endBy default the field is rendered with an inline style attribute that takes
it out of the visual flow without breaking accessibility tools. Pass your own
class (or style) to opt out of the default style and use your CSS instead:
Otori.field("user[website]", session: session, class: "visually-hidden")Underscored attribute keys are converted to dashes so data_foo: "bar"
renders as data-foo="bar". Any other attribute pair is passed through
unchanged.
Note
The field stores a load timestamp in the session under a
honeypot_field_<name> key. The companion caught? call reads and
clears it on success, or resets it when the form is rejected.
signals_field renders a hidden input plus a small inline <script> that
listens for the first occurrence of each of five events on the surrounding
form: mousemove, touchstart, keydown, focusin, and a window-level
scroll. On submit, the boolean results are serialized to JSON and posted
along with the rest of the form.
In the action, get the rating directly from the params:
Otori.signals_rating(params) # => 0.0 to 1.0Or work with the parsed object for more detail:
signals = Otori::Signals.from_json(params["honeypot_signals"])
signals.human_rating # 0 (bot) to 1 (human)
signals.mouse?
signals.touch?
signals.scroll?
signals.keyboard?
signals.focus?Note
The human rating is the fraction of the five signals that fired, so each one
contributes 0.2. A score of 0 is almost certainly a dumb bot, while 0.2
could be a sophisticated bot triggering a single signal (almost always
mouse), though a human filling out a short form at the top of the page may
also land there.
0.4 is a reasonable threshold for flagging entries: it still catches bots
that fake one or two signals, but avoids false positives for autofill and
password manager submissions, which often only trigger focus plus mouse or
touch.
This gem provides basic bot protection, but it should not be your only line of defense.
- It is not foolproof, sophisticated bots can bypass honeypots.
- Combine this with a rate limiter such as
rack-attack. - For high-value forms, consider adding CAPTCHA or email verification.
- The submission timestamp is stored in the session. If sessions are compromised, an attacker could manipulate timing checks. Make sure your session store uses signed and encrypted cookies.
- The timing check compares wall-clock timestamps, which makes it resilient to timing attacks since the check is a simple threshold comparison.
- This gem does not touch CSRF tokens. Honeypot fields are regular form inputs and do not interfere with your framework's CSRF protection.
For most use cases (contact forms, newsletter signups), this gem provides solid protection with zero user friction. Expect it to catch between 60% and 90% of automated form submissions.
bundle install
bundle exec rspec
bundle exec rubocopMIT