Answer first, an allowlist that makes XSS fail closed #
Content Security Policy, or CSP, is a browser security header that names the sources your page may load things from. You declare approved origins for scripts, styles, images, frames, and network connections. When an attacker injects a malicious script through cross-site scripting, the browser checks the allowlist and refuses to run it. Many XSS flaws downgrade from full compromise to a blocked request and a log line.
Think of CSP as a bouncer holding a guest list at the door of your page. Input sanitizing decides who gets an invitation. The bouncer decides who actually walks in. Even if a forged invitation slips through, a name missing from the list goes nowhere.
CSP works best as part of a headers strategy alongside output encoding and disciplined input handling. As stated by MDN in its CSP guide (https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP), the main use of CSP is controlling which resources a document may load, and it is a defense against XSS rather than a substitute for sanitizing input. It is a safety net with real teeth, not a replacement for secure coding.
Try it: Meta Tag Generator — so you can try this on your own site now.
What is XSS and why do allowlists help? #
Cross-site scripting happens when attacker-controlled input is rendered as active code. Stored XSS persists the payload in your database and executes for every viewer. Reflected XSS bounces the payload off a URL or search result. DOM-based XSS triggers entirely in client-side code. All three share one requirement, which is that the browser executes something the attacker supplied. The OWASP XSS overview (https://owasp.org/www-community/attacks/xss/) describes these variants and why escaping output remains the first line of defense.
Traditional fixes focus on the injection point by escaping output and validating input. CSP adds enforcement at the execution point. Even if one rendering path misses escaping, the injected script still needs to satisfy the policy. If it comes from an unlisted host or an inline block you did not approve, it does not run. Defense moves from single-point perfection to layered failure, and layered failure is how real systems survive contact with real attackers.
The injected code can take several shapes: a script tag pointing at an evil host, an inline script block, an inline event handler like onmouseover, a javascript: URL, or a string passed to eval. A tight policy answers every one of those with the same word. No.
| Directive | Controls | Common value | Note |
|---|---|---|---|
| script src | JavaScript sources | nonce or hash plus strict dynamic | Most attack surface |
| style src | Stylesheets | self and trusted hosts | Keep least privilege |
| object src | Plugins | none | Block legacy content |
| base uri | Base tag | none | Stops hijacking |
| connect src | Fetch and sockets | self plus api host | Analytics and auth endpoints |
How do directives control each resource type? #
A CSP policy is a list of directives separated by semicolons, each governing one resource type. The W3C CSP3 draft (https://www.w3.org/TR/CSP3/) defines the full set, but the working set you will touch most looks like this.
script-src controls JavaScript sources and is the most security-critical directive. style-src controls stylesheets. img-src, font-src, and media-src scope images, fonts, and media. connect-src restricts fetch, WebSocket, and analytics endpoints. frame-ancestors controls who may embed your page, which defends against clickjacking. object-src locks down legacy plugin content. base-uri blocks base tag hijacking, where an attacker rewrites relative URLs to point at hostile hosts.
Source values include self for your own origin, explicit hosts for trusted CDNs, and scheme values like https: for broad transport rules. The special token none blocks a type entirely. A sensible baseline blocks objects outright:
Content-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'none'Two more directives deserve attention. upgrade-insecure-requests tells the browser to rewrite http: subresource URLs to https:, which helps during migrations to full transport security. The reporting directives report-to and report-uri tell the browser where to send violation reports, and reports are what make rollout survivable. More on that below.
Keep policies least privilege. Allow only the hosts each page needs, separate development allowances from production, and prefer nonces over broad host lists for inline scripts.
What are nonces, hashes, and framework realities? #
Use per-response nonces or per-block hashes to make CSP work in modern apps such as SvelteKit: the server mints a fresh random nonce per response and stamps approved script tags, or lists SHA-256 digests of approved blocks. Scope: nonces suit server-rendered apps, hashes suit static pages. Constraint: the nonce must differ on every response and must not be predictable, and strict policies forbid inline event handlers and javascript: URLs.
Modern frameworks complicate naive CSP because they use inline scripts for hydration and state. A strict policy that blocks all inline code can break a working app, while a loose policy that allows all inline code barely helps. I have seen teams ping-pong between those two for weeks. There is a better answer.
The practical answer is nonces or hashes generated per response. The server creates a fresh random nonce, adds it to the CSP header, and stamps approved script tags with the same value. Attacker-injected tags lack the nonce and stay blocked. As stated by MDN (https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP), the nonce must differ on every response and must not be predictable, which means the server has to render HTML dynamically rather than serve static markup.
Hashes work the other way. The server hashes each approved inline block with SHA-256 or stronger and lists the digests in the policy. The browser runs a block only when its content matches a listed digest. Hashes suit static pages because nothing changes per response. Nonces suit server-rendered apps because each response mints fresh values.
In SvelteKit the natural home for this is a server hook, as described in the hooks docs (https://svelte.dev/docs/kit/hooks). One sketch looks like this:
// src/hooks.server.js
import { randomBytes } from 'node:crypto';
export async function handle({ event, resolve }) {
const nonce = randomBytes(16).toString('base64');
event.locals.nonce = nonce;
const response = await resolve(event);
response.headers.set(
'Content-Security-Policy',
`script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'`
);
return response;
}Then templates stamp approved tags with that same nonce. Third-party scripts need explicit decisions on top. Analytics, chat widgets, payment elements, and video embeds each require host entries in the relevant directives. Inventory every integration before tightening the policy, or legitimate features will fail alongside attacks.
What makes strict CSP the modern default? #
The CSP posture BYOB recommends, following the OWASP CSP cheat sheet and Google's strict CSP guide, is nonce- or hash-based strict CSP instead of long host allowlists. Strict policies stay small, mark exactly which script tags are trusted, and use strict-dynamic for legitimate dependencies, while sprawling host lists collect stale entries that research shows are routinely bypassed. Scope: nonces per response or hashes per block; check the finished policy with CSP Evaluator or Lighthouse.
Here is the idea worth arguing about: long host allowlists are where CSP goes to die. They sprawl, they collect stale entries, and research has shown they are routinely bypassed through allowlisted hosts that serve hostile content. The OWASP CSP cheat sheet (https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html) states the current leading practice plainly. Build a strict CSP from nonces or hashes instead of a granular allowlist.
A nonce-based strict policy looks like this, as recommended in Google's strict CSP guide (https://web.dev/articles/strict-csp):
Content-Security-Policy:
script-src 'nonce-{RANDOM}' 'strict-dynamic';
object-src 'none';
base-uri 'none';The moving parts are few, and each earns its place. The nonce marks exactly which script tags you trust. strict-dynamic lets a trusted script load the further scripts it needs, which is what makes third-party widgets tractable without enumerating hundreds of hosts. object-src none kills plugin-era attack surface. base-uri none stops base tag hijacking.
The hash-based twin swaps the nonce for digests of approved blocks. Pick nonces when you render per response. Pick hashes when you serve static markup. Either way, refactor away inline event handlers and javascript: URLs first, because strict policies forbid them. Replace onclick attributes with addEventListener calls. That refactor is tedious for an afternoon and then it is done forever.
One caution from the same Google guide: check the finished policy with a checker such as CSP Evaluator or Lighthouse. Small edits can silently weaken a policy, and a checker catches what tired eyes miss.
How do you roll out CSP without breaking production? #
Roll out CSP on your BYOB SvelteKit app in 3 stages: ship report-only mode first, fix missing sources from real violation reports, then enforce during a low-traffic window. Prerequisites: an inventory of every integration (analytics, chat, payments, embeds) plus a reporting endpoint via report-to. Constraint: keep development and production policies distinct, and review reports on a cadence because every new host is a policy change.
Enforcing an untested policy is a fast way to break checkout, auth callbacks, and embeds. Roll out in stages. Start with report-only mode, which sends violation reports without blocking content. Collect reports from real traffic, fix missing sources, and remove unneeded allowances. Then switch to enforcement during a low-traffic window and monitor violations closely.
Wire reporting deliberately. Declare an endpoint, point the policy at it with report-to, and keep report-uri alongside until older browsers fade from your traffic. Read the reports in batches. Each violation names the blocked URL and the directive that blocked it, so the fix is usually obvious: add a host you forgot, nonce a tag you missed, or celebrate a genuine attack the policy just ate.
Treat CSP as living configuration. Every new integration, font host, or API domain is a policy change. Review violation reports on a cadence, keep development and production policies distinct, and pair CSP with neighboring headers for frame control, content-type protection, and transport security.
Sanitize input. Enforce policy. Read reports. Ship the report-only header today, enforce next week, and turn your next XSS from an emergency into a log entry.
What are the trade-offs? #
Strict CSP with nonces or hashes plus report-only rollout: scripts fail closed instead of running injected code.
| Where strict CSP wins | Where it loses |
|---|---|
| XSS payloads blocked by allowlist even when input slips through | Hydration inline scripts break under naive block all inline rules |
| Small nonce based policies beat sprawling host lists that bypass easily | Third party embeds and auth callbacks need inventory before tightening |
| Report-only mode finds missing sources on real traffic | First launch speed may favor report-only while the inventory completes |
Ship report-only on day one when embeds are still uncounted. Enforce once reports run quiet.
Who this is for (and who should skip it) #
Strict CSP fits SvelteKit apps with third-party scripts, embeds, or auth callbacks where injected scripts must fail closed instead of silently running. Skip strict enforcement for a first prototype with no traffic and rapidly changing hosts; stay in report-only mode until real reporting shows the allowlist is complete. Limit: an untested policy blocks checkout, auth callbacks, and embeds along with attacks.
This fits if you ship a SvelteKit app with third party scripts, embeds, or auth callbacks and you want injected scripts to fail closed instead of silently running. Teams that inventory integrations before tightening policy get the most value.
Skip strict enforcement for a first prototype with no traffic and rapidly changing hosts, and keep it in report only mode until real reporting shows the allowlist is complete.
One limit to know. An untested policy blocks checkout, auth callbacks, and embeds along with attacks. Inventory third party hosts first, ship report only, read reports, then enforce in a low traffic window.
- Best for developers adding an allowlist header so injected scripts fail closed.
- Best for startups rolling out CSP in report mode before enforcing without breaking production.
- Best for freelancers hardening client marketing sites against cross-site scripting.
What we learned building this #
BYOB apps render through SvelteKit and run behind platform edge defaults for transport security, while per project headers stay configurable. Our inbox preview isolates rendered email HTML with a strict Content Security Policy of default src none in the inbox preview, which shows how allowlists are applied in practice. For deployed apps the general pattern we follow is platform handled edge security plus app owned content policy tuned per integration.