@sdxc/honeypot
Honeypot form fields with a signed render timestamp, router middleware and a `remix/ui` component
- Installs with
- @sdxc/crypto@sdxc/result
- Depends on
remix- Used by
- uptime, blog
- Source
- packages/honeypot
Honeypot form fields with a signed render timestamp: a trap field people never see, router
middleware that refuses a filled trap or a forged token before the handler runs, and a remix/ui
component that renders both fields without client JavaScript.
Installation
npm add @sdxc/honeypot
The middleware runs on the remix router and the component
renders with remix/ui; every verification returns an
@sdxc/result value. Both install alongside this
package.
Usage
Guard a form route
Install the middleware once on the route that renders the form and accepts it. It publishes the
honeypot as ctx.honeypot for rendering, and verifies every submission before the handler runs.
import { Honeypot } from "@sdxc/honeypot";
import { honeypot } from "@sdxc/honeypot/middleware";
import { HoneypotFields } from "@sdxc/honeypot/ui";
import { unwrap } from "@sdxc/result";
let trap = new Honeypot({ secret: HONEYPOT_SECRET });
router.map(routes.contact, {
middleware: [honeypot(trap)],
actions: {
async index(ctx) {
let fields = unwrap(await ctx.honeypot.issue());
return render(
<form method="post" action="/contact">
<HoneypotFields {...fields} />
<textarea name="message" />
<button type="submit">Send</button>
</form>,
);
},
action(ctx) {
// Reached only with a verified token and an empty trap.
let { renderedAt, elapsedMs } = unwrap(ctx.honeypotOutcome);
return saveMessage(ctx, { renderedAt });
},
},
});
Verify without the middleware
import { isFailure } from "@sdxc/result";
let result = await honeypot.verify(await request.formData());
if (isFailure(result)) return result.error.code; // "trap-filled", "invalid-token", …
result.data.renderedAt; // when the form was issued, as this app signed it
API
@sdxc/honeypot
new Honeypot({ secret, tokenField?, trapPrefix?, minSeconds?, maxAge? })
secretsigns tokens with HMAC-SHA-256. A list signs with its first entry and verifies with every entry, so rotating keeps forms already open in a browser valid.tokenFieldnames the hidden token input (defaulthp-token).trapPrefixstarts every trap name (defaulthp_); eight random letters follow, so browser and password-manager autofill never recognize it.minSecondsrefuses a submission sent sooner than that after render (default0, since a fast person is still a person).maxAgerefuses a token older than that many seconds (default unset, so a form left open overnight still submits).
honeypot.issue({ now? })
Resolves Result<Honeypot.Fields, HoneypotError>: { tokenField, token, trapField }. The token is
base64url(payload).base64url(mac), and the payload records the issue time and the trap's name,
so a bot can neither choose the trap nor backdate the form. It fails with misconfigured when
there is no secret.
honeypot.verify(form, { now? })
Takes a FormData or URLSearchParams and resolves Result<Honeypot.Verification, HoneypotError>:
{ renderedAt, elapsedMs }. A trap the bot dropped counts as empty. Tokens issued up to a minute in
the future are accepted, to allow for clock differences.
HoneypotError
code is one of:
| Code | When |
|---|---|
missing-token | No token field, or a body that is not a form |
invalid-token | Malformed, signed with no configured secret, or an unknown format |
trap-filled | The trap field holds any value, whitespace included |
too-fast | Sooner than minSeconds, or issued over a minute in the future |
expired | Older than maxAge |
misconfigured | No secret to sign or verify with |
@sdxc/honeypot/middleware
honeypot(instance, { onFailure? }) publishes instance as ctx.honeypot (and under the
HoneypotKey context key) on every request, so a handler issues fields from it. On every method
other than GET, HEAD and OPTIONS it also verifies the submitted fields, so one installation covers
a route that renders a form and accepts it. It reads the form parsed by remix's formData()
middleware when present, and otherwise a clone of the request, leaving the body readable for the
handler. onFailure(error, ctx) returns a Response to refuse, or null to continue with the
failure published; the default is a plain-text 400.
The verification is published as ctx.honeypotOutcome (HoneypotOutcome, a
Result<Honeypot.Verification, HoneypotError>) and under the HoneypotOutcomeKey context key.
@sdxc/honeypot/ui
<HoneypotFields tokenField token trapField label? /> renders the token as
<input type="hidden"> and the trap as a text input inside a wrapper that is positioned
off-screen, inert and aria-hidden. The trap has tabindex="-1", autocomplete="off" and the
ignore attributes 1Password, LastPass and Bitwarden honor. label (default
"Leave this field empty") labels the trap. Form bots skip display: none and hidden inputs,
which is why the trap stays in the layout.
Pattern: Answer a bot like a success
A refusal teaches a bot to adapt. Answering with the page a person would see after submitting teaches it nothing.
import { honeypot as honeypotMiddleware } from "@sdxc/honeypot/middleware";
router.post("/contact", {
middleware: [
honeypotMiddleware(honeypot, {
onFailure(error) {
if (error.code === "misconfigured") return new Response(null, { status: 500 });
return Response.redirect(new URL("/contact/sent", "https://example.com"), 303);
},
}),
],
handler: sendMessage,
});
Pattern: Score what passes with a spam filter
The honeypot refuses what no person produces. What passes carries a render time this app signed,
which @sdxc/spam's timing rule scores as one signal
among many.
import { honeypot as honeypotMiddleware } from "@sdxc/honeypot/middleware";
import { unwrap } from "@sdxc/result";
import { createSpamFilter, DEFAULT_RULES } from "@sdxc/spam";
let filter = createSpamFilter({ checks: DEFAULT_RULES });
router.post("/comments", {
middleware: [honeypotMiddleware(honeypot)],
async handler(ctx) {
let form = await ctx.request.formData();
let assessment = await filter.check({
content: String(form.get("content") ?? ""),
renderedAt: unwrap(ctx.honeypotOutcome).renderedAt,
});
return saveComment(form, assessment);
},
});
Pattern: Testing a guarded route
Issue fields in the test the way a page would, then post them.
import { Honeypot } from "@sdxc/honeypot";
import { honeypot as honeypotMiddleware } from "@sdxc/honeypot/middleware";
import { unwrap } from "@sdxc/result";
import { createRouter } from "remix/router";
import { expect, test } from "vitest";
test("refuses a filled trap", async () => {
let honeypot = new Honeypot({ secret: "test" });
let router = createRouter();
router.post("/contact", {
middleware: [honeypotMiddleware(honeypot)],
handler: () => new Response("sent"),
});
let fields = unwrap(await honeypot.issue());
let body = new URLSearchParams({ [fields.tokenField]: fields.token, [fields.trapField]: "spam" });
let response = await router.fetch("https://example.com/contact", { method: "POST", body });
expect(response.status).toBe(400);
});