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 existing form.RenderHTML and 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 in cmd/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_id so a token minted for form A cannot be replayed against form B; form_id is 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 on hx-post like any form control, so nothing special is needed to send it.

Verification (at submit time):

  1. 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.
  2. Split on .; base64url-decode; parse issued_at.
  3. Recompute HMAC-SHA256(secret, form_id + "." + issued_at) and compare with hmac.Equal (constant time). Mismatch → invalid.
  4. 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:

ConditionMeaningResponse
valid MAC, elapsed >= min_fill_seconds, elapsed <= form_ttlhuman-plausibleproceed → 200 + the success fragment
elapsed < min_fill_secondsfilled too fast → botsilent drop: 200 + the same success fragment, no relay (byte-identical, so a bot learns nothing)
invalid / missing / tampered MACforged or absent → botsilent drop (200 + success fragment, no relay)
valid MAC, elapsed > form_ttlstale page — likely a real lingering user200 + 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_seconds present and > 0 enables the token check for that form; absent → feature off (a honeypot-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_seconds and FORM_TOKEN_SECRET is empty, that is a configuration error surfaced at load / by config validate — not a silent per-request failure.
  • Collision guard: token_field must not name a declared fields: entry — reuse the exact parse-time guard the honeypot uses.
  • Strict parse + schema: the new keys go on rawSpam under KnownFields(true); the JSON Schema spam $def gains min_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-urlencoded by default, which is exactly what the submit endpoint already requires — no change there.
  • A bot that skips htmx (raw POST with no token) simply has no valid token → silent drop. A bot that does hx-get/fetch the token first pays the round-trip and the min_fill_seconds wait — 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 until form_ttl. form_ttl bounds 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.

  • Engineinternal/token: HMAC-SHA256 Sign/Verify, form-bound MAC, base64url, injectable clock; round-trip/tamper/form-binding/malformed tests.
  • Configmin_fill_seconds/form_ttl/token_field on spam: with defaults, window-sanity/negative/collision guards, and the JSON-schema $def.
  • Handler verify — strip token_field; missing/forged/too-fast → silent 200; 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 routerhandler.Route dispatches on req.RequestContext.HTTP.Method + req.RawPath: GET …/form/{form} → render, GET …/token/{form} → token, POST …/submit/{form} → submit, else 405. cmd/submit/main.go now lambda.Start(h.Route) and wires the signer from FORM_TOKEN_SECRET. The two GET routes + a FormTokenSecret (NoEcho) SAM parameter are in template.yaml; sam validate --lint passes.
  • 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-get form snippet for A, the hx-get token-input snippet for B, plus the hx-post submit); 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:

  1. 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-time RenderHTML path 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.
  2. CORS AllowHeaders must include the htmx request headers. htmx adds HX-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), AllowHeaders needs the HX-* set. This is a concrete adjustment to the already-shipped Phase 1 CORS work, not a future item.
  3. Success redirect via HX-Redirect. The deferred success-redirect item (3b) should, for htmx, send an HX-Redirect response header rather than a 303 — htmx acts on the header to navigate. Worth folding into 3b’s design.