A mechanical sports stopwatch with its hand near the sixty mark

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

Content-Security-Policy — served on every response
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-requests

It 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:

next.config.js
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

Third-party resources blocked by a self-only CSP and their first-party replacements
Blocked by defaultWhat ships instead
Font CDNsAll three typefaces self-hosted at build time via next/font
Analytics scriptsServer-side signals — no client tracking script at all
Video embedsA static poster image linking out, or a click-to-load facade
Chat widgetsA contact page that loads instantly
CDN-hosted JS librariesBundled 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.

Written by

Karol

Senior engineer and systems architect behind Tall Karol. Everything published here is grounded in real client work — no roundups, no tools that haven't run in production.

Why Tall KarolWork with Tall Karol

Related notes

More on web development

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