lucky_honeypot
lucky_honeypot
Simple invisible captcha spam protection for Lucky Framework apps.
[!Note] The original repository is hosted at https://codeberg.org/fluck/lucky_honeypot.
How it works
This shard uses three techniques to catch spambots:
- 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 two first checks fail, the submission is quietly rejected. The bot thinks it succeeded and moves on. The third one can be used to reject submissions at a certain human rating threshold, or to flag entries that may be suspicious.
Installation
-
Add the dependency to your
shard.yml:dependencies: lucky_honeypot: codeberg: fluck/lucky_honeypot -
Run
shards install
Usage
Require the shard in your src/shards.cr:
require "lucky_honeypot"
Add the honeypot to the form you would like to protect:
class SignUps::NewPage < AuthLayout
include LuckyHoneypot::Tag
def content
form_for SignUps::Create do
# ...
honeypot_input "user:website"
# ...
end
end
end
[!TIP] The name of the honeypot input can be anything, but it's best to keep it in line with the rest of the fields in your form, so it looks believable.
Finally, configure the honeypot in the receiving action:
class SignUps::Create < BrowserAction
include LuckyHoneypot::Pipe
honeypot "user:website"
post "/sign_up" do
# ...
end
end
That's it! Your form is now protected.
Configuring the input
The honeypot_input is good to go out of the box, but there are some things to consider. By default, it is rendered with a style attribute to make it inaccessible for humans, so they don't accidentally fill it out. By passing a class attribute, it is assumed that you're using your own CSS class to hide the input:
honeypot_input "user:website", class: "visually-hidden"
[!IMPORTANT] When a
classattribute is passed, the defaultstyleattribute won't be rendered. However, you can pass your ownstyleattribute as well.
Aside from the special class attribute, you can pass any other attribute as well:
honeypot_input "user:website", data_purpose: "not for humans"
[!NOTE] The
honeypot_inputmacro method will also set a timestamp in the session to verify submission timing. Consider this when you add your own field instead of using this macro.
Configuration options
Habitat.configure do |settings|
# Default required delay between page load and form submission.
settings.default_delay = 2.seconds
# Disables the submission delay entirely; useful in test environments.
settings.disable_delay = false
# Default name for the signals input field
settings.signals_input_name = "honeypot_signals"
end
Configuring the pipe
The honeypot macro does two things:
- Ensure the declared honeypot field is not filled
- Ensure the form was not submitted too quickly
The default timing for the form submission is 2 seconds, but that can be configured:
honeypot "user:website", wait: 5.seconds
# or
honeypot "user:website", wait: 1_500.milliseconds
[!TIP] The ideal timing will depend on the length of your form. Do some testing to see how fast you can fill out the form, and use that as the baseline.
If a honeypot is filled or submitted too quickly, a head 204 (No Content) will be returned. This is common behaviour for honeypots. The bot will assume the submission was successful and move on to its next target. If you want to handle it differently, can can pass a block with the desired response:
honeypot "user:website" do
flash.info = "Moving on..."
redirect to: Home::Index, status: HTTP::Status::SEE_OTHER
end
Finally, you can also add multiple honeypots, each with their own timing and HTTP handling:
honeypot "user:website", wait: 5.seconds
honeypot "note" do
html Bot::IndexPage
end
Detecting input signals
This shard comes with simple input signals detection built-in. It monitors mouse movements, touch gestures, scroll triggers, keyboard input, and focus events. If any of those are detected, it adds to the likeliness of human interaction.
To track the input signals, add the honeypot_signals tag to your form:
honeypot_signals
Similar to the honeypot_input tag, it accepts additional attributes:
honeypot_signals data_some: "value"
The signals tag only tracks input signals and stores the result in a hidden field that is submitted with the form. It is up to you what to do with the information. You could for example use it to flag a record in case of a suspicious submission:
if LuckyHoneypot::Signals.human_rating(params.get("honeypot_signals")) < 0.2
# Do your thing
end
And if you want more information about which inputs were triggered:
signals = LuckyHoneypot::Signals.from_json(params.get("honeypot_signals"))
signals.human_rating # a value between 0 (bot) and 1 (human)
signals.mouse? # if true, the mouse was moved
signals.touch? # if true, a touch gesture was detected
signals.scroll? # if true, a scroll was triggered
signals.keyboard? # if true, keyboard input was detected
signals.focus? # if true, input focus was triggered
[!NOTE] The human rating is the fraction of the five signals (mouse, touch, scroll, keyboard, focus) that fired, so each one contributes
0.2. A score of0is almost certainly a dumb bot, while0.2could be a sophisticated bot triggering a single signal, though a human filling out a short form at the top of the page may also land there.
0.4is a reasonable rejection threshold: 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. Use0.6as a "flag as suspicious" threshold rather than a hard reject, since legitimate autofill users will frequently fall below it.
Security considerations
This shard provides basic bot protection, but it should not be your only line of defense. Here are few important points to consider:
- It's not foolproof and sophisticated bots can bypass honeypots
- Combine this with Lucky's built-in rate limiting feature
- 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 is secure and uses signed cookies (Lucky's default)
- The timing check compares wall-clock timestamps, which makes it resilient to timing attacks since the check is a simple threshold comparison
- This shard works alongside Lucky's built-in CSRF protection. The honeypot fields are regular form inputs and do not interfere with CSRF tokens
For most use cases (contact forms, newsletter signups, etc.), this shard provides excellent protection with zero user friction. By adding a honeypot, you'll catch between 60% and 90% of all automated form submissions.
If you want protection from more sophisticated bots, have a look at the Prosopo shard or the hCaptcha shard.
Contributing
We use conventional commits for our commit messages, so please adhere to that pattern.
- Fork it (https://codeberg.org/fluck/lucky_honeypot/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'feat: add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors
- Wout - creator and maintainer
lucky_honeypot
- 1
- 0
- 0
- 1
- 3
- 11 days ago
- November 21, 2025
Sun, 12 Apr 2026 08:30:14 GMT