Spamtrap is a Rails gem that protects forms from spambots through a set of complementary, individually opt-in mechanisms:
-
Honeypot fields — hidden textarea fields that real users never touch. Bots that fill them in are silently discarded with a
200 OKresponse. -
Nonce — an HMAC-SHA256 token binding each submission to a render timestamp, the client IP, and the honeypot name. It is a freshness check, not replay protection, unless you opt into
:single_usemode (see below). -
Field name mutation — form field names are AES-128-GCM encrypted on render, so bots cannot target fields by recognizable names like
emailorbody. The controller remaps them back transparently.mutate: true(and:strict) additionally rejects any submission containing a plaintext field name; usemutate: :lenientfor remap-only. -
Minimum fill time — submissions arriving less than a second after the form was rendered are rejected; scripted posters usually submit within milliseconds.
-
JavaScript proof of presence — an optional hidden field only an executing script fills in, for forms where excluding no-JS users is acceptable.
-
Content hook — an app-supplied check on the submitted params, given the same trap response, callback and instrumentation as the built-in checks. Each feature is opt-in and can be used independently or combined. None of them is a complete defense on its own — read the guarantees below before relying on one.
| Feature | Guarantees | Does not guarantee |
|---|---|---|
| Honeypot | Traps bots that auto-fill hidden fields | Traps a bot that knows to skip hidden fields |
Nonce (nonce: true) |
The form was rendered by this app, for this IP and honeypot, within nonce_timeout |
Replay protection — the same valid token is accepted repeatedly until it expires |
Nonce (nonce: :single_use) |
The above, plus: a given nonce is accepted once | Replay detection without a shared cache store; a NullStore accepts every write (see Testing) |
Mutation (mutate: true / :strict) |
Hides field names from scrapers of a given render, and rejects any submitted key that isn't a valid mutation token | Working without updating allowed_params for any custom, non-form params your app posts |
Mutation (mutate: :lenient) |
Hides field names from scrapers of a given render | Stopping a bot that already knows the real field names — plaintext names still pass through |
Minimum fill time (min_fill_time) |
Rejects a submission received less than min_fill_time seconds after a verified render, when nonce or mutate is on |
Anything on a honeypot-only form — there's no verified timestamp to check it against |
JS proof (js_proof: true) |
Traps a client that never executes JavaScript | Traps a headless browser, or a bot that parses the page's script and computes the value itself |
Content hook (suspicious_if:) |
Runs your own heuristic with the same trap response, callback, and instrumentation as the built-in checks | Any spam detection itself — that logic is entirely yours |
Requires Ruby 3.1 or newer and Rails 7.2 or newer. CI runs Ruby 3.4 and 4.0 against Rails 7.2 through 8.1.
Add the following to your Gemfile:
gem 'spamtrap'Spamtrap installs itself into ActionController::Base and ActionView::Helpers::FormBuilder
via Spamtrap::Railtie, from on_load hooks that fire after your app's own initializers run —
no explicit setup needed in a Rails app. ActionController::API subclasses get the spamtrap
macro the same way, so a JSON-only endpoint can use it too.
Then run the install generator to write an initializer documenting every configuration option at its default, commented out:
rails g spamtrap:install
This creates config/initializers/spamtrap.rb. Uncomment and change only the options you want
to override — see Configuration below for what each one does. Writing the
file yourself instead of running the generator works just as well; the generator just saves you
copying the option list out of this README.
If you're using actionpack/actionview without a full Rails application (no Rails::Railtie
loaded), require the gem and install it yourself once Action Controller and Action View are
loaded:
require 'spamtrap'
Spamtrap.install!Global defaults can be set in an initializer. Per-controller or per-action options always
take precedence, including an explicit false that overrides a global true. All of these
are read at request time, so changes take effect immediately without restarting the server.
# config/initializers/spamtrap.rb
Spamtrap.enabled = true # false skips every check (honeypot, nonce, mutation); for test environments
Spamtrap.honeypot_styles = [:textarea] # decoys f.spamtrap renders: any of :textarea, :text, :checkbox
Spamtrap.js_proof = false # require a hidden field only an executing script fills in
Spamtrap.nonce = false # false, true, or :single_use
Spamtrap.nonce_timeout = 1800 # seconds; also caps mutation token age unless mutation_timeout is set
Spamtrap.mutation_timeout = nil # defaults to nonce_timeout
Spamtrap.nonce_skew = 60 # seconds a submitted timestamp may sit in the future
Spamtrap.nonce_store = Rails.cache # where :single_use records seen nonce ids
Spamtrap.nonce_bind_ip = true # true (full IP, default), :prefix (/24 IPv4 or /48 IPv6), or false (not bound)
Spamtrap.min_fill_time = 1 # seconds; false or 0 disables; needs nonce or mutate for a trustworthy timestamp
Spamtrap.mutate = false # false, true/:strict (traps plaintext keys), or :lenient (remap only)
Spamtrap.allowed_params = [] # extra top-level param names a strict action accepts unencrypted
Spamtrap.filter_parameters = true # apply config.filter_parameters to mutated field names in the log
Spamtrap.trap_response = :head # see "Trap response, Turbo and remote forms" below
Spamtrap.on_trap = ->(reason:, request:) { ... } # optional trap callback
Spamtrap.suspicious_if = nil # optional content hook; see "Content hook" below
Spamtrap.secret_key_base = nil # defaults to Rails.application.secret_key_base
Spamtrap.previous_secret_key_base = nil # set during a rotation window; see "Rotating secret_key_base" below
Spamtrap.token_context = nil # binds tokens to a per-request value (e.g. host); see "Binding tokens to the host" belowThe same options can be set per controller action:
spamtrap :sarah_palin_walks_with_dinosaurs,
only: %i[create update],
nonce: true,
nonce_timeout: 60,
mutate: :strict,
trap_response: :unprocessable,
on_trap: ->(reason:, request:) { ... }An optional callback is invoked whenever the spamtrap fires — honeypot, nonce, or mutation. Use it for logging, metrics, rate-limiting, or any other custom behaviour.
Set a global callback in the initializer:
# config/initializers/spamtrap.rb
Spamtrap.on_trap = lambda do |reason:, request:, controller:, honeypot:, params:|
# reason is one of :honeypot, :nonce_missing, :nonce_expired, :nonce_invalid,
# :nonce_replayed, :plaintext_field, :mutation_expired, :too_fast, :no_js, :content
Rails.logger.warn "[Spamtrap] #{reason} trap fired from #{request.remote_ip} on #{request.path}"
StatsD.increment('spamtrap.triggered', tags: ["reason:#{reason}"])
endOr override per declaration (takes precedence over the global callback):
spamtrap :comment, only: :create, on_trap: ->(reason:, request:) {
Honeybadger.notify("Spamtrap fired", context: { reason: reason, ip: request.remote_ip })
}Only the keywords your callback actually declares are passed, so an existing
->(reason:, request:) { ... } callback keeps working unchanged. Available keywords:
reason:— see the list above.request:— theActionDispatch::Requestobject (IP, path, headers, etc.).controller:— the controller instance handling the request.honeypot:— the honeypot field name declared for this action.params:— the request params, already remapped to real field names when mutation is on.
Exceptions raised inside the callback are rescued and logged; a broken callback never
prevents the trap response from being sent. If the callback renders or redirects itself,
that response is used instead of trap_response.
A hidden textarea is added to the form with a deliberately enticing name. Real users never see
or interact with it — f.spamtrap renders it with tabindex="-1", autocomplete="off",
aria-hidden="true", and an inline style="display:none" by default, so no app CSS is
required to hide it. Spambots, which auto-fill all visible and hidden fields, will populate it.
When the form is submitted with a non-empty honeypot field, spamtrap discards the submission
with reason: :honeypot.
Declare the controller actions you want protected:
class CommentsController < ApplicationController
spamtrap :sarah_palin_walks_with_dinosaurs, only: %i[create update]
endAdd the honeypot field to your form using the f.spamtrap form builder helper:
<%= form_for @comment do |f| %>
<%= f.spamtrap :sarah_palin_walks_with_dinosaurs, class: 'reindeer_jerky' %>
<fieldset>
<%= f.label :body %>
<%= f.text_area :body %>
</fieldset>
<%= f.submit %>
<% end %>Any of the default attributes can be overridden by passing it explicitly — style: nil removes
the inline hide for apps that prefer their own CSS, and tabindex: nil restores the honeypot to
the tab order (e.g. to also catch keyboard-driven bots):
<%= f.spamtrap :sarah_palin_walks_with_dinosaurs, style: nil, class: 'reindeer_jerky' %>.reindeer_jerky { display: none; }Style the honeypot by class, as above, rather than an id selector: when mutation
(mutate:) is on, the honeypot's own name is encrypted along with every other field's, so a
bot can't learn to skip it by its static name — and its id is deliberately left opaque too
(not subject to Spamtrap.stable_ids), since a stable id would give it away just as easily.
If you enable mutation via the spamtrap: option on form_for/form_with (the recommended
way — see Field Name Mutation), field order doesn't matter. If instead
you pass mutate: directly to f.spamtrap, call it before any other field helper in the
form; fields rendered before it are not mutated.
By default f.spamtrap renders a single hidden textarea. Some bots skip textareas, or skip
anything hidden with display:none, but still auto-fill visible-looking inputs and tick
checkboxes — styles: adds more decoy shapes to catch those:
<%= f.spamtrap :sarah_palin_walks_with_dinosaurs, styles: %i[textarea text checkbox] %>:textarea renders the classic hidden <textarea>, named after the declared honeypot.
:text renders a hidden text <input> named <honeypot>_input; :checkbox renders a hidden,
unchecked checkbox named <honeypot>_check. All requested styles share the same hiding
attributes (tabindex="-1", autocomplete="off", aria-hidden="true", style="display:none"),
and all are encrypted along with every other field when mutation is on. The controller checks
all three derived names regardless of which styles were actually rendered, so changing styles:
later never leaves an old decoy unchecked. The derived names are on the strict-mutation
allowlist automatically — you never need to add them to Spamtrap.allowed_params.
Set a default for every form with Spamtrap.honeypot_styles = %i[textarea text checkbox] in
the initializer, override per render with styles: on f.spamtrap, or via
form_with model: @comment, spamtrap: { styles: %i[textarea text checkbox] }.
The nonce is an HMAC-SHA256 digest over v1:timestamp:client_ip:honeypot_name:nonce_id,
keyed with an HKDF-derived key (from Rails.application.secret_key_base). It proves the
form was rendered by this app, for this client IP, for this honeypot, within the timeout
window, and not more than nonce_skew seconds in the future.
This is a freshness check, not replay protection. By itself (nonce: true), the same
valid token is accepted any number of times until it expires — a captured, valid submission
can be replayed. If you need replay protection, use nonce: :single_use instead.
Enable globally via the initializer, or per-controller:
spamtrap :sarah_palin_walks_with_dinosaurs, nonce: true, only: %i[create update]Add the nonce fields to your form:
<%= form_for @comment do |f| %>
<%= f.spamtrap :sarah_palin_walks_with_dinosaurs, nonce: true, class: 'reindeer_jerky' %>
<% end %>Rendering the nonce fields needs an actual request to bind them to (for the client IP);
calling f.spamtrap nonce: true from a mailer view, or from ApplicationController.render
outside a request, raises Spamtrap::NoRequestError.
Override the timeout for a specific action (seconds, or an ActiveSupport::Duration):
spamtrap :field, nonce: true, nonce_timeout: 15.minutes, only: %i[create]spamtrap :field, nonce: :single_use, only: %i[create]On first successful use, the nonce_id is recorded in Spamtrap.nonce_store (default
Rails.cache); a second submission with the same nonce_id is rejected with
reason: :nonce_replayed. This requires a real, shared cache store in production — Redis,
Memcached, or Solid Cache. With ActiveSupport::Cache::NullStore (Rails' test-environment
default), Spamtrap logs one warning per process and cannot detect replays at all, because
every write to a NullStore succeeds.
The trade-off: a legitimate user who submits the same page twice — double-click, back button, browser retry — is rejected the second time, same as a replayed bot submission.
Passed to on_trap as reason::
:nonce_missing— timestamp, nonce, or nonce id absent from the submission.:nonce_expired— timestamp older than the timeout.:nonce_invalid— bad HMAC, wrong form, wrong IP, timestamp too far in the future, or a malformed nonce id.:nonce_replayed—:single_useonly; the nonce id was already seen.:too_fast— the nonce itself checked out, but the render timestamp is younger thanSpamtrap.min_fill_time; see Minimum fill time.
Spamtrap.nonce_bind_ip controls how tightly the nonce is bound to the client IP:
true(default) — binds to the full client IP. Users whose IP address changes between page load and form submission — mobile network handoffs, CGNAT — are rejected with:nonce_invalid.:prefix— binds to the request's /24 network for IPv4, or /48 for IPv6, instead of the exact address. An IPv4-mapped IPv6 address (e.g.::ffff:203.0.113.5) is masked as IPv4 first, not folded into a single /48. This tolerates most mobile carrier and CGNAT address churn while still requiring the submission to come from roughly where the form was rendered.false— the nonce isn't bound to the IP at all.
Sites with a significant mobile user base should prefer :prefix over the default true,
since a full carrier IP change between page load and submission is common on mobile networks
and would otherwise reject legitimate users.
nonce_bind_ip can also be set per action and per render, overriding the global default —
but the view and the controller must agree, or a legitimate submission is rejected as
:nonce_invalid:
spamtrap :field, nonce: true, nonce_bind_ip: :prefix, only: %i[create]<%= f.spamtrap :field, nonce: true, nonce_bind_ip: :prefix %>or via form_with:
form_with model: @comment, spamtrap: { nonce: true, nonce_bind_ip: :prefix }Every field is encrypted with AES-128-GCM, using a random IV per field and the render
timestamp as associated data, so field names are unrecognizable in HTML source, change on
every render, and expire after mutation_timeout (defaults to nonce_timeout). The
controller decrypts and remaps them transparently before the action runs — no changes to
params.require(...).permit(...) or any other controller code required.
mutate: true is strict by default. It hides field names from scrapers of that render
and rejects any submitted key that isn't a valid mutation token, with
reason: :plaintext_field (see Strict mutation below). Use
mutate: :lenient if you only want field names hidden, without rejecting a bot that already
knows the real field names (from source, docs, or a previous unmutated render) — under
:lenient, submitted plaintext names are simply left alone and pass through like any other
params.
Enable globally via the initializer, or per-controller:
spamtrap :sarah_palin_walks_with_dinosaurs, mutate: true, only: %i[create update]The recommended way to enable it in the view is the spamtrap: option on form_with (or
form_for), which mints the shared render timestamp when the builder is constructed — so it
no longer matters where in the form you call f.spamtrap, or whether you call it before or
after the fields it protects:
<%= form_with model: @comment, spamtrap: { mutate: true } do |f| %>
<%= f.label :body %>
<%= f.text_area :body %>
<%= f.label :email %>
<%= f.email_field :email %>
<%= f.spamtrap :sarah_palin_walks_with_dinosaurs %>
<%= f.submit %>
<% end %>f.spamtrap needs no mutate: argument here — it inherits mutate: (and nonce:) from the
options the builder was constructed with. Combine both in the same hash:
form_with model: @comment, spamtrap: { mutate: true, nonce: true }If you don't pass spamtrap: to form_with/form_for, pass mutate: true directly to
f.spamtrap instead — but then call it before any other field helper, since the shared
render timestamp isn't minted until f.spamtrap runs, and fields rendered before it are not
mutated:
<%= form_for @comment do |f| %>
<%= f.spamtrap :sarah_palin_walks_with_dinosaurs, mutate: true %>
<%= f.label :body %>
<%= f.text_area :body %>
<%= f.label :email %>
<%= f.email_field :email %>
<%= f.submit %>
<% end %>The rendered HTML will contain opaque encrypted field names instead of body, email,
etc. The encryption key is derived from secret_key_base, so only the originating server
can decrypt submissions.
Mutation can be combined with the nonce for freshness plus name hiding:
spamtrap :field, mutate: true, nonce: true, only: %i[create update]<%= f.spamtrap :field, mutate: true, nonce: true %>Mutation propagates automatically to fields_for nested builders and into arrays of nested
builders, so nested attributes forms are mutated at every level without extra configuration.
Model-backed forms remain fully supported: helpers still pre-populate from model values while rendering encrypted field names in the HTML.
Rails writes a request's parameters to the log before any before_action runs, so it sees
the encrypted names, and name-based entries in config.filter_parameters (:email,
:passw, …) never match them. Spamtrap closes that gap by appending a filter block to
config.filter_parameters. On a request carrying spamtrap_timestamp, the block decrypts
each encrypted name and applies your app's own filters to the real name, so a mutated
comment[email] is logged as [FILTERED] and a mutated comment[body] still appears. There
is no separate list to maintain: whatever config.filter_parameters holds, including
filters added later, is what applies.
token_contextis unknown at that point (it often depends on state abefore_actionsets), so the block decrypts without checking the GCM tag. The unverified name is used only to decide whether to mask a value the client itself sent, never to read params.- Dotted filters such as
"credit_card.number"work too. Only field names are mutated, never the object orfields_fornames around them, so the block rebuilds the field's real path and runs the filters against it, leaving out array positions as Rails does. - When one render's
fields_forchildren share a token (the same field name under several parents), the value is masked if a filter matches any of those paths. - Cost: a block in
filter_parametersmakes Rails pass it every leaf parameter it filters. The block returns straight away on requests withoutspamtrap_timestamp. On requests with one, it indexes the params once and decrypts only keys shaped like tokens.
Set Spamtrap.filter_parameters = false to switch it off. The block stays installed but does
nothing.
Every FormBuilder field helper is mutated: text_field, email_field, password_field,
number_field, url_field, telephone_field/phone_field, text_area, check_box,
radio_button, hidden_field, file_field, date_field, time_field, datetime_field,
datetime_local_field, month_field, week_field, search_field, color_field,
range_field, select, collection_select, grouped_collection_select, time_zone_select,
weekday_select, collection_check_boxes, collection_radio_buttons, date_select,
time_select, datetime_select, label, and rich_text_area when Action Text is loaded.
Rails' multi-parameter field names for the date/time selects (e.g. field(1i), field(2i))
are remapped correctly — the base field name is decrypted and the (Ni) suffix is preserved.
Element ids and label for attributes are derived from the real field name (e.g.
comment_body), while name stays encrypted — so your CSS, Stimulus targets, and browser
autofill keep working exactly as they would on an unmutated form. Set
Spamtrap.stable_ids = false to opt out and fall back to an id derived from the opaque
encrypted name instead. collection_check_boxes, collection_radio_buttons, and the
date_select/time_select/datetime_select helpers always keep their own opaque ids — Rails
computes those per collection item or multi-parameter field internally.
field_with_errors wrapping (Rails' default ActionView::Base.field_error_proc) works on
mutated fields: Spamtrap resolves the model's errors using the real field name before handing
off to the wrapper.
mutate: true and mutate: :strict are equivalent, and this is the default mutate:
behaviour: any submitted top-level or nested key that is not a valid mutation token is treated
as a trap, with reason: :plaintext_field — except for a fixed allowlist:
authenticity_token, commit, button, _method, utf8, the spamtrap_* hidden fields,
the honeypot field name, Rails' own routing params (controller, action, id, format),
and anything you add to Spamtrap.allowed_params. Object and fields_for container names
(e.g. comment in comment[body]) are structure, not fields, and are never flagged
themselves — but a plaintext key found inside one still traps.
Three captcha widgets' response params are also allowlisted by default, since the widget
injects them as plaintext and the app has no way to encrypt them: g-recaptcha-response
(Google reCAPTCHA v2/v3), h-captcha-response (hCaptcha), and cf-turnstile-response
(Cloudflare Turnstile). Any other widget's plaintext param needs adding to
Spamtrap.allowed_params yourself.
A missing render timestamp is reported as reason: :mutation_expired rather than
:plaintext_field, since nothing could be decrypted to tell a real violation from a missing
one. An expired render timestamp (older than mutation_timeout, or too far in the future)
is also reason: :mutation_expired — in both :strict and :lenient mode — but unlike a
missing timestamp, every field is still decrypted and remapped to its real name before the
trap fires, with no additional staleness bound on that remap. This lets on_trap and a
trap_response callable re-render the form with what the user actually typed, using
params/controller.params as normal, instead of showing them an empty form again. The
action itself never runs either way.
If your app posts any custom top-level params that aren't part of the form (e.g. a query
string param merged into a redirect, or a param your JS adds), add them to
Spamtrap.allowed_params or the action will trap on them.
Use mutate: :lenient to opt out of strict checking and go back to remap-only behaviour —
field names are still hidden, but a submitted plaintext field name is never trapped:
spamtrap :field, mutate: :lenient, only: %i[create update]Spamtrap.min_fill_time (default 1, seconds) rejects a submission whose verified render
timestamp is less than that many seconds old, with reason: :too_fast. A scripted poster
typically submits within milliseconds of fetching the page; one second is generous for a human,
who has to notice the form, move to it, and type.
It only applies when nonce or mutate is on — those are what bind spamtrap_timestamp into
something a submission can't forge (the nonce HMAC, or the mutation AAD), which is what makes
the timestamp trustworthy enough to check. Honeypot-only forms have no verified timestamp, so
min_fill_time has no effect on them. The check runs after the nonce HMAC is verified, so a
forged or stale timestamp is reported as :nonce_invalid/:nonce_expired rather than
:too_fast, and before a :single_use nonce is recorded, so a submission trapped as too fast
can be sent again from the same page once enough time has passed.
Set false or 0 to disable it globally:
Spamtrap.min_fill_time = falseOr override it per action, like any other option:
spamtrap :field, nonce: true, min_fill_time: 3, only: %i[create]js_proof: true adds a hidden spamtrap_js field, plus an inline script that fills it in as
the page is parsed. A submission missing the field, or with the wrong value, is trapped with
reason: :no_js.
spamtrap :sarah_palin_walks_with_dinosaurs, js_proof: true, only: %i[create update]<%= f.spamtrap :sarah_palin_walks_with_dinosaurs, js_proof: true %>Enable it globally with Spamtrap.js_proof = true, or via form_with ... spamtrap: { js_proof: true }, the same as any other option.
What this stops, and what it doesn't. It stops a client that never executes JavaScript at all — most scripted form posters. It does not stop a headless browser, which executes the page's script like any real one. The value is present in the page source (reversed, as obfuscation only, not encryption), so a bot that bothers to parse the inline script can compute it itself. It's off by default because it's the one check here that excludes real users — anyone with JavaScript disabled or blocked is rejected along with the bots.
The token is bound to the render timestamp and expires after nonce_timeout. With nonce or
mutate also on, that timestamp is itself verified (HMAC or mutation AAD); without either, it's
merely bounded by age, since nothing stops a submission from claiming an arbitrary timestamp.
Content Security Policy. The inline script is emitted via javascript_tag(nonce: true), so
an app with a CSP nonce generator configured (config.content_security_policy_nonce_generator)
gets the nonce attribute automatically and needs no further change. An app with a strict CSP
and no nonce generator configured must add one, or the inline script will be blocked and
js_proof will trap every submission.
Turbo. The script runs as the page is parsed, so it works on a full page load and on a
Turbo Drive visit. A form rendered inside a Turbo Frame response will not run it, unless
your app re-executes scripts delivered inside frames — don't enable js_proof for a
frame-rendered form unless you've verified your app does that.
Spamtrap.suspicious_if (or per-action suspicious_if:) plugs your own spam-filtering logic
into the same trap machinery as the built-in checks — the same trap response, on_trap
callback, and trap.spamtrap instrumentation — without spamtrap trying to guess what "spammy"
means for your form. It runs last, after every other check has passed, and a truthy return
traps the submission with reason: :content.
spamtrap :comment, only: :create,
suspicious_if: ->(params) { params[:comment][:body].to_s.scan('http').size > 2 }The callable is dispatched the same way as on_trap (see Trap callback):
a bare positional argument receives just params, or declare params:, request:, and/or
controller: as keywords to receive only what you ask for. An exception raised inside it is
logged and treated as not suspicious — a bug in your own heuristic must not end up trapping
every legitimate visitor.
Set it globally in the initializer, or override per action:
Spamtrap.suspicious_if = ->(params) { params[:comment][:body].to_s.scan('http').size > 2 }By default, a trapped submission gets Spamtrap.trap_response = :head — an empty 200 OK,
indistinguishable from success to a bot and to any polling script. This is ideal for
honeypot traps, which no legitimate user should ever hit, but it is a poor experience when a
human is trapped by mistake — for example a legitimate double-submission caught by
nonce: :single_use, or a cached, stale nonce.
trap_response accepts:
-
:head— 200, empty body (default). -
:no_content— 204. -
:unprocessable— 422. -
:redirect_back— 303 to the referer, or/if there is none. -
A callable —
->(controller, reason) { controller.render ... }. -
A
Hashkeyed by reason, with a:defaultkey for anything not listed, e.g.:Spamtrap.trap_response = { honeypot: :head, # stay silent for bots nonce_expired: :unprocessable, nonce_replayed: :unprocessable, default: :head }
If on_trap renders or redirects itself, that response wins over trap_response.
Turbo apps in particular should not use :head for reasons a human can plausibly hit.
Turbo Drive expects a form submission to answer with a redirect or a 4xx/5xx status. On a
200 it logs "Form responses must redirect to another location" and renders nothing, so a
trapped human sees no feedback at all. Use :unprocessable (Turbo renders the response) or
:redirect_back, and keep :honeypot on :head since a human should never trigger it.
rails-ujs/jquery-ujs remote: true forms have the same problem, in a different shape.
An empty 200 fires the form's ajax:success handler with an empty body — a modal that
treats "success fired" as "the message sent" closes itself, and the trapped human walks away
thinking it went through. An empty 422 fires ajax:error, but with nothing in the response
to render, so there's still no visible feedback.
The fix is the same for both: a callable that re-renders the form with a 422 and a visible error, instead of an empty body either way.
Spamtrap.trap_response = {
honeypot: :head,
plaintext_field: :head,
default: lambda do |controller, reason|
controller.flash.now[:alert] = 'Your form expired or was sent twice. Please check it and send it again.'
controller.render controller.action_name == 'create' ? :new : :edit,
layout: !controller.request.xhr?,
status: :unprocessable_entity
end
}layout: !controller.request.xhr? skips the layout for the remote: true/Turbo Stream
request (which only needs the form partial back) while still rendering it for a normal
full-page submission. The reasons a human can plausibly hit — the ones worth a default
branch like this rather than a silent :head — are :too_fast (a very quick submission,
such as a browser autofill-and-send), :nonce_expired, :mutation_expired, :nonce_invalid
(most often an IP change between page load and submit), :nonce_replayed (a double
submit under nonce: :single_use), and :no_js (JavaScript disabled or blocked, under
js_proof).
Every trap publishes an ActiveSupport::Notifications event, trap.spamtrap, before on_trap
is called — use it for metrics or logging without touching on_trap at all:
ActiveSupport::Notifications.subscribe('trap.spamtrap') do |event|
# event.payload: reason, honeypot, controller, action, ip, request
StatsD.increment('spamtrap.triggered', tags: ["reason:#{event.payload[:reason]}"])
endSpamtrap.throttle_key(request) returns "spamtrap:<ip>", with the IP normalised the same way
the nonce binds it (Spamtrap.normalize_ip, honouring Spamtrap.nonce_bind_ip) — use it to key
a rate limit to one client without reimplementing that normalisation yourself.
Count trips in a trap.spamtrap subscriber, then block once a client trips it too often:
ActiveSupport::Notifications.subscribe('trap.spamtrap') do |event|
key = "#{Spamtrap.throttle_key(event.payload[:request])}:trips"
Rails.cache.increment(key, 1, expires_in: 1.hour)
end
Rack::Attack.blocklist('spamtrap repeat offenders') do |request|
Rails.cache.read("#{Spamtrap.throttle_key(request)}:trips").to_i >= 5
endThis needs a real, shared cache store — as with nonce: :single_use (see
Single-use nonces), ActiveSupport::Cache::NullStore (Rails'
test-environment default) never actually records a trip.
Rails 8's rate_limit is a blunter alternative: no subscriber needed, but it throttles every
submission from a client prefix, not just trapped ones:
class CommentsController < ApplicationController
rate_limit to: 10, within: 1.hour, by: -> { Spamtrap.throttle_key(request) }, only: :create
endSpamtrap.secret_key_base (defaults to Rails.application.secret_key_base) is the secret new
tokens and nonces are minted and verified against; Spamtrap.previous_secret_key_base (default
nil) is tried second on a mismatch. Set it for one timeout window after rotating
secret_key_base, and forms that were already rendered under the old secret keep verifying
until they'd have expired on their own anyway:
# config/initializers/spamtrap.rb
Spamtrap.secret_key_base = ENV['SPAMTRAP_SECRET']
Spamtrap.previous_secret_key_base = ENV['SPAMTRAP_PREVIOUS_SECRET'] # remove once nonce_timeout/mutation_timeout has passedKeys are derived from each secret once per process (HKDF), not on every request.
An app serving many hostnames from one secret_key_base shares its keys across all of them —
a nonce, mutation token, or js_proof value minted while rendering a form on a.example also
verifies on b.example. Spamtrap.token_context closes that gap by mixing a value you derive
from the request into every token:
# config/initializers/spamtrap.rb
Spamtrap.token_context = ->(request) { request.host }It's off (nil) by default, so the token format is unchanged for apps that don't set it — every
existing token keeps verifying exactly as before. Once set, a token minted on one host and
submitted from another fails the same check it always would have, with no new trap reason:
nonce_invalid for the nonce, plaintext_field (strict) or an unmapped field (:lenient) for
mutation, and no_js for js_proof.
Caution: don't enable this if a form can legitimately be rendered on one hostname and posted
to another — e.g. www.example.com serving the form for app.example.com to submit to, or an
app sitting behind a proxy that rewrites the Host header before it reaches Rails. In either
case request.host differs between render and submission for entirely legitimate traffic, and
every one of those submissions would be rejected. Use a context both sides agree on instead, such
as the tenant id:
Spamtrap.token_context = ->(request) { Current.tenant_id }Fragment, page, or CDN caching of a rendered form freezes the render timestamp, client IP, and nonce into the cached HTML. Once that snapshot is older than the timeout, every visitor served the cached form is rejected, regardless of whether they're a bot — because the nonce and mutation tokens they submit are the same ones baked into the cache, no matter who requests the page or when.
Either:
- Exclude the
f.spamtrapoutput from whatever you cache, and render it fresh outside the cached fragment, or - Turn
nonceandmutateoff for forms you cache.
Tests that post to a protected action need valid spamtrap_timestamp and nonce fields in the
params, built the same way the view helper builds them, or the request will be trapped.
Spamtrap::TestHelper builds them for you. It's not required by spamtrap itself, so pull it
in explicitly and include it in your test cases:
require 'spamtrap/test_helper'
class ActionController::TestCase
include Spamtrap::TestHelper
endIt provides:
spamtrap_params(honeypot:, ip: '0.0.0.0', nonce: false, mutate: false, js_proof: false, at: Time.now.to_i - 5, bind_ip: Spamtrap.nonce_bind_ip, fields: nil, context: nil)— the params hash a protected action needs: the empty honeypot field, plus whateverspamtrap_timestamp/nonce/js_prooffields the declared options require.fields:is merged in — throughspamtrap_mutatefirst whenmutate:is truthy, unchanged otherwise — so one call builds the whole POST.spamtrap_token(name, timestamp, context: nil)— encrypts a real field name into the same kind of mutation token the view helper renders, for posting to amutate:-protected action.spamtrap_decrypt(token, timestamp, context: nil)— the reverse, for asserting on a token your app captured.spamtrap_mutate(hash, at:, context: nil)— encrypts every field name in a whole params hash the way the form builder renders them, so it round-trips through amutate:-protected action without hand-building a token per field. Object/fields_forcontainer names (a key whose value is a Hash, or an Array of Hashes) are left plaintext and recursed into; every other key — including one whose value is an array of scalars — is encrypted, with a trailing multi-parameter suffix likepublished_on(1i)preserved after the token.spamtrap_nonce_params(honeypot:, ip:, at: Time.now.to_i - 5, nonce_id: SecureRandom.hex(16), bind_ip: Spamtrap.nonce_bind_ip, context: nil)— just the nonce fields, if you need them without the honeypot key.spamtrap_js_param(honeypot:, at:, context: nil)— just thespamtrap_jsfield, at the value the inline script would have written for a page rendered atat, if you need it without the rest ofspamtrap_params.
at: defaults to five seconds in the past on both, so a token built this way already clears
Spamtrap.min_fill_time's default 1-second floor (see Minimum fill time)
without you having to think about it. bind_ip: lets you build params for an action declared
with a non-default nonce_bind_ip: (see IP binding). context: lets you build
params matching an app that has set Spamtrap.token_context (see
Binding tokens to the host) — pass the same
value your token_context callable would have returned for the request under test.
class CommentsControllerTest < ActionController::TestCase
def test_create_with_mutation_and_nonce
ts = Time.now.to_i - 5
params = spamtrap_params(honeypot: 'field', ip: '127.0.0.1', nonce: true, mutate: true, at: ts)
.merge(comment: { spamtrap_token('body', ts) => 'hi' })
@request.remote_addr = '127.0.0.1'
post :create, params: params
assert_response :success
end
def test_create_with_a_whole_mutated_form
ts = Time.now.to_i
params = spamtrap_params(honeypot: 'field', mutate: true, at: ts,
fields: { comment: { body: 'hi', address: { city: 'Springfield' } } })
post :create, params: params
assert_response :success
end
endThe nonce is bound to the client IP (see IP binding), so the ip: you pass to
spamtrap_params/spamtrap_nonce_params must match the test request's remote_addr —
ActionController::TestCase defaults remote_addr to '0.0.0.0'.
If your own test helpers mint a spamtrap_timestamp directly instead of going through
spamtrap_params/spamtrap_nonce_params — e.g. stamping Time.now.to_i straight into a
fixture — either backdate it by a few seconds the same way, or set
Spamtrap.min_fill_time = false in your test environment; otherwise a request built and posted
within the same second as Time.now trips :too_fast.
Rails.cache is usually ActiveSupport::Cache::NullStore in the test environment, which
means nonce: :single_use cannot detect replays there (see Single-use nonces)
— assert on the first submission's behaviour, not on rejecting a second one, unless you
swap in a real cache store for that test.
Alternatively, set Spamtrap.enabled = false for the whole test environment to skip every
check, or declare nonce: false (and mutate: false) on the action under test — or use a
spamtrap :field, only: :create, nonce: false override in a test-only subclass — if the nonce
or mutation behaviour itself isn't what's under test.
(The MIT License)
Copyright (c) 2010-2026 Cedric Howe
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.