Static sites are great: fast, cheap to host, almost nothing to attack. Then you add a contact form and hit the same wall everyone hits — a static site can't process a submission. You need a backend.
The usual answers are a third-party service (Formspree, Netlify Forms, Basin) or a small server you now have to babysit. Both add a dependency you don't control, a recurring bill, and — the part that bugs me most — your submission data lives on someone else's infrastructure.
There's a third option I've been running for a while: one WordPress install, zero public pages, used purely as a form endpoint. Every form from every static site I own hits it. I own all the data. And because it serves no public HTML, its attack surface is close to nothing.
The architecture
Three pieces, each doing one job:
- WordPress — the backend. Locked down so hard it doesn't behave like a normal WP site anymore.
-
A form plugin — handles building, validation, storage, email, file uploads. (I use CraftForms because it exposes a clean
craftforms/v1REST namespace and can also serve the form HTML to an external page — more on that below.) -
Your static frontend — Cloudflare Pages / Netlify / wherever. It either
fetches the REST endpoint on submit, or drops in an embed snippet.
WordPress never serves a public request. It only processes submissions.
The part that matters: locking it down
The biggest WordPress attack vector isn't your host — it's outdated plugins. So the first move is brutal minimalism: one plugin, no theme, no page builder, no public frontend. A WP install with one plugin and a blocked frontend has almost no CVE surface, because none of the usual stuff is installed.
The rest is one must-use plugin. Drop this in wp-content/mu-plugins/ (no activation needed) and you've blocked the four standard entry points:
<?php
if ( ! defined( 'ABSPATH' ) ) exit;
// 1. Restrict the REST API to your form namespace only.
// Kills user enumeration (/wp/v2/users), route discovery, the usual REST exploits.
add_filter( 'rest_pre_dispatch', function ( $result, $server, $request ) {
if ( strpos( $request->get_route(), '/craftforms/' ) === 0 ) {
return $result;
}
return new WP_Error( 'rest_restricted', 'REST API disabled.', [ 'status' => 403 ] );
}, 10, 3 );
// 2. Kill XML-RPC (brute-force + pingback DDoS amplification).
add_filter( 'xmlrpc_enabled', '__return_false' );
// 3. Block every public frontend request for logged-out visitors.
// Runs in PHP, so it works on Apache, nginx, anything — no .htaccess needed.
add_action( 'template_redirect', function () {
if ( is_user_logged_in() ) return;
status_header( 403 );
nocache_headers();
exit;
} );
template_redirect doesn't fire for REST or wp-admin, so your submission endpoint and the admin panel still work — only public pages get the 403. (The full version in the article also moves /wp-login.php to a secret slug so brute-force scanners can't find it.)
Verify it:
curl https://your-backend.com/wp-json/wp/v2/ # → 403
curl https://your-backend.com/wp-json/craftforms/v1/embed/KEY # → form data
Submitting from the static side
Plain JSON to one endpoint:
const res = await fetch(
'https://your-backend.com/wp-json/craftforms/v1/submit/contact-form',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
}
);
Or skip building the form entirely and let the backend render it — a single embed <div> + script tag, and you get every field type, client-side validation, and even Stripe payments / bookings on a site that has no server of its own.
Why I keep doing it this way
A third-party form service gives you one thing: an email on submit. This setup gives me a real backend I own — SMTP delivery I control, branded HTML emails, a submissions database, file uploads into the Media Library, and (with the embed) full ecommerce/booking on a static site. No per-submission billing, no data on someone else's box.
I wrote the full walkthrough — the complete mu-plugin (including the hidden-login bit), the embed setup, required-header spam protection, and the static-frontend workflow — over on my blog:
👉 Use WordPress as a Locked-Down Form Backend for Static Sites
Disclosure: I build CraftForms, the form plugin used here — but the lock-down approach works with any plugin that exposes a single REST namespace.
Top comments (0)