Prima Get an API key

Module kit / Forms and leads

Form Spam and Abuse Protection

forms-leads/form-spam-and-abuse-protection

Kind backend
Domain forms-leads
Maturity prod
Reusable extract
Stack fastapi-0.118, starlette-0.48, pydantic-2.12, sqlalchemy-2.0-sync, redis-6.4, react-19, axios-1, web-crypto-subtle
Depends on _shared/fastapi-project-layout, _shared/error-envelope, _shared/test-layout
Frontends forms-leads/ui/form-builder-page
Reference implementation
  • alchemy_labs/backend/services/form_protection_service.py
  • alchemy_labs/backend/api/routers/protection_router.py
  • alchemy_labs/backend/api/routers/public_forms_router.py
  • alchemy_labs/frontend/src/hooks/useFormProtection.tsx
  • alchemy_labs/backend/tests/v2/coverage/test_forms_g11_public_forms_router_endpoints_protection_router_testclient_no_auth_public_flow_.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

A public, unauthenticated form (or booking) endpoint stays reachable by real visitors while scripted and bot submissions are rejected, without a third-party CAPTCHA: four independent, invisible layers (honeypot field, submission-timing window, per-IP rate limit, browser-computed proof-of-work) each get a vote, and the moment any one of them fails, the request collapses into the exact same HTTP 200 {"success": true} a clean submission gets, with a sentinel zero UUID instead of a real one, the invariant that makes it trustworthy against an attacker probing the endpoint is that nothing about the response (status code, body shape, timing-visible branch) tells a bot which layer, or whether any layer, rejected it (submit_public_form, public_forms_router.py 636-661).

When to use

Trigger phrases: "honeypot field", "form spam", "bot submission blocked", "rate limit form", "proof of work form", "PoW challenge", "form_protection", "protection_router", "why is this a fake success", "_protection_token", "pow_challenge", "the widget doesn't render a honeypot". Read it before touching FormProtectionService.validate_submission, _check_rate_limit/_check_rate_limit_memory, _verify_pow/solve_pow, _validate_enhanced_protection, _is_bot_submission, or useFormProtection's solvePoWSync/getProtectionData/HoneypotField.

Not this recipe: the form builder/editor UI and the FormTemplate/FormField/FormEmbed CRUD (forms_router.py), a neighbouring recipe; the lead/CRM side effects a clean submission triggers (services/forms/lead_integration.py, process_submission_lead, cited only for context at public_forms_router.py 40); session/partial-save management (FormSessionService, session/start, session/save, session/email, session/{token}), a neighbouring recipe on the same router file, out of scope here even though its tests live in the same coverage file (see Tests); the AI-generation branch of /submit-ai (Prima call, prompt substitution), it reuses this exact protection path unmodified (public_forms_router.py 1080-1115) but the generation itself is a different recipe; the calendar booking endpoint /api/calendar/public/book/{slug}, it is the OTHER, and at this commit the ONLY, real caller of useFormProtection.tsx (form_type='booking', BookingCalendar.tsx 152, 421) but its router/schema are not among this recipe's source files and are not covered here (Gotchas has the consequence of this for the public-forms surface).

Stack assumptions

At 62ba4fc4: fastapi==0.118.2, starlette==0.48.0, pydantic==2.12.0, SQLAlchemy==2.0.43 (not touched by this recipe directly, no table), redis==6.4.0 / psycopg2-binary==2.9.10 (reached only via core.config.settings); frontend react ^19.1.1, axios ^1.11.0 (via services/api), TypeScript ^4.9.5, browser-native crypto.subtle.digest('SHA-256', ...), no PoW/WASM library. Foundations assumed: S4 (fastapi-project-layout) for mount_router/surface_exempt/core.config.settings (app_fastapi.py 904, 949); S7 (error-envelope) for the app-wide @app.exception_handler(HTTPException) that rewrites every HTTPException this router raises into {"error","code","request_id","timestamp","details"} (app_fastapi.py 724-725, measured directly, see Gotchas, it is the reason the coverage file's non-protection assertions fail today); S9 (test-layout) for the client/db_session TestClient fixtures the coverage test imports from tests/conftest.py.

Environment, all in core/config.py: SECRET_KEY (line 58, SecretStr, min_length=32, defaults to a 32+ char dev string), FormProtectionService.__init__ reads it once via .get_secret_value() (form_protection_service.py 53) and every challenge token's HMAC is signed with it, so rotating SECRET_KEY invalidates every challenge issued before the rotation (a visitor mid-form at rotation time gets "Invalid security token"); REDIS_ENABLED (line 388, default True) and REDIS_URL (389), the service-level rate limiter only touches Redis when REDIS_ENABLED is true AND the client's own ping() succeeds (form_protection_service.py 70-88), otherwise it falls back silently to the per-process in-memory limiter, logged once at WARNING. Fail behaviour: FormProtectionService.validate_submission and every layer it calls return (bool, Optional[str]) and never raise; the two protection_router.py endpoints do not wrap themselves in try/except, so an exception there (not observed in testing) would fall through to the app-wide @app.exception_handler(Exception) (500, app_fastapi.py 795-796).

Data model

No database table. Two separate in-process stores, both lost on restart and not shared across replicas unless Redis is configured:

  • FormProtectionService._rate_limits: Dict[str, list[float]] (form_protection_service.py 35), a class-level defaultdict(list) guarded by FormProtectionService._rate_limit_lock (a threading.Lock, line 36), keyed f"{form_type}:{client_ip}", values are raw submission-attempt Unix timestamps, pruned to the trailing 3600s on every check (_check_rate_limit_memory, 246-267).
  • public_forms_router._rate_limit_store: Dict[str, list[float]] (public_forms_router.py 62), a SEPARATE, unrelated defaultdict(list) backing the router's own legacy _check_rate_limit (73-95), keyed by a SHA-256 IP hash truncated to 16 hex chars (_get_ip_hash, 68-70) rather than the raw IP the service-level limiter uses. The two limiters share no state and use different windows/limits (service: 3600s, 5/hr form or 3/hr booking; router: 60s, 10/min), both are consulted independently for a single /submit call (Application logic).

Redis, service-level limiter only, only when configured and reachable: key form_protect:{form_type}:{client_ip} (form_protection_service.py 232), a fixed-window INCR + EXPIRE 3600 on first increment (233-236), the raw client IP is the key material here, unhashed, unlike the router's own hashed in-memory store.

The signed timing token is not stored anywhere; it is a stateless, self-contained string f"{timestamp}:{nonce}:{form_type}:{signature}" (generate_challenge, 108-110) verified by recomputing the HMAC (_validate_token, 176-211), there is no server-side token registry, so a token can never be revoked early, and (Security & tenancy) is never checked for reuse.

What is NOT stored anywhere in this recipe: the honeypot value a bot enters (logged only as a boolean "triggered", the string itself is never persisted, form_protection_service.py 146-149); the PoW solution, past the single request that used it; a bot-blocked attempt's submitted data, it is recorded only as a FormAnalytics(event_type='bot_submission_blocked') row carrying an IP hash, never the payload (public_forms_router.py 641-652).

API surface

RouteAuthBodyCodesNotes
GET /api/protection/challenge?form_type=form|booking (protection_router.py 39-60)none, surface_exempt("public", "anti-abuse checks called from public form embeds") (app_fastapi.py 904)none200 ChallengeResponse{token, pow_challenge, pow_difficulty, honeypot_field}Calls form_protection.generate_challenge(form_type) (94-124); the service returns a 5th key, timestamp (123), silently dropped because ChallengeResponse (16-21) doesn't declare it (Gotchas)
POST /api/protection/validate (protection_router.py 63-91)none, same surface_exemptValidateRequest{token, pow_challenge, pow_solution, honeypot_value?, form_type="form"}200 ValidateResponse{valid, error}; 422 on a missing required fieldclient_ip resolved from X-Forwarded-For (first value) else request.client.host (74-80), a near-duplicate of get_client_info in the other router; exists as a standalone endpoint but nothing in this repo's frontend calls it directly (Gotchas)
GET /api/f/{embed_code}/page (public_forms_router.py 1680-1814)none, surface_exempt("public", "form embeds on client sites, no auth by design") (app_fastapi.py 949)none200 HTML shellGenerates its own challenge inline via form_protection.generate_challenge(form_type="form") (1760-1761) and injects it into window.FORM_CONFIG.protection (1783) for the static widget to consume, see Gotchas for whether it actually does
POST /api/f/{embed_code}/submit (public_forms_router.py 591-...)none, same mountPublicSubmissionRequest (schemas/forms.py 653-666)200 always on the protection path (fake success on reject, see Purpose); 404 unknown/non-public form; 422 missing dataRuns enhanced (_validate_enhanced_protection) THEN legacy (_is_bot_submission) checks, ORs the two failures (625-661, Application logic)
POST /api/f/{embed_code}/submit-ai (public_forms_router.py 1037-...)none, same mountsame PublicSubmissionRequestsame shape, mirrors /submit's protection block verbatim (1080-1115)Out of scope beyond the shared protection call (When to use)

Direct Python entrypoints a rebuild reimplements, not routes but the actual surface:

FunctionNever raisesReturns
FormProtectionService.generate_challenge(form_type) (94-124)yes{token, pow_challenge, pow_difficulty, honeypot_field, timestamp}
FormProtectionService.validate_submission(token, pow_challenge, pow_solution, honeypot_value, client_ip, form_type) (130-170)yes, each layer returns a tuple(is_valid: bool, error: Optional[str])
public_forms_router._validate_enhanced_protection(submission, ip_address) (140-182)yes(is_valid, error); (True, None) when NO protection data is present at all (163-165, backward-compat)
public_forms_router._is_bot_submission(data, session, ip_address) (120-137)yesbool, legacy honeypot-in-data + timing + rate-limit, OR'd

Application logic

Challenge issuance, generate_challenge (94-124), in commit order: mint timestamp = int(time.time()) and a 16-byte hex nonce (104-105); sign f"{timestamp}:{nonce}:{form_type}" with HMAC-SHA256 truncated to 32 hex chars (_sign, 336-342) and concatenate into the 4-part token (108-110); mint an unrelated 16-byte hex pow_challenge (113, no relation to the token's nonce); pick one of 5 honeypot field names uniformly via secrets.choice (116, HONEYPOT_FIELDS = ['website','url','company_website','homepage','fax'], line 50); return all five values, difficulty fixed at POW_DIFFICULTY = 4 (47, "requires ~65k hash attempts, ~100-500ms" per the module's own comment).

validate_submission (130-170), in commit order, first failure short-circuits (no partial credit):

  1. Honeypot: any truthy honeypot_value → reject, generic "Submission rejected", logged with the IP but never the value (145-149).
  2. Token/timing (_validate_token, 176-211): 4-colon-part shape check; HMAC recomputed over the first 3 parts and compared with hmac.compare_digest (185-190, constant-time); the token's OWN form_type must equal the caller's declared form_type (192-194, so a booking challenge cannot validate a form submission or vice versa); then elapsed = now - timestamp must be ≥ MIN_SUBMISSION_TIME_SECONDS (3) and ≤ MAX_SUBMISSION_TIME_SECONDS (1800) (196-205).
  3. Rate limit (_check_rate_limit, 217-244): tries Redis first (INCR+EXPIRE, fixed window), falls back to the in-memory sliding-window limiter on any Redis exception; limit is BOOKING_ATTEMPTS_PER_HOUR=3 for form_type=="booking", else FORM_SUBMISSIONS_PER_HOUR=5 (43-44, 223-227).
  4. Proof-of-work (_verify_pow, 286-311): SHA256(challenge + solution) must start with POW_DIFFICULTY zero hex digits (298-304); solve_pow (313-330, its own docstring says "for testing purposes... in production, this runs in the browser") is the reference brute-force solver, an unbounded while True incrementing an integer nonce.

Router orchestration for /submit and /submit-ai (identical shape, public_forms_router.py 625-661 / 1080-1115): call _validate_enhanced_protection first, it reads the flat _protection_* attributes THEN the nested protection object, preferring whichever is truthy per field (151-161); if NEITHER token nor pow_challenge is present at all, it returns (True, None) unconditionally, the backwards-compatibility escape hatch (163-165); if a token is present without a pow_solution it is flagged suspicious and rejected without ever calling the service (167-170, partial-payload guard). Independently of that result, _is_bot_submission always runs the three legacy checks against the submitted data/session/IP (634). is_valid (enhanced) and is_bot (legacy) are OR'd: either one failing produces the fake-success branch (636-661), a FormAnalytics(event_type='bot_submission_blocked') row is written and committed, then the same PublicSubmissionResponse(success=True, submission_id=ZERO_UUID, ...) a clean submission would get is returned, no 4xx anywhere on this path.

Invariants

  • A blocked submission is observationally identical, at the HTTP layer, to a successful one: same status code, same response shape, the only tell is the sentinel 00000000-0000-0000-0000-000000000000 submission id (test_submit_honeypot_returns_fake_success_and_logs_bot).
  • No protection data at all is explicitly allowed through the enhanced layer (backward compatibility for callers with no useFormProtection integration); the legacy layer still runs regardless, so a bare POST with a filled legacy honeypot field is still blocked (_validate_enhanced_protection 163-165 vs _is_bot_submission 634, independent).
  • A signature failure and a stale/future timestamp are reported through the SAME generic message shape ("Invalid security token" / a specific but non-diagnostic string), never revealing which byte of the 4-part token was wrong (_validate_token 176-211).
  • The PoW check is a pure function of (challenge, solution) with no reference back to the token or its timestamp, a solved PoW from one challenge cannot be replayed against a DIFFERENT token's validation call only because validate_submission requires all of token-timing, rate-limit AND pow to pass together, not because PoW itself is bound to the token (Security & tenancy has the resulting nonce-reuse gap).
  • The two rate limiters (Data model) are both consulted and neither is authoritative alone: a request under the service-level Redis/in-memory limit can still be blocked by the router's own separate hashed-IP limiter, and vice versa (_is_bot_submission calls _check_rate_limit independently of _validate_enhanced_protection's own call into the service).

Frontend

useFormProtection.tsx is the real, working, hand-verified hook the task names: solvePoWSync (110-135) does the actual work, real SHA-256 via crypto.subtle.digest, in batches of 5000 nonces yielding to the event loop between batches (await new Promise(resolve => setTimeout(resolve, 0)), 133) so the UI thread stays responsive without ever actually blocking on it; getProtectionData() (175-194) waits for a pre-solved PoW (kicked off in the mount effect, 154-159) or solves synchronously if the caller races ahead of it, and returns the FLAT shape {_protection_token, _protection_pow_challenge, _protection_pow_solution, _protection_honeypot}; HoneypotField (199-231) renders one absolutely-positioned, zero-size, aria-hidden, tabIndex={-1} input named after challenge.honeypot_field, a value a screen-reader user or keyboard-only human will never reach, but a naive bot's form-filler will.

Its one real caller in this repo at this commit: BookingCalendar.tsx 152 (useFormProtection('booking')) and 421 (getProtectionData(), spread into the booking POST body at 438). Frontend module that would render the ADMIN side of this feature, by id:

forms-leads/ui/form-builder-page   the form builder/editor UI where a workspace owner
                                    creates and configures a FormTemplate/FormEmbed;
                                    it never calls generate_challenge or validate_submission
                                    itself (those only run on the PUBLIC, unauthenticated
                                    surface), it is this recipe's required frontend_modules
                                    entry because no other written frontend recipe touches
                                    forms-leads yet, not because it renders this protection
                                    layer at runtime (see Gotchas for what actually does, and
                                    doesn't, consume the challenge on the public embed)

Minimum admin/debug surface (no dedicated route exists at this commit): the FormAnalytics rows with event_type='bot_submission_blocked' and an event_data.ip_hash (public_forms_router.py 641-652) are the only durable trace of a block; logger.warning lines "Honeypot triggered from IP %s", "Token validation failed from IP %s: %s", "Rate limit exceeded from IP hash: %s", "PoW verification failed from IP %s" (scattered across both files) are the rest.

Security & tenancy

Auth dependency: none, both routers mount under surface_exempt("public", ...) (app_fastapi.py 904, 949), by design; there is no workspace/tenant dimension anywhere in this file, form_type and client_ip are the only axes. Fail direction: every protection failure returns HTTP 200 with a fake success rather than a 4xx/429 (Purpose), deliberate, so a bot gets no signal to tune against, but it equally means a real, legitimately-rate-limited human gets a false "Thank you" with nothing recorded and no error to retry against; the seam itself has no closed-on-misconfiguration behaviour to speak of (there is no required credential specific to this feature beyond the app-wide SECRET_KEY, which always has a default).

Secrets at rest: none specific to this feature; SECRET_KEY is the app-wide signing credential (Stack assumptions), not scoped to forms. Rate limits: see Data model/Application logic for the two independent limiters; neither is applied to GET /api/protection/challenge itself, so an attacker can mint challenges (and harvest honeypot field names, since honeypot_field is returned in plaintext by design, Application logic) at unlimited rate, only the SUBMIT side is rate-limited.

Attacker-controlled inputs, all reachable with no credential: client_ip is derived from X-Forwarded-For (first value) or X-Real-IP, trivially spoofable by any caller not sitting behind Alchemy's own proxy since neither header is validated against a trusted-proxy list (get_client_info 185-208, and protection_router.py's own near-duplicate at 74-80), spoofing a fresh IP on every request defeats BOTH rate limiters entirely; pow_solution and honeypot_value are attacker-chosen strings with no length bound before they reach hashlib.sha256/a dict comparison (cheap either way, not a DoS vector at this scale); the PoW nonce/token pairing has no binding between them (Invariants), a solved (challenge, solution) pair, once computed, is valid for verification against ANY still-fresh token of the same request, not just the one it was issued alongside, because _verify_pow never reads the token at all (validate_submission 130-170 calls the three checks independently); this means a pre-computed bank of solved PoW pairs can be reused across many distinct tokens within their own timing windows, which is a real amplification gap in the difficulty-4 PoW's intended cost model, not merely a hypothetical one.

Tests

Run 2026-09-12 against a freshly reset scratch schema (DROP SCHEMA public CASCADE; CREATE SCHEMA public; on the postgres-test service in docker-compose.test.yml, port 5434, the coverage suite's own conftest docstring warns this large a model graph does not always round-trip cleanly between separate pytest invocations otherwise, which is exactly what was hit before resetting), using the venv at alchemy_labs/backend/.venv:

python -m pytest tests/v2/coverage/test_forms_g11_public_forms_router_endpoints_protection_router_testclient_no_auth_public_flow_.py \
    -q --no-cov -o addopts="" -p no:cacheprovider -k "honeypot or protection_challenge or protection_validate"

prints 8 passed, 29 deselected, deterministically (confirmed twice, once standalone per-test and once as the filtered group). Those 8 are the entire honeypot/PoW/timing-token/rate-limit surface this recipe covers: test_submit_honeypot_returns_fake_success_and_logs_bot, test_submit_ai_honeypot_returns_fake_success, test_protection_challenge_returns_full_shape, test_protection_challenge_booking_type, test_protection_challenge_mocked_generate, test_protection_validate_valid, test_protection_validate_extracts_forwarded_ip, test_protection_validate_missing_required_fields_422.

Hermetic vs live: fully hermetic, no network; test_protection_validate_valid/test_protection_validate_extracts_forwarded_ip monkeypatch form_protection.validate_submission entirely (678-716) rather than exercising real PoW math through the HTTP layer, so _verify_pow/solve_pow's actual hashing is NOT exercised by any test in this file; it is only exercised indirectly through the browser's solvePoWSync, with no server-side unit test of a real solved-PoW round trip anywhere in this coverage file. That is a genuine, stated gap, not an invented one.

Known-answer style: test_protection_challenge_mocked_generate monkeypatches generate_challenge to a fixed dict and asserts pure passthrough of token/pow_challenge/honeypot_field; test_protection_challenge_returns_full_shape asserts the exact 4-key response shape (Application logic notes the service's real 5th key, timestamp, is dropped by the router's response model, matching this test).

The REST of the file's 37 def test_ functions cover the neighbouring get_public_form/session/* endpoints (When to use, out of this recipe's scope) and were NOT all green when run: measured 2026-09-12 against this exact commit with a freshly reset schema, 12 of them fail because they assert r.json()["detail"] == "...", the shape of FastAPI's DEFAULT HTTPException body, while the live app's global @app.exception_handler(HTTPException) (S7 error-envelope, app_fastapi.py 724-725) rewrites every raised HTTPException into {"error": "...", "code": "RES_001", "request_id": "...", "timestamp": "...", "details": null} instead, verified directly (GET /api/f/does-not-exist returns {"error":"Form not found","code":"RES_001",...}, no detail key, deterministically and reproducibly in isolation). This is a real, dated discrepancy between the shared test file and the running app; it has nothing to do with this recipe's own protection layers (all 8 of which pass), and is flagged here rather than silently rerun-until-green.

Deploy & ops

Config keys: SECRET_KEY, REDIS_ENABLED, REDIS_URL (all core/config.py, Stack assumptions). No migrations, no table exists for this recipe. No scheduler job: this is pure per-request logic; the in-memory rate-limit dictionaries are pruned lazily on next access (_check_rate_limit_memory 254-258) and never proactively swept, so a distinct (form_type, ip-or-hash) key that never submits again keeps a (small, bounded-length) list in memory until the process restarts, a slow, bounded leak, not a crash risk at plausible traffic.

Argus-style check: GET /api/protection/challenge with no auth returns 200 with a token containing :form: (proves the seam is reachable; there is no external payment-style dependency to verify here, the only external dependency, Redis, fails open by design). Rollback: fully stateless code path, in-memory rate-limit state is lost on every deploy regardless (a fresh process means a fresh window for every IP), and Redis-backed state, if enabled, survives a code rollback untouched since every key carries its own TTL.

Gotchas

  • 2026-09-12 (measured at 62ba4fc4): the enhanced protection payload IS generated server-side for the public embed page and injected into window.FORM_CONFIG.protection (public_forms_router.py 1760-1783), but backend/static/form-widget.js, the actual script GET /{embed_code}/page serves and that renders the real embed, contains zero references to protection/honeypot/pow_ (grep -c on the file returns 0). It never solves the PoW, never renders a honeypot input, never sends any _protection_* field back. useFormProtection.tsx is real and correctly wired, but its only live caller in this repo is BookingCalendar.tsx (152, 421), posting to the calendar booking endpoint, not to /api/f/{embed_code}/submit. For the actual public-form-embed surface this recipe's endpoints serve, enhanced protection is generated and never consumed; only the router's OWN legacy checks (honeypot-in-data, session timing, hashed-IP rate limit) run in practice.
  • 2026-09-12 (measured at 62ba4fc4, schemas/forms.py 661-664 and confirmed against the live venv): PublicSubmissionRequest's four flat _protection_token/_protection_pow_challenge/_protection_pow_solution/_protection_honeypot attributes, commented "Alternative flat fields for simpler integration", are not real Pydantic v2 model fields, PublicSubmissionRequest.model_fields lists only data, session_token, tracking, protection; Pydantic v2 treats a leading-underscore class attribute as private/non-field unless declared via PrivateAttr, so sending those flat keys in a request body is silently dropped and submission.protection stays None. _validate_enhanced_protection's getattr(submission, '_protection_token', None) (public_forms_router.py 151) therefore always reads the class default, never a caller's value; the only way protection data reaches this endpoint's validation is the nested protection object aliased _protection, which is exactly the shape useFormProtection.tsx.getProtectionData() does NOT produce (it returns the flat shape). Combined with the gotcha above, this endpoint's enhanced layer has no live sender at this commit, on either the widget or the (unused-here) React hook's own wire format.
  • 2026-09-12 (measured at 62ba4fc4, useFormProtection.tsx 48-105): solvePoW's inline workerCode string (51-100) builds a Worker-ready source, complete with its own comment "This is a placeholder - we need proper SHA-256" (87) describing a deliberately fake, non-cryptographic simpleHash, but no new Worker(...) ever instantiates it (grep -n "new Worker" on the file: no match); solvePoW ignores the string entirely and calls solvePoWSync(...).then(resolve) (103) instead, which does use real crypto.subtle SHA-256, on the main thread, yielding every 5000 nonces. The "Pre-solve PoW in background" comments (154, 246) describe a Web Worker that does not exist; the real behaviour is main-thread, chunked, yielding, usually fine at difficulty 4 (~100-500ms per the module's own estimate) but not actually off-thread.
  • 2026-09-12 (measured at 62ba4fc4): PoW verification (_verify_pow) never reads the token it is submitted alongside, a solved (challenge, solution) pair is valid against /protection/validate for ANY request carrying that same pow_challenge, independent of which token accompanies it, as long as that token's own timing window hasn't expired (Security & tenancy). This is a real gap in the PoW's intended one-challenge-one-spend cost model, not a hypothetical one; nothing in the reference implementation binds the two together.
  • 2026-09-12 (measured at 62ba4fc4): two unrelated in-memory rate limiters exist for the same feature (FormProtectionService._rate_limits, raw IP, 3600s window vs public_forms_router._rate_limit_store, hashed IP, 60s window), see Data model. A change to one without the other does not change the effective limit a submitter experiences, since both are consulted and either can reject.
  • 2026-09-12 (measured at 62ba4fc4, freshly reset scratch schema, alchemy_labs/backend/.venv): of the coverage file's 37 tests, the 8 that exercise THIS recipe's honeypot/PoW/timing-token/rate-limit surface pass cleanly and deterministically; 12 others, all on neighbouring endpoints in the same file, fail on this exact commit because they assert FastAPI's default {"detail": ...} error body while the app's own global exception handler answers the S7 error-envelope shape instead (Tests). Documented here rather than silently treated as "the test file passes."

Reference implementation

Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.

  • alchemy_labs/backend/services/form_protection_service.py (346 lines, whole file in scope): module docstring (1-11); FormProtectionService class + config constants (29-58); _get_redis (60-88); generate_challenge (94-124); validate_submission (130-170); _validate_token (176-211); _check_rate_limit/_check_rate_limit_memory (217-267); reset_rate_limit (269-280, unused by any caller in these files, grep -n "reset_rate_limit(" finds only its own definition); _verify_pow (286-311); solve_pow (313-330); _sign (336-342); module-level singleton form_protection (346).
  • alchemy_labs/backend/api/routers/protection_router.py (91 lines, whole file in scope): ChallengeResponse/ValidateRequest/ValidateResponse (16-36); get_challenge (39-60); validate_submission (63-91).
  • alchemy_labs/backend/api/routers/public_forms_router.py (1815 lines; this recipe's slice): module docstring (1-11); protection config + legacy helpers (50-137); _validate_enhanced_protection (140-182); get_client_info (185-208); submit_public_form's protection block (625-661) and honeypot-field stripping (674-675); submit_public_form_with_ai's mirrored block (1080-1115, 1126-1127); get_form_page's challenge generation and config injection (1760-1783).
  • alchemy_labs/backend/schemas/forms.py: ProtectionData (642-649); PublicSubmissionRequest (653-666, including the dead flat _protection_* fields, Gotchas).
  • alchemy_labs/backend/app_fastapi.py: router mounts (904, 949); the global HTTPException handler this recipe's error shape depends on (724-725).
  • alchemy_labs/backend/core/config.py: SECRET_KEY (58); REDIS_ENABLED/REDIS_URL (388-389).
  • alchemy_labs/frontend/src/hooks/useFormProtection.tsx (278 lines, whole file in scope): Challenge/ProtectionData interfaces (31-43); solvePoW and its unused workerCode (48-105); solvePoWSync (110-135); useFormProtection (137-276): challenge fetch effect (146-170), getProtectionData (175-194), HoneypotField (199-231), reset (236-258).
  • alchemy_labs/frontend/src/components/BookingCalendar.tsx: the hook's one live caller (152, 421, 438).
  • alchemy_labs/backend/static/form-widget.js: the actual public-embed renderer, which does not consume the challenge this recipe generates (Gotchas), cited for that absence, not as a positive reference.
  • Test: alchemy_labs/backend/tests/v2/coverage/test_forms_g11_public_forms_router_endpoints_protection_router_testclient_no_auth_public_flow_.py (723 lines; this recipe's slice is lines 328-357, 477-489, 641-723), and tests/conftest.py for the scratch-database fixtures.

Deviations of the reference from an ideal implementation, all dated 2026-09-12: the enhanced protection layer has no live sender on the public-forms-embed surface (form-widget.js doesn't read the challenge it's handed; the one real hook caller posts to a different router); the flat _protection_* schema fields are dead Pydantic surface; the PoW solution is not bound to its issuing token; the solvePoW Web Worker path is unreachable dead code; two independent, differently-windowed rate limiters exist for one feature; reset_rate_limit is defined but never called from either router.

Acceptance checks

Run from alchemy_labs/backend at 62ba4fc4 with docker-compose.test.yml's postgres-test service up on port 5434 and its schema freshly reset (DROP SCHEMA public CASCADE; CREATE SCHEMA public;), the suite's own conftest docstring says this model graph needs that between separate invocations.

  1. python -m pytest tests/v2/coverage/test_forms_g11_public_forms_router_endpoints_protection_router_testclient_no_auth_public_flow_.py -q --no-cov -o addopts="" -p no:cacheprovider -k "honeypot or protection_challenge or protection_validate" prints 8 passed, 29 deselected.
  2. grep -n "^HONEYPOT_FIELDS" services/form_protection_service.py api/routers/public_forms_router.py prints two different lists (['website','url','company_website','homepage','fax'] vs ['website_url_hp','fax_number','phone_secondary','company_url_hp']), confirming the two independent honeypot vocabularies.
  3. python -c "from schemas.forms import PublicSubmissionRequest as R; print(sorted(R.model_fields.keys()))" prints ['data', 'protection', 'session_token', 'tracking'], the flat _protection_* names are absent, confirming the dead-field gotcha.
  4. grep -c "protection\|honeypot\|pow_" static/form-widget.js prints 0.
  5. grep -n "new Worker" ../frontend/src/hooks/useFormProtection.tsx prints nothing (no match), confirming the inline workerCode is never instantiated.
  6. curl -s "http://<app>/api/protection/challenge?form_type=booking" | python -c "import sys,json; b=json.load(sys.stdin); assert set(b)=={'token','pow_challenge','pow_difficulty','honeypot_field'}; assert ':booking:' in b['token']; print('ok')" prints ok against a running instance.
  7. grep -n "^def reset_rate_limit" services/form_protection_service.py shows it defined at line 269; grep -rn "reset_rate_limit(" api/routers/protection_router.py api/routers/public_forms_router.py prints no call sites, confirming it is unused by either router.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.