sdxc

Type to search, or start from one of these:

[ Forms & abuse ]

@sdxc/honeypot

Honeypot form fields with a signed timestamp, middleware and a UI component

npm add @sdxc/honeypot
pnpm add @sdxc/honeypot
yarn add @sdxc/honeypot
bun add @sdxc/honeypot
Depends on
remix
Used by
uptime, blog

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? })

  • secret signs 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.

  • tokenField names the hidden token input (default hp-token).

  • trapPrefix starts every trap name (default hp_); eight random letters follow, so browser and password-manager autofill never recognize it.

  • minSeconds refuses a submission sent sooner than that after render (default 0, since a fast person is still a person).

  • maxAge refuses 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:

CodeWhen
missing-tokenNo token field, or a body that is not a form
invalid-tokenMalformed, signed with no configured secret, or an unknown format
trap-filledThe trap field holds any value, whitespace included
too-fastSooner than minSeconds, or issued over a minute in the future
expiredOlder than maxAge
misconfiguredNo 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);
});