Min-Fill Signed Token
Spec for the min-fill signed token — a stateless anti-automation control that rejects submissions completing faster than a human plausibly could. It is the natural companion to the honeypot shipped in 0.3.x and corresponds to item 1d in the Production Hardening backlog (target: 0.4.x).
Assumes an htmx front end. This spec is written for embedding pages that use htmx: forms are loaded and submitted with
hx-get/hx-post, and the server responds with HTML fragments that htmx swaps into the DOM (not JSON). That assumption is not incidental — it makes both token issuance shapes below htmx-native and removes the “needs custom JS” cost. See Cross-cutting htmx effects for the parts of htmx that reach past min-fill.
The idea: when a form is loaded, the server issues a signed timestamp; when
the submission arrives, the server checks that at least min_fill_seconds have
elapsed since issuance. The signature (an HMAC) makes the timestamp
unforgeable and un-backdatable by the client, so a bot can’t just post
issued_at = now - 1h. The proof travels in the token, so no server-side
state is needed — consistent with the stateless-Lambda design.
The prerequisite: a per-request token, minted server-side
A min-fill token is only meaningful if it is minted fresh on each form load.
Today form.RenderHTML(form, action) runs only in the form html CLI —
forms are rendered offline and embedded as static HTML. There is one Lambda route
(POST /api/v1/submit/{form}) and no request-time render. A token baked into
static HTML is identical for every visitor and every load — useless (its elapsed
time is time-since-build, and the token is public and reusable forever).
So the feature needs a server path that issues a fresh token per form load.
Under htmx this is not a reluctant scope-expansion — serving HTML fragments over
hx-get is exactly htmx’s model, so the issuance path is wanted infrastructure,
not overhead grafted on for min-fill.
Issuance — both endpoints are supported
Under an htmx front end the old “add a JSON endpoint + hand-write JS to inject the token” option is obsolete — htmx doesn’t want JSON, and its declarative attributes mean no custom JavaScript either way. The relay will expose both htmx-native issuance shapes, so a site can use whichever fits — and, decisively, so the server-render path (A) exists as the foundation for conditional fields later (see below). Both endpoints mint the token identically (the token scheme); they differ only in how much HTML they return.
- A. Relay renders the whole form (
GET /api/v1/form/{form}→ HTML form fragment). The page does<div hx-get="https://relay.example.com/api/v1/form/contact" hx-trigger="load" hx-swap="innerHTML"></div>. The Lambda server-renders the form via the existingform.RenderHTMLand injects a freshly minted<input type="hidden" name="_ts">. The relay owns one source of truth for the form markup; the token is always fresh because the form is always server-rendered. Requires a GET route + a method router incmd/submit/main.go. - B. Relay mints just the token, as an HTML fragment
(
GET /api/v1/token/{form}→ an<input type="hidden">fragment). The site owns its own form HTML and drops in<input hx-get="https://relay.example.com/api/v1/token/contact" hx-trigger="load" hx-swap="outerHTML">; htmx replaces that element with the server’s fresh hidden-input fragment. Minimal — the relay stays a sink + token-minter and never renders whole forms; the site keeps full control of markup.
When to use which: B for a site that owns its markup and only needs the
server-issued token (the lightest path). A when you want the relay to be the
canonical form server — which reuses the built, golden-tested renderer and is
the prerequisite for the conditional-fields work below. Supporting both costs
little extra: A and B share the token minting and the same method router; A is
just B plus a full RenderHTML call.
Why A matters: conditional fields later
Server-rendering the form at request time (A) is the foundation for conditional
fields — a deferred item from Form Config v1 (the conditions
non-goal). The htmx pattern for a conditional field is a server round-trip: a
controlling input carries hx-get="…/form/contact?…" hx-trigger="change" hx-target="#dependent", and the server responds with the re-rendered dependent
fragment (a field shown/hidden, or its options recomputed). That only works if
the server renders form fragments at request time — exactly what A
introduces. Building A now for min-fill means conditional fields later is an
extension of an existing render endpoint, not a from-scratch architectural
change. B alone (token only) would not unlock this.
Token scheme (unchanged by htmx)
Standard-library crypto only — crypto/hmac + crypto/sha256 + encoding/base64.
No new dependency.
Construction (at issue time):
message = form_id + "." + issued_at # issued_at = Unix seconds, decimal
mac = HMAC-SHA256(secret, message)
token = base64url(issued_at) + "." + base64url(mac)- The MAC covers
form_idso a token minted for form A cannot be replayed against form B;form_idis not carried in the token body — the verifier knows it from the route and recomputes the MAC. - The token is delivered as a hidden
<input>(default name_ts). htmx serializes hidden fields onhx-postlike any form control, so nothing special is needed to send it.
Verification (at submit time):
- Read the token from its hidden field and strip it from the submission — the same pre-processing the captcha token and honeypot already get, so it never reaches validation, mapping, or any output.
- Split on
.; base64url-decode; parseissued_at. - Recompute
HMAC-SHA256(secret, form_id + "." + issued_at)and compare withhmac.Equal(constant time). Mismatch → invalid. elapsed = now - issued_at; apply the outcomes below.
Outcomes — HTML fragments, not JSON (htmx-shaped)
htmx swaps 2xx responses into the DOM and, by default, ignores 4xx/5xx (it
fires htmx:responseError instead of swapping). So the outcome responses are
HTML fragments with 200 status, differing only in body:
| Condition | Meaning | Response |
|---|---|---|
valid MAC, elapsed >= min_fill_seconds, elapsed <= form_ttl | human-plausible | proceed → 200 + the success fragment |
elapsed < min_fill_seconds | filled too fast → bot | silent drop: 200 + the same success fragment, no relay (byte-identical, so a bot learns nothing) |
| invalid / missing / tampered MAC | forged or absent → bot | silent drop (200 + success fragment, no relay) |
valid MAC, elapsed > form_ttl | stale page — likely a real lingering user | 200 + a “form expired — reload” fragment (htmx swaps it so the user actually sees it; a 4xx would be silently dropped by htmx) |
Two deliberate points:
- Too-fast / forged / missing all return the identical success fragment a real submit returns — opaque to bots, and htmx shows the visitor the normal “thanks” state.
- Expired returns 200 + a reload fragment, not a 4xx — precisely because
default htmx wouldn’t swap a 4xx, so a real user who lingered would otherwise
see nothing. This is the htmx-driven change from the pre-htmx draft (which used
422). It also means we never silently eat a lingering user’s submission.
These fragments are part of a broader “submit endpoint speaks htmx” shift (success/error markup, re-rendered-form-with-errors on validation failure) that is bigger than min-fill — see the cross-cutting note below. Min-fill only needs a success fragment and a reload fragment; if the broader htmx response mode isn’t built yet, those two minimal fragments are enough to ship this feature.
Config surface
The controls live in the existing per-form spam: block (beside honeypot):
forms:
contact:
spam:
honeypot: website
min_fill_seconds: 3 # reject submissions faster than this; enables the feature
form_ttl: 2h # optional; token max age (default 2h). Go duration string
token_field: _ts # optional; hidden field name (default "_ts")
fields: [ ... ]
outputs: [ ... ]min_fill_secondspresent and> 0enables the token check for that form; absent → feature off (ahoneypot-only form is unaffected).- The signing secret is not in the config — it comes from a Lambda env var,
FORM_TOKEN_SECRET, keeping it out of the S3 config (aligns with the “secrets out of config” should-fix). Resolve once at startup. - Fail closed at config load: if any form sets
min_fill_secondsandFORM_TOKEN_SECRETis empty, that is a configuration error surfaced at load / byconfig validate— not a silent per-request failure. - Collision guard:
token_fieldmust not name a declaredfields:entry — reuse the exact parse-time guard the honeypot uses. - Strict parse + schema: the new keys go on
rawSpamunderKnownFields(true); the JSON Schemaspam$defgainsmin_fill_seconds/form_ttl/token_field(regenerate golden +docs/static/config.schema.json).
htmx integration notes
- Token refresh on re-render. When the server re-renders a form (a validation failure that swaps the form back with inline errors), it mints a new token in that fragment, so the clock restarts from the re-render. Because the form is always server-rendered (A) or the token is always server-issued (B), the token is never stale on a fresh interaction.
- No custom JS. Both A and B are pure htmx attributes on the page. htmx sends
Content-Type: application/x-www-form-urlencodedby default, which is exactly what the submit endpoint already requires — no change there. - A bot that skips htmx (raw
POSTwith no token) simply has no valid token → silent drop. A bot that doeshx-get/fetch the token first pays the round-trip and themin_fill_secondswait — see the replay caveat.
Security posture — state honestly
Guarantees: the submission carries a timestamp a server issued, bound to this
form, at least min_fill_seconds ago, unforgeable/un-backdatable without the
secret.
Does not guarantee (document this):
- Not single-use. No server state ⇒ no nonce store ⇒ a valid token is
replayable until it expires: a bot mints one token, waits
min_fill_seconds, and reuses it untilform_ttl.form_ttlbounds the window; rate limiting (0.3.x) caps volume within it. True single-use needs persistence (a DynamoDB nonce set with TTL) — an explicit non-goal (breaks statelessness). - Not proof of a human. It raises automation cost; a patient bot clears it. Keep honeypot + captcha + throttle in place.
Secret rotation invalidates outstanding tokens (mid-fill users get the
“expired/reload” fragment). Optional graceful path: a verify-only
FORM_TOKEN_SECRET_PREVIOUS during a rotation window. Nice-to-have.
Testability & clock seam
Unit-testable if time is injectable — no wall-clock sleeps. Add a clock seam
(now func() time.Time, default time.Now) on the signer/verifier and on
Handler (which today calls time.Now().UTC() inline). Tests mint at t0 and
verify at t0 + Δ to exercise too-fast / ok / expired deterministically.
Unit-testable (red/green/verify): mint→verify round-trip; tamper; wrong-form
binding; boundary elapsed == min_fill_seconds; expired; malformed/empty token;
config parse/validate (enable flag, secret-present fail-closed, token_field
collision); handler branches (too-fast → success fragment + no relay; ok →
relays, token stripped; expired → reload fragment) against a fake relayer + fake
clock. Infra-verified: the issuance routes (A and B) + a deploy probe (load →
immediate submit dropped; load → wait → submit succeeds).
Phased plan (red/green/verify + infra)
Phase 1 — Token engine + verification (red/green) — ✅ done
Delivered across three red/green/verify cycles (engine → config → handler verify), each with an independent adversarial review.
Response idiom: shipped in the endpoint’s current JSON idiom (drop =
200 {"message":"ok"}, expired =422 {"error":"…"}), not HTML fragments. The HTML-fragment conversion is the separate endpoint-wide “htmx-native responses” work; when it lands it upgrades success/drop/expired/validation responses together. Fail-closed is enforced per request in the handler (500 when a min-fill form has no secret), not as a startup check — this stays correct across S3 config hot-reloads.
- Engine —
internal/token: HMAC-SHA256Sign/Verify, form-bound MAC, base64url, injectable clock; round-trip/tamper/form-binding/malformed tests. - Config —
min_fill_seconds/form_ttl/token_fieldonspam:with defaults, window-sanity/negative/collision guards, and the JSON-schema$def. - Handler verify — strip
token_field; missing/forged/too-fast → silent200; expired →422; valid in-window → proceed; window inclusive at both bounds; fail-closed 500 on missing secret. Reviewed (constant-time compare, no secret/token in logs, goldens untouched).
Phase 2 — Token issuance path (infra + red/green) — ✅ code done, deploy-probe pending
Both issuance endpoints shipped (A and B share the token minting and the method
router; A is B plus a RenderHTML call).
- Method router —
handler.Routedispatches onreq.RequestContext.HTTP.Method+req.RawPath:GET …/form/{form}→ render,GET …/token/{form}→ token,POST …/submit/{form}→ submit, else405.cmd/submit/main.gonowlambda.Start(h.Route)and wires the signer fromFORM_TOKEN_SECRET. The two GET routes + aFormTokenSecret(NoEcho) SAM parameter are intemplate.yaml;sam validate --lintpasses. - B — token endpoint (
GET /api/v1/token/{form}): returns<input type="hidden" name="{token_field}" value="…">with a freshly minted token; 404 if the form doesn’t exist or min-fill is off; 500 fail-closed if the secret is missing. Handler unit tests re-verify the minted token. - A — form endpoint (
GET /api/v1/form/{form}): renderer extended with optional server-injected hidden fields (existing goldens byte-identical), and a handler that renders the form with the minted token injected (or plain when min-fill is off). Handler unit tests. - Verify (deploy probe): load via A and via B; submit immediately →
dropped; load, wait
min_fill_seconds, submit → succeeds; submit with no/ garbage token → dropped. (Requires AWS credentials — pending.) - Update docs: htmx integration how-to for both shapes (the
hx-getform snippet for A, thehx-gettoken-input snippet for B, plus thehx-postsubmit); note that A is also the render path future conditional fields build on;CHANGELOG.md; docs rebuild.
Cross-cutting htmx effects (beyond this spec)
Adopting htmx changes more than min-fill; these are flagged here but belong in their own plan items, not this one:
- The submit endpoint should speak HTML, not JSON. Today it returns
{"message":"ok"}/{"error":"…"}. Under htmx, success and — especially — validation errors want to be HTML fragments (ideally the form re-rendered with inline messages), so htmx can swap them. This is the single biggest htmx change and is a prerequisite the min-fill fragments lean on. It also composes with endpoint A above: the request-timeRenderHTMLpath A introduces is exactly what a “re-render the form with inline errors” response reuses, so building A brings this within reach. Specced separately in htmx-native Responses. - CORS
AllowHeadersmust include the htmx request headers. htmx addsHX-Request,HX-Current-URL,HX-Target,HX-Trigger, etc. Because these are non-simple headers, a cross-origin htmx request triggers a CORS preflight, which the shipped 0.3.x config (AllowHeaders: [content-type]) would reject. If the relay is on a different origin than the site (the normal case),AllowHeadersneeds theHX-*set. This is a concrete adjustment to the already-shipped Phase 1 CORS work, not a future item. - Success redirect via
HX-Redirect. The deferred success-redirect item (3b) should, for htmx, send anHX-Redirectresponse header rather than a303— htmx acts on the header to navigate. Worth folding into 3b’s design.