
Build Note
Keeping 100 Lighthouse scores behind a strict CSP
A strict Content-Security-Policy costs nothing in Lighthouse — third-party requests do. The exact header, the nonce trade-off, and the workarounds.
Published July 9, 2026
A strict Content-Security-Policy doesn't cost Lighthouse points. tallkarol.com serves default-src 'self' on every response and holds 100s across performance, accessibility, best practices, and SEO. The real trade-off sits elsewhere: a nonce-based CSP in Next.js forces dynamic rendering, so I keep 'unsafe-inline' for scripts and stay fully static. Here's the header, the reasoning, and what it rules out.
Does a strict CSP hurt Lighthouse scores?
No — and the intuition that it might is backwards. A CSP is a set of rules the browser enforces on requests; it adds no measurable runtime cost. What actually tanks performance scores is the stuff a strict CSP forbids: tag managers, font CDNs, chat widgets, tracking pixels, and every other third-party request competing with your content for bandwidth and main-thread time.
Locking the policy to 'self' turns the header into a diet. Every byte on this site is first-party: the three typefaces are self-hosted at build time through next/font, images ship as AVIF/WebP from the same origin through next/image, and there is no client-side analytics script at all. The strict CSP isn't a tax on the performance budget — it's the mechanism that protects it.
Why not a nonce-based CSP?
The textbook answer to 'unsafe-inline' is a nonce-based policy. I looked at it and turned it down, and the reasoning is worth keeping honest.
The mechanics behind that decision: nonces have to be unique per request, which in Next.js means middleware on every route — and that flips statically generated pages to dynamic rendering. Static optimization is exactly what the performance score rests on, so the textbook hardening would have cost the thing being protected. Hash allowlists avoid the middleware but break differently: the framework's inline bootstrap changes across builds and upgrades, so the allowlist becomes a maintenance liability that fails closed in production.
What the header actually looks like
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requestsIt ships alongside Strict-Transport-Security (two years, preload, subdomains included), X-Frame-Options: DENY, a same-origin Cross-Origin-Opener-Policy, X-Content-Type-Options: nosniff, a strict referrer policy, and a Permissions-Policy that disables camera, microphone, geolocation, and payment outright. One less obvious line item: production source maps stay on. Lighthouse's best-practices audit checks for them, and they cost nothing at runtime — browsers only fetch source maps when devtools are open.
The one exception: dev mode needs unsafe-eval
The strict policy above is the production one, and it cannot be the development one. Next's dev server relies on eval(), so serving the production script-src locally kills hydration outright — and the failure is silent. The page renders, nothing throws where anyone would look, and every interactive element simply does nothing. It reads like a React bug for as long as you let it.
Split the directive on the environment rather than loosening the header everywhere, so the exception can never reach production by accident:
process.env.NODE_ENV !== "production"
? "script-src 'self' 'unsafe-inline' 'unsafe-eval'"
: "script-src 'self' 'unsafe-inline'",The general lesson outlives this specific directive: when hardening breaks local development, scope the exception to development in the config itself — the same reflex behind giving a build its own output directory instead of loosening the shared one. A commented-out header somebody re-enables before deploying is not a system, and a policy only enforced in production is a policy you discover problems with in production.
What a locked-down CSP rules out — and what to do instead
| Blocked by default | What ships instead |
|---|---|
| Font CDNs | All three typefaces self-hosted at build time via next/font |
| Analytics scripts | Server-side signals — no client tracking script at all |
| Video embeds | A static poster image linking out, or a click-to-load facade |
| Chat widgets | A contact page that loads instantly |
| CDN-hosted JS libraries | Bundled first-party through the build, tree-shaken |
Every row in that table is a network request that never leaves the origin — which is also why the performance score doesn't wobble between audits. When a client project genuinely needs a third-party embed, the answer isn't reopening default-src; it's a facade that loads the embed on interaction, plus a deliberate, per-origin extension of the policy.
The receipts
- Every route is statically generated — 25 pages out of
npm run build, no server rendering in the request path. - Homepage First Load JS: 113 kB against a hard 115 kB ceiling, enforced on every change.
- This post's route adds 260 bytes of page-specific JavaScript. The entire body — code block, tables, FAQ — is server-rendered.
- Lighthouse: 100 / 100 / 100 / 100, mobile, measured against the deployed URL. Local runs under-report LCP — simulated throttling chains script evaluation into a text paint — so the deployed number is the only one I trust.
The pattern generalizes: set the policy first and let the constraint do the arguing. It's the same set of rules every Tall Karol web build ships with.
Questions
Does a Content-Security-Policy make a website slower?
No. The header is a few hundred bytes and the browser enforces it at request time with no measurable cost. In practice a strict CSP usually makes sites faster, because it forbids the third-party scripts that cause most slowdowns.
Can a site score 100 on Lighthouse with 'unsafe-inline' in script-src?
Yes. tallkarol.com holds 100 in every category with 'unsafe-inline' in script-src. Lighthouse's CSP check lives in Best Practices as an advisory item — on this site it flags the concession without costing the score.
Which security headers should a static marketing site send?
At minimum: a Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options: nosniff, and frame-ancestors (or X-Frame-Options) with a Referrer-Policy. This site adds a Cross-Origin-Opener-Policy and a Permissions-Policy that switches off camera, microphone, geolocation, and payment APIs it never uses.
Related notes
- How to fix failing Core Web Vitals
- What is headless WordPress, and is it worth it?
- Gutenberg vs Elementor: which should you build on?
More on web development
- Claude Code rules for Elementor sites
- Claude Code rules for WCAG testing
- Cursor rules for WCAG testing
- Animating hero text without layout shift
Related service: Web Development
Want this kind of engineering on your project?
Tall Karol takes on fractional and project-based engagements for startups and agencies.
Book a working session