Every WordPress security guide is a list of things you must keep doing forever: patch core weekly, audit plugins, rotate salts, install a WAF, monitor for injected admin users. The advice is sound, and it exists because the architecture leaves a database, a PHP interpreter, and a public login form exposed to the internet at every moment.
Static architecture takes a different approach to the same problem. Instead of defending the runtime, it deletes the runtime. There is no database to inject into, no interpreter to execute an uploaded payload, and no /wp-admin for a botnet to brute-force. What ships to the edge is a directory of files.
This piece walks the actual threat model — what disappears, what genuinely remains, and how to configure the headers and pipeline that protect what is left.
What actually disappears when the database goes
The clearest way to see the difference is to map the request path. A dynamic CMS resolves a page by executing code against a database on every single request:
DYNAMIC CMS REQUEST PATH
Browser ─► CDN ─► Origin server ─► PHP-FPM ─► Plugin stack ─► MySQL
│ │ │ │
[SSH keys] [RCE, LFI] [plugin CVEs] [SQLi, dump]
│
/wp-admin ──► [brute force, credential stuffing]
STATIC SITE REQUEST PATH
Browser ─► Cloudflare edge ─► static file
│
(no execution)
Build happens elsewhere, on a schedule you control:
Git push ─► CI runner ─► hugo --minify ─► upload artifact
│
[supply chain: this is the real remaining surface]Whole vulnerability classes vanish because their preconditions vanish:
- SQL injection requires a query built at request time. No query, no injection.
- Remote code execution via plugin requires an interpreter running third-party code. There is no interpreter.
- Arbitrary file upload to a web-executable directory requires that directory to execute. Static hosts serve
.phpas a download or a 404. - Credential stuffing against the CMS login requires a public login. There isn’t one — publishing happens through Git.
- Privilege escalation via user roles requires a user table. There is no user table.
That is not a hardening exercise. Those are structural impossibilities, and they hold without anyone remembering to patch anything.
The three risks that genuinely remain
Honest security writing names what is left. On a static stack, three things can still hurt you.
Supply chain compromise in the build. Your CI runner executes npm ci, pulls Hugo modules, and runs whatever those packages contain. A malicious transitive dependency can inject a crypto-miner or a credential exfiltrator into your generated HTML, and it will deploy to the edge with a valid signature and a green checkmark. Mitigations: commit lockfiles, pin GitHub Actions to full commit SHAs rather than mutable tags, and enable Dependabot on the build manifest.
Hosting and DNS account takeover. With no origin server to break into, the attacker’s cheapest path is your Cloudflare or GitHub account. Hardware security keys on both, plus scoped deploy tokens instead of personal access tokens, closes most of it. This is the risk that is now materially larger in relative terms — not because it grew, but because everything around it shrank.
Third-party client-side JavaScript. Analytics, chat widgets, and embedded forms run in your users’ browsers under your origin. A compromised widget host is a compromised site. This is precisely what a strict Content-Security-Policy exists to contain, and it is the one place where the static advantage requires active configuration rather than passive architecture.
Why a real CSP is only practical on a static site
Content-Security-Policy is widely recommended and rarely implemented well, because on a plugin-driven CMS you cannot enumerate what your own pages load. Some plugin somewhere emits an inline <script>, so you add unsafe-inline, and the policy stops preventing the attack it was written for.
On a static site you generated every byte, so you can be strict. A Cloudflare Pages _headers file:
/*
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googletagmanager.com; style-src 'self'; img-src 'self' data: https://www.google-analytics.com; connect-src 'self' https://www.google-analytics.com; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=(), interest-cohort=()Three details are doing most of the work. object-src 'none' kills legacy plugin-embed vectors. base-uri 'self' blocks base-tag injection, which otherwise lets an attacker who achieves any HTML injection redirect every relative URL on the page. frame-ancestors 'none' ends clickjacking without needing the older X-Frame-Options.
Deploy in report-only mode first. Add Content-Security-Policy-Report-Only with the same policy, watch the violation reports for a week, then promote it. Skipping that step is how teams ship a policy that silently breaks their own analytics.
Comparing the two postures honestly
| Dimension | Dynamic CMS (WordPress + plugins) | Static (Hugo + Cloudflare Pages) |
|---|---|---|
| Public attack surface | Origin server, PHP, DB, admin login | CDN edge serving immutable files |
| Patch cadence required | Weekly, indefinitely | Only at build time, non-urgent |
| Typical CVE exposure | Core plus every installed plugin | Effectively zero in the serve path |
| Compromise blast radius | Full DB read/write, persistent backdoor | Requires account or CI takeover |
| Recovery from compromise | Restore DB + files, rotate all secrets | git revert and redeploy |
| Realistic CSP strictness | unsafe-inline usually mandatory | No unsafe-inline achievable |
| Cost of a WAF to compensate | A recurring subscription | Not needed for injection classes |
The recovery row is the one operators underrate. When a static deploy is bad — compromised dependency, defaced content, anything — the fix is reverting a commit and rebuilding. Your entire site’s history is in Git, byte-for-byte reproducible. There is no “is the backdoor still in the database?” phase, because there is no database.
Keeping the features you thought needed a database
Migration usually stalls on three functions. Each has a mature static-compatible answer:
Contact forms. Post to a Cloudflare Worker, Formspree, Netlify Forms, or a Google Form. The submission endpoint holds the data; your origin stays read-only. Validate and rate-limit inside the Worker if you own it.
Site search. Generate a JSON index at build time and filter it client-side. For a few hundred pages this is instant and costs nothing. Past a few thousand, move to a hosted index such as Pagefind or Algolia — still no database on your side.
Comments. Use a hosted service, or accept that most business sites get more spam than discussion and drop them. This is often a feature removal rather than a loss.
Personalization. Handle at the edge with a Worker reading a cookie, not by rendering the page dynamically. The specifics of edge logic without giving up cacheability are covered in edge caching and Cloudflare WAF rules for static sites.
If you are coming from a heavy install, the content and URL-preservation side of the move is the harder half — see migrating heavy WordPress sites to Hugo for the redirect and taxonomy mapping work that has to happen alongside the security gain.
The search consequence nobody plans for
Security is not usually filed under SEO, right up until a compromise. Injected spam pages trigger a manual action; Safe Browsing flags produce an interstitial that destroys click-through; a hacked-content penalty takes weeks to lift after cleanup. The recovery window is the expensive part — removing the malware is the fast half, and re-earning Google’s trust is the slow one.
There is a quieter benefit too. AI crawlers and RAG pipelines have short patience — slow or intermittently erroring origins get partially indexed and then deprioritized. A static origin serving from the edge responds fast and effectively never 5xx’s, which means crawlers complete their traversal. Which of those crawlers you admit, and how you tell them apart, is set in robots.txt — see robots.txt directives for AI crawlers.
Next step
Enumerate your request path today. List every process that runs between the CDN and the byte your user receives: interpreter, plugin, database, cache layer. Anything on that list is something you are committing to patch forever. Then decide, for each item, whether it earns its place or whether a build-time equivalent exists.
For most content and local-business sites, the honest answer is that nothing on the list earns it. A MarketLens Standard audit will print the exact headers, exposed endpoints, and third-party script origins found on your live site so you can see the surface before you decide.
MarketLens