Our review
Installs a hardened contact form with a 9-layer defense stack (CSRF, honeypot, timing, Turnstile, Resend) into a WordPress/Sage/Acorn theme.
Strengths
- Comprehensive anti-spam and anti-bot architecture
- Shared validation between JS and PHP via validation.json
- Accessible modal with focus trap, aria-live, and role=dialog
- Seamless integration of Resend and Cloudflare Turnstile
Limitations
- Requires a Sage 10/11 theme with Acorn and Blade
- Depends on external Resend and Turnstile accounts
- Not suitable for Laravel-only, plain PHP, or Node projects
When the project uses WordPress with Sage/Acorn and the user requests a secure contact form or advanced spam protection.
For simple forms with low security needs or in projects without a Sage/Acorn theme.
Security analysis
SafeThe skill installs a contact form with spam defenses; it does not contain destructive, exfiltrating, or obfuscated actions. All tools used are within expected scope for a WordPress theme setup.
No concerns found
Examples
Add a contact form to this theme with Resend and Turnstile, and make it spam-proof.Harden the existing contact form against bots by adding CSRF, honeypot, and rate limiting.Set up Resend and Turnstile for the contact form in this Sage theme.name: wp-hardened-contact-form description: Install a production-hardened contact form into a WordPress / Sage / Acorn theme. Ships a 9-layer defense stack (CSRF nonce, honeypot, timing, interaction count, gibberish heuristic, rate limit, field validation, Cloudflare Turnstile, Resend delivery) with a single shared validation.json consumed by both JS and PHP, an accessible glass-lightbox modal (role=dialog, focus trap, per-field aria-live errors), zero-framework tests. Use when the user asks to add a contact form, harden an existing form against spam/bots, add Resend + Turnstile to a WP/Sage site, or set up a form with "no visible CAPTCHA." allowed-tools: Read, Glob, Grep, Bash, Write, Edit, AskUserQuestion
/wp-hardened-contact-form
Install a hardened contact form into a WordPress + Sage 10/11 + Acorn (or plain WP theme with composer.json and Blade) project. Patterns extracted from a site currently running this stack in production.
Trigger examples: "add a contact form to this theme", "harden the form against bots", "set up Resend + Turnstile contact", "install a 9-layer contact form."
Stack requirements
- PHP 7.4+ for this handler's own code (arrow functions, null coalescing, typed returns). Sage 11.2 independently requires PHP 8.3+ — if you're targeting current Sage 11, that's the effective floor. On Sage 10 / plain WP, 7.4+ suffices.
- WordPress 5.0+ —
register_rest_route(4.4+),sanitize_textarea_field(4.7+),wp_remote_post,get_transient,sanitize_email,is_emailall well-established. 5.0 is a conservative floor. - Sage 10 or 11 theme layout (Acorn-based), or any WP theme that can load Blade + register REST routes
- Resend account — https://resend.com
- Optional: Cloudflare Turnstile widget — https://dash.cloudflare.com/?to=/:account/turnstile
If the project doesn't meet the first three bullets, stop and tell the user which assumption failed. Do not try to adapt the endpoint to a Laravel-only, plain-PHP, or Node project — those are outside this skill's scope.
What gets installed
Inside the target theme (paths assume a standard Sage layout — adjust if the project differs):
theme/
├── app/Http/ContactHandler.php (new — 9-layer REST endpoint)
├── app/setup.php (append REST route + wp_head config)
├── resources/
│ ├── data/validation.json (new — shared validation rules)
│ ├── js/contact-modal.js (new — client-side modal controller)
│ └── views/partials/contact-modal.blade.php (new — accessible glass lightbox)
├── tests/
│ ├── contact.test.js (new — zero-framework JS tests)
│ └── contact-validation.test.php (new — zero-framework PHP tests)
└── .env.example (append Resend + Turnstile keys)
The Blade partial is included in the site layout; the JS is imported from the app's main entry; the REST route is registered in setup.php.
Phase 1 — Gather inputs
Use AskUserQuestion to batch. Skip any already derivable from project context.
| Q | What to ask | Derived from |
|---|---|---|
| Project slug | kebab-case, e.g. mybrand | theme folder name or domain stem if not obvious |
| Display name | e.g. "My Brand" | site title |
| Public domain | e.g. mybrand.com | homepage URL |
| Contact inbox | email where messages land | user provides |
| Resend from address | e.g. noreply@mybrand.com | must match a Resend-verified domain; fall back to onboarding@resend.dev for dev |
| Install Turnstile? | yes/no | yes → ask for site key + secret |
From the slug, derive three casings the templates need:
{slug}— lowercase kebab (e.g.mybrand) — REST namespace, transient prefix, log tag{SLUG}— UPPERCASE (e.g.MYBRAND) —window.{SLUG}global{Slug}— PascalCase (e.g.Mybrand) —X-{Slug}-Noncecustom header
Document these in a one-line summary back to the user before writing files, so they can reject a bad derivation early.
Phase 2 — Copy templates + parameterize
Copy each template from this skill's templates/ directory into the target theme, applying the slug substitutions above at copy time.
| Source (in this skill) | Destination (in target theme) | Substitutions |
|---|---|---|
| templates/ContactHandler.php | theme/app/Http/ContactHandler.php | {slug} → project slug; {domain} → public domain |
| templates/validation.json | theme/resources/data/validation.json | no substitutions |
| templates/contact-modal.blade.php | theme/resources/views/partials/contact-modal.blade.php | {SLUG} → uppercase slug |
| templates/contact-modal.js | theme/resources/js/contact-modal.js | {slug} → lowercase; {SLUG} → uppercase; {Slug} → PascalCase |
| templates/contact.test.js | theme/tests/contact.test.js | no substitutions |
| templates/contact-validation.test.php | theme/tests/contact-validation.test.php | no substitutions |
Verify every substitution landed with a single grep -r '{slug}\|{SLUG}\|{Slug}\|{domain}' theme/ afterwards. If anything matches, find + replace manually — those tokens must not survive into committed code.
Phase 3 — Register REST route in setup.php
Open theme/app/setup.php. If the file already imports use Illuminate\Support\Facades\Vite; or similar, it's a Sage setup file — append the contents of templates/setup-rest-route.php to the end (after the existing add_action/add_filter calls, before any closing brace).
If there's no setup.php, create one and make sure it's required from the theme's functions.php (Sage 10/11 does this via collect(['setup', 'filters'])->each(…)).
Apply the same {slug} / {SLUG} / {Slug} substitutions as Phase 2.
Verify by restarting the LocalWP site and visiting https://<site>/wp-json/{slug}/v1/ — you should see a JSON response listing the /contact route with methods: ["POST"]. If it 404s, the route didn't register (check setup.php is actually required from functions.php).
Phase 4 — Include the Blade partial + trigger
In the Blade view where the contact form should be reachable (usually layouts/app.blade.php, front-page.blade.php, or a dedicated /contact page template), add:
@include('partials.contact-modal')
Add at least one trigger — any element with data-contact-trigger opens the modal. Example for a header link:
<a href="#contact" data-contact-trigger class="…">Write</a>
Multiple triggers can exist on the same page; all open the same modal.
Phase 5 — Import the JS into the app entry
In the theme's main JS entry (usually theme/resources/js/app.js):
import { initContactModal } from './contact-modal.js';
document.addEventListener('DOMContentLoaded', () => {
initContactModal();
});
If the entry already has a DOMContentLoaded handler, add the initContactModal() call there.
Also required: the contact-modal.js references /wp-content/themes/{slug}/resources/data/validation.json — verify the path matches your theme's actual slug (WP theme folder name). If it differs, edit the URL in loadValidation() before testing.
Phase 6 — Env vars
Append the contents of templates/.env.example to the theme's existing .env.example (create one if it doesn't exist). Then tell the user to copy to .env and fill in:
RESEND_API_KEY— required. Without it the endpoint returns{ok:false, error:"not_configured"}.RESEND_FROM— for initial testing only,onboarding@resend.devworks without domain verification (Resend docs mark it explicitly as "testing only — use a verified domain for production"). For production, verify your own domain in the Resend dashboard and use anoreply@yourdomainaddress.CONTACT_TO— inbox that receives messages.TURNSTILE_SITE_KEY+TURNSTILE_SECRET_KEY— optional. Empty = Turnstile check skipped silently (dev mode).
Confirm .env is gitignored. Almost every Sage .gitignore already excludes .env + .env.local; verify before proceeding.
Phase 7 — Run tests
node theme/tests/contact.test.js
php theme/tests/contact-validation.test.php
Both should print passed: N failed: 0. If either fails, the most common cause is a path mismatch — the tests look for validation.json and contact-modal.blade.php relative to theme/tests/. Adjust the resolve() / __DIR__ paths if your layout differs.
Critical gotchas — embed these in the project's CLAUDE.md
Landmines from the reference implementation. Teach future sessions to avoid them.
-
Custom
X-{Slug}-Nonceheader is load-bearing. Do NOT "simplify" toX-WP-Nonce. WP's built-inrest_cookie_check_errorsruns on therest_authentication_errorsfilter and validatesX-WP-Nonceagainst a cookie-authenticated user session — if a logged-in editor submits the form, the standard header collides with the logged-in-session nonce check and rejects the request. Using a custom header name keeps the endpoint's permission flow in our ownpermission_callbackfor both anonymous and authenticated visitors. -
Turnstile widget mode: Managed +
appearance: 'interaction-only'. The Cloudflare dashboard widget must be created in "Managed" mode. Client-side, passappearance: 'interaction-only'(documented value — Cloudflare supportsalways,execute,interaction-only) so the widget stays invisible unless a user actually needs to solve a challenge. During the reference build, dashboard-level "Invisible" widget mode failed with HTTP 400 errors fromchallenges.cloudflare.com; the client-sidesizeparameter also does NOT accept'invisible'(documented values:normal,flexible,compact). Use Managed + interaction-only and don't setsizeat all. -
Honeypot field name:
website. Not_gotcha, nothoneypot, notemail_confirm. Common spam-bot libraries include heuristics that skip well-known honeypot names; a plausible-looking field name (website,url,company_url) avoids that detection. -
Silent bot rejection = fake
{ok: true}/ HTTP 200. Never return 400/403 on honeypot, timing, interaction-count, or gibberish failures — that signal tells the bot which layer tripped and lets it train. Only return non-200 for user-fixable errors (invalid email, message too long, rate limit, CAPTCHA failure). -
validation.jsonis the single source of truth. Never hand-sync constants into the PHP handler or JS client — always editvalidation.jsonand let both sides read from it at runtime. The bundled tests each loadvalidation.jsonindependently and assert that expected-behavior cases (typical email, over-length rejection, keyboard-mash gibberish flagging) still work in their respective runtimes; breaking the JSON breaks both test suites. -
Privacy Pass 401s in dev tools. Requests to
challenges.cloudflare.com/cdn-cgi/returning 401 are expected — they're the Turnstile/RFC 9578 Privacy Pass handshake, not a bug. Only worry about 400s. -
Gibberish heuristic false-positives short names. The detector skips strings under
gibberishSkipLengthBelowchars (default 8). If a real user sends just "Hi." it's below threshold and not evaluated. International surnames like Schmidt, Schwarzkopf, O'Brien pass because the vowel ratio is generous (0.15) and consonant-run cap is 6. -
Rate limit resets on serverless cold starts. The endpoint uses WP transients (persistent), so this doesn't affect a traditional WP host. But if you deploy behind a serverless layer (Vercel Functions, Lambda), WP transients may not persist reliably; pair with a WAF-level rate limit if so.
Verification checklist
Before declaring the install done:
- [ ]
curl -I https://<site>/wp-json/{slug}/v1/returns 200 with the/contactroute listed - [ ] Submitting the form as a real user (slow, multiple mousemoves, real email, >10 char message) delivers a Resend email to
CONTACT_TO - [ ] Submitting with the
websitehoneypot populated returns 200 with{ok:true}but no email arrives - [ ] Submitting 4× in 60s from the same IP returns
{ok:false, error:"rate_limited"}on the 4th - [ ] Submitting within 1s of page render returns 200 with
{ok:true}but no email (timing reject) - [ ] Submitting with 0 interactions returns 200 with
{ok:true}but no email (interaction reject) - [ ] Submitting "asdfghjkl qwertyui zxcvbnm" returns 200 with
{ok:true}but no email (gibberish reject) - [ ]
node theme/tests/contact.test.jsandphp theme/tests/contact-validation.test.phpboth green - [ ] Manual a11y sanity: Tab into the modal cycles only through its fields, Shift-Tab cycles backward, ESC closes, field errors are announced by a screen reader (VoiceOver / NVDA)
Reference implementation
Extracted from a production WordPress theme currently running this stack. The gotchas in this file are lessons from that specific build — each one cost time to discover, and they're documented here so you don't have to rediscover them. If the user wants to see the pattern in the wild, they can share a live URL.
Security Audit Scanner
Security
Analyzes code to detect OWASP Top 10 vulnerabilities.
OWASP Security Checklist
Security
Generates application security checklists based on the OWASP Top 10.
Threat Model Generator
Security
Generates threat model documents with STRIDE analysis.