Blocking Bot Signups with a Simple Honeypot in Phoenix
Problem Statement
On Accomplish, every few minutes we were seeing signups from random email addresses paired with nonsense names (e.g. asdflkjasd@example.com
, hjkdhkjahsdjkh@example.com
). These bot registrations were cluttering our database and even sending unnecessary Discord notifications on every “new user.” We tried integrating Google’s Invisible reCAPTCHA, but it proved tricky in Phoenix—between the custom JS hook, CSP headaches, and environment variables, it felt overkill. We also considered Cloudflare Turnstile, but didn’t want any visible UI burden on real users.
Instead, we needed something quick, non-invasive, and bullet-proof enough to catch 99% of basic bots. Enter the honeypot: a hidden form field that real humans never touch. Bots tend to fill every field, so any non-empty honeypot immediately marks a submission as spam. It took less than 20 minutes to implement, and since then, those garbage signups have stopped cold. Below is a step-by-step tutorial showing exactly how we did it in an Elixir Phoenix app.
1. Adding a Hidden “Nickname” Field to the HEEx Template
First, we open new.html.heex
(or wherever your signup form lives). We add one extra input, styled to be invisible:
<.form
:let={f}
for={@changeset}
action={~p"/signup"}
method="post"
id="registration_form"
class="mt-10 space-y-6"
>
<!-- Honeypot field: real users won’t see or fill this -->
<div style="display: none;">
<.input field={f[:nickname]} type="text" value=""/>
</div>
<.form_item>
<.form_label field={f[:email]}>Email</.form_label>
<.input field={f[:email]} type="email" autocomplete="email" />
</.form_item>
<.form_item>
<.form_label field={f[:password]}>Password</.form_label>
<.input field={f[:password]} type="password" autocomplete="new-password" />
<.form_description>
Password must be between 8 and 72 characters long.
</.form_description>
</.form_item>
<!-- ...other fields like first_name, username, etc. ... -->
<.button class="w-full" phx-disable-with="Creating account...">
Create an account
</.button>
</.form>
By wrapping the <.input field={f[:nickname]}>
inside a div
with style="display: none;"
, we ensure real users never see or fill it. Bots, however, will generally fill in every available input, including this hidden “nickname” field.
2. Updating the User
Schema and Changeset
Next, we tell Ecto that a virtual :nickname
field exists—and we add a validation so that if :nickname
is ever non-blank, the changeset is invalid. In accounts/user.ex
, we make two changes:
defmodule Accomplish.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :password, :string, virtual: true
field :nickname, :string, virtual: true # ← honeypot (virtual)
field :first_name, :string
field :username, :string
# ... other real fields ...
timestamps()
end
@doc """
Builds a registration changeset for user creation. Enforces that `nickname` is blank.
"""
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :password, :first_name, :username, :nickname])
|> validate_required([:email, :password, :first_name, :username])
|> validate_nickname_empty() # ← custom honeypot check
|> validate_format(:email, ~r/@/, message: "must have the @ sign and no spaces")
|> validate_length(:password, min: 8, max: 72, message: "should be at least 8 characters")
# ... any additional validations (username format, etc.) ...
end
defp validate_nickname_empty(changeset) do
case get_change(changeset, :nickname) do
nil -> changeset
"" -> changeset
_ -> add_error(changeset, :nickname, "must be blank")
end
end
end
3. No Changes to Accounts.register_user/1
or the Controller
Because Accounts.register_user/1
already receives a map of user params and builds a changeset under the hood, we don’t need to alter its signature. It simply uses our updated registration_changeset/3
that now expects (and rejects) any non-empty :nickname
. In other words, existing code like:
def create(conn, %{"user" => user_params}) do
case Accounts.register_user(user_params) do
{:ok, user} ->
# …deliver confirmation and log in…
{:error, changeset} ->
render(conn, :new, changeset: changeset)
end
end
continues to work, because register_user/1 calls:
User.registration_changeset(%User{}, attrs, opts)
|> Repo.insert()
Under the hood, if "nickname" is non-blank, the changeset is invalid and {:error, changeset}
is returned.
4. Writing a Controller Test for the Honeypot
To ensure our honeypot blocks bots correctly, we add a new test in test/accomplish_web/controllers/user_registration_controller_test.exs
. We simply mimic a bot by sending a non-empty "nickname"
in params and assert that the signup fails (no session token, re-renders with “must be blank” error).
defmodule AccomplishWeb.UserRegistrationControllerTest do
use AccomplishWeb.ConnCase, async: true
import Accomplish.AccountsFixtures
import AccomplishWeb.UrlHelpers, only: [web_app_signed_in_path: 0]
describe "POST /signup" do
@tag :capture_log
test "creates account and logs the user in", %{conn: conn} do
email = unique_user_email()
conn =
post(conn, ~p"/signup", %{
"user" => valid_user_attributes(email: email)
})
assert get_session(conn, :user_token)
assert redirected_to(conn) == web_app_signed_in_path()
end
test "rejects form submission when honeypot (nickname) is filled", %{conn: conn} do
email = unique_user_email()
# Build a valid user map, then put a non‐empty "nickname" to mimic a bot
user_params =
valid_user_attributes(email: email)
|> Map.put("nickname", "i-am-a-bot")
conn = post(conn, ~p"/signup", %{"user" => user_params})
response = html_response(conn, 200)
assert response =~ "Register"
# The changeset error message "must be blank" appears when nickname is non-empty
assert response =~ "must be blank"
refute get_session(conn, :user_token)
end
end
end
5. Why This Works
- Bots auto-fill anything: Many automated scripts fill every available field. By adding a hidden input that real humans never touch, bots inevitably populate it, triggering our validation.
- No external dependencies: Unlike reCAPTCHA or Cloudflare Turnstile, we’re not loading any third-party JS, dealing with CSP, or managing API keys/secrets.
- Minimal code changes: We only added a virtual field, one small validator, and a hidden input in the form—no complex JS hooks required.
- Immediate feedback: If a bot tries to sign up, it sees a “must be blank” error. Genuine users never notice or care.
6. Considerations and Caveats
- Not 100% bulletproof: More sophisticated bots can detect hidden
<input>
s and skip them. However, for most low-level spam, this blocks 95–99% of junk. If you start to see spam again, you can combine this with:- A timestamp honeypot (reject if the form was submitted in < 2 seconds).
- A rate-limit on
/signup
(e.g. PlugAttack, Hammer). - JavaScript checks (e.g. require a small JS‐generated token before submission).
- UX remains clean: No visible CAPTCHA widget, no extra friction for genuine users.
- Easy maintainability: All code is Elixir/Ecto; no front-end‐only workarounds.
Conclusion
Instead of wrestling with Google’s invisible reCAPTCHA (CSP, CSP, CSP…), we opted for simplicity: a hidden honeypot field plus one small changeset validator. It took almost zero time to implement and has dramatically reduced bot signups on Accomplish. While it’s not unbreakable, it’s more than good enough for most apps that face noisy, dumb bots filling every field.
If you need a quick, non-invasive spam shield on a Phoenix signup form, give the honeypot pattern a try—just a handful of lines in your schema, controller, and HEEx template. Good luck, and may your spam counts drop to zero!