Rolling Out a Strict CSP Without Breaking Login
A Content Security Policy is the control that decides whether an injection becomes an incident, and it is also the control most often shipped in a weakened form because the strict version broke something on the first attempt. This page is part of the preventing XSS in auth workflows guide and covers a staged rollout that ends with a policy worth having.
Start in Report-Only, on the Login Page First
Deploy Content-Security-Policy-Report-Only with the policy you eventually want, and collect violations before enforcing anything. Nothing breaks, and within a week you have an inventory of every script source your application actually uses — which is invariably different from the list anyone would have written from memory.
Begin with the authentication pages rather than the whole site. They are the highest-value target, they are usually the simplest pages you have, and a strict policy is achievable there long before it is achievable on a marketing homepage full of third-party tags. Shipping a strict policy on login while the rest of the site catches up is a real security gain, not a compromise.
Keep the report endpoint afterwards. A steady baseline of violations from browser extensions is normal; a sudden spike is one of the earliest signals available that something is injecting script into your pages.
Prefer Nonces to Host Allowlists
An allowlist of hosts is easy to write and weaker than it looks: any host on the list that serves user-uploaded content, hosts a JSONP endpoint, or ships a vulnerable library becomes a way to execute script in your origin. A nonce-based policy sidesteps that entirely — only scripts carrying the per-response random value execute, regardless of where they came from.
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{random}' 'strict-dynamic';
object-src 'none';
base-uri 'none';
form-action 'self';
frame-ancestors 'none';
require-trusted-types-for 'script';
report-uri /csp-report
'strict-dynamic' is what makes nonces practical: a script you trusted can load its own dependencies without you enumerating them, while injected markup still cannot execute. It also causes older browsers to ignore host allowlists in the same directive, which is intended — the nonce is the control.
The Directives People Forget
Most weakened policies are weakened by omission rather than by a deliberate unsafe-inline.
base-uri 'none' stops an injected <base> tag from redirecting every relative script URL on the page to an attacker’s host — a complete bypass of a policy that otherwise looks strict. form-action 'self' stops an injected form from posting credentials off-origin, which matters most on exactly the pages this guide is about. object-src 'none' removes a legacy plugin execution path that nothing modern needs.
frame-ancestors 'none' (or an explicit allowlist) replaces the old framing header and prevents clickjacking of your login and consent screens. And require-trusted-types-for 'script' turns unsafe DOM sinks into errors rather than silent risks, which is the difference between auditing sinks forever and having the platform enforce them.
Finding What Actually Breaks
Work the list in order of value: inline handlers in your own markup first, then vendor snippets, then style. Each fix is small; the difficulty is entirely in knowing which ones exist, which is what the report-only phase gives you.
For vendors that insist on inline execution, pass the nonce through where the vendor supports it. Where they do not, the honest question is whether that vendor belongs on your authentication pages at all — a third-party script there executes with your origin’s full privileges, and session-replay tools in particular have a history of capturing form fields.
Reading the Reports
Violation reports arrive as JSON with a handful of fields, and knowing which two matter turns a noisy stream into a work list.
Group by document and directive rather than treating each report individually, because one inline handler on a shared header produces a report per page view. The count matters far less than the distinct combinations, and a work list of eight distinct violations is a morning’s work where a list of eighty thousand reports looks impossible.
Keep the endpoint cheap. Reports arrive unauthenticated from browsers you do not control, so rate-limit them, cap the body size, and store an aggregate rather than every payload — otherwise the reporting mechanism becomes a way for anyone to fill your logging budget.
Enforce, Then Keep It Enforced
Switch from report-only to enforcing on the login pages first, watch for a day, then widen. Keep both headers briefly during the transition — enforcing the strict policy while report-only carries a stricter one you are working towards — so the next tightening is already measured before it lands.
Add a test that asserts the policy header is present and contains the directives you rely on, run against every environment. Policies disappear through middleware ordering changes and static-file handlers far more often than through deliberate edits, and a header that silently stopped being sent looks exactly like a header that is working.
Finally, put the report endpoint on a dashboard with a baseline, and alert on deviation rather than on volume. The point of keeping it after rollout is not tidiness — it is that a spike in violations is one of the few signals that tells you an injection happened at all.
Keeping the Policy Honest Over Time
A policy decays the way any configuration decays: one exception at a time, each individually justified. Two habits prevent that.
Treat every widening as a change with an owner and an expiry. Adding a host to script-src or reintroducing 'unsafe-inline' for a vendor should carry a comment naming who asked, why, and when it will be revisited — and the revisit should actually happen. Policies that accumulate permanent exceptions end up allowing everything while still appearing to be a control.
Review the policy whenever the page changes materially. A redesign that introduces a new analytics tag, an embedded video, or a third-party widget is exactly when someone reaches for the quickest directive that makes it work. Catching that in review, while the alternative is still cheap, is far easier than removing it a year later when the vendor is embedded in the product.
Finally, keep the strictest policy on the authentication pages even if the rest of the site relaxes. Those pages carry the credential, they are simple enough to keep clean, and a strong policy there protects the moment that matters most — which is a far better outcome than a uniform, weakened policy applied everywhere.
Frequently Asked Questions
Can I use a hash instead of a nonce?
Yes, for inline scripts whose content is fixed — a hash avoids per-response state and works with fully cached pages. The downside is that any change to the script, including whitespace, invalidates the hash, so it suits a small number of stable snippets rather than a dynamic application. Many sites use hashes for a handful of static inline blocks and nonces for everything else.
Does a strict CSP break analytics?
It restricts them, which is the point, and most vendors support nonce-based loading. Anything that requires 'unsafe-inline' or 'unsafe-eval' should be treated as a risk decision rather than a formality, because those directives hand injected script the same execution rights as your own code. On authentication pages the right answer is usually to carry no third-party script at all.
What about styles?
style-src deserves its own decision. Injected CSS can exfiltrate data through attribute selectors and background requests, so 'unsafe-inline' for styles is not harmless — but many frameworks inject style at runtime, which makes strict style policies harder than script ones. Use nonces where your framework supports them, and treat a temporary 'unsafe-inline' for styles as a documented exception with an owner rather than a permanent state.
How long should the report-only phase last?
Long enough to cover a full usage cycle — a week for most products, longer if you have monthly workflows. The goal is to see violations from real traffic on real browsers rather than from your own testing, because the surprises come from paths nobody thought to exercise. Filter extension noise first, or the volume will hide the handful of reports that matter.
Is a CSP a substitute for output encoding?
No — it is the layer that catches what encoding missed. Contextual output encoding stops injection at the source; the policy stops what slipped through from executing. Teams that treat the policy as a replacement end up with both a weaker policy (because everything needs an exception) and unescaped output, which is the worst of both.
Related
- Preventing XSS in auth workflows — the layered defences this policy belongs to.
- Securing localStorage vs HttpOnly cookies — limiting what an injection can steal when it does happen.
- Configuring secure cookie flags in production — the storage decision the policy protects.