Honeytrap
Invisible honeypot spam protection for any Plug-based Elixir app.
Bots fill every form field they can find. Honeytrap adds a hidden field that real users never touch. It also measures how fast the form was submitted and collects a few passive interaction signals. If any of those look wrong, the request is flagged. You decide what to do with the flag.
Install
Add honeytrap to your deps in mix.exs:
def deps do
[
{:honeytrap, "~> 0.2.0"}
]
end
Install the signals script
The signals collection script ships with the package and must run once per
page, from your assets bundle. Never load it through an inline <script> tag:
such tags do not execute when a LiveView patches the page.
The script listens at the document level and fills every
data-honeytrap-signals input just before its form submits. It works for plain
controller forms and LiveView forms alike, no extra setup per form.
With esbuild
This is the Phoenix default. Add one alias flag to your existing esbuild args (you likely have the rest already):
# config/config.exs
config :esbuild,
version: "0.17.11",
args: [
# ...your existing args...
"--alias:honeytrap=" <>
Path.expand("../deps/honeytrap/assets/js/honeytrap.js", __DIR__)
]
// assets/js/app.js
import 'honeytrap'
With BunBundle
Use the $/ root alias instead, no config needed:
// assets/js/app.js
import '$/deps/honeytrap/assets/js/honeytrap.js'
Usage
1. Arm the trap
Set a timestamp on the session when you render the form. Do this in your controller action.
def new(conn, _params) do
conn = Honeytrap.arm(conn, :website)
render(conn, :new)
end
2. Render the fields
Add the honeypot field and the signals field to your form template.
<form action={~p"/contact"} method="post">
<%= Honeytrap.field(:website) %>
<%= Honeytrap.signals_field() %>
<label>Message <textarea name="message"></textarea></label>
<button type="submit">Send</button>
</form>
Note
The honeypot field is hidden with inline styles by default. Pass a :class
or :style to use your own hiding.
For nested params, pass a path instead of a flat name. The path is also what
you give the Plug or check/3, so render and check always agree:
<.form for={@form} phx-submit="save">
<%= Honeytrap.field("user[website]") %>
<%= Honeytrap.signals_field() %>
</.form>
renders <input name="user[website]">, which lands in
params["user"]["website"].
3. Plug the check into your pipeline
pipeline :submissions do
plug Honeytrap.Plug, fields: [:website]
end
On matching requests the plug puts a result in conn.assigns.honeytrap:
%{
bot: true,
reasons: [filled: :website, filled: :phone],
human_rating: 0.2
}
Each field contributes at most one reason, the first that applies.
4. Act on the result
Honeytrap does not halt the pipeline. Your action decides what to do. That way you keep an escape hatch for false positives.
def create(conn, params) do
if conn.assigns.honeytrap.bot do
render(conn, :thanks)
else
Contact.deliver(params)
render(conn, :thanks)
end
end
LiveView forms
LiveView events never pass through the Plug pipeline, so call Honeytrap.check/3
from your handle_event callbacks instead. A check is one-shot: re-arm after
every check, or resubmissions keep riding the old timestamp and the delay
check silently weakens.
@honeytrap_field "user[website]"
def mount(_params, _session, socket) do
{:ok, arm_honeytrap(socket)}
end
def handle_event("save", params, socket) do
if honeytrap_bot?(socket, params) do
{:noreply, put_flash(socket, :error, "That looked automated")}
else
# ... real save logic ...
{:noreply, arm_honeytrap(socket)}
end
end
defp arm_honeytrap(socket) do
assign(socket, :honeytrap_armed, Honeytrap.arm_field(@honeytrap_field))
end
defp honeytrap_bot?(socket, params) do
Honeytrap.check(params, socket.assigns.honeytrap_armed, fields: [@honeytrap_field]).bot
end
Hybrid forms
For a LiveView form that submits to a controller via phx-trigger-action, the
POST goes through your pipeline as usual. But a LiveView cannot write to the
Plug session, so arm from the controller that renders the page, not from
mount:
def new(conn, _params) do
conn = Honeytrap.arm(conn, :website)
live_render(conn, MyLive, session: %{})
end
The Plug then consumes the session timestamp on the POST, same as a plain controller form.
Configuration
Pass options to the plug directly:
:fields(required). List of honeypot field names to check. Set per plug call, since different forms use different fields.
These options can also be set globally via Application env under the
:honeytrap key. Per-plug values override the global default.
:default_delay(default2.0). Minimum seconds between arm and submit.:disable_delay(defaultfalse). Skip the elapsed time check.:minimum_human_rating(defaultnil). If set, flags submissions below this score.:signals_input_name(default"honeytrap_signals"). Name of the signals input.
Example app config:
# config/config.exs
config :honeytrap, default_delay: 3.0
How it works
Three independent checks:
- Filled honeypot. Real users cannot see the field. Bots often fill it anyway.
- Elapsed time. Real users take at least a second or two. Instant submits are suspicious.
- Human signals. A tiny script watches for mouse, touch, scroll, keyboard, and focus events on the form. The result is packed into a hidden JSON blob. You can act on the rating or ignore it.
The Plug stores its arming timestamps in the session, LiveView callers keep them in socket assigns. No cookies beyond what you already have.
License
MIT.