Honeytrap

CI Version

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:

These options can also be set globally via Application env under the :honeytrap key. Per-plug values override the global default.

Example app config:

# config/config.exs
config :honeytrap, default_delay: 3.0

How it works

Three independent checks:

  1. Filled honeypot. Real users cannot see the field. Bots often fill it anyway.
  2. Elapsed time. Real users take at least a second or two. Instant submits are suspicious.
  3. 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.