Notes · Dissecting Real Systems
growing
Why Your Progressive Approach to Web Security Fails
Adding a security header can only tighten what reaches the browser; nothing you add can loosen it. Loosening is a hand edit of the policy already shipped — and that asymmetry is why incremental hardening degenerates into permissive sprawl, and why deny-by-default with a report-only rollout is the only shape that survives a real application.
A design or implementation mistake in a mechanism that gives explicit permission tends to fail by refusing permission, a safe situation, since it will be quickly detected.
— Jerome H. Saltzer and Michael D. Schroeder, The Protection of Information in Computer Systems (1975)
Cite this
Mangalapilly, Y. J. (2026, August). Why Your Progressive Approach to Web Security Fails. Saṃhitā Notes. https://yesudeep.com/blog/why-your-progressive-approach-to-web-security-fails/ @online{mangalapilly2026why,
author = {Yesudeep Jose Mangalapilly},
title = {Why Your Progressive Approach to Web Security Fails},
journal = {Sa\d{m}hit\=a Notes},
year = {2026},
month = {August},
url = {https://yesudeep.com/blog/why-your-progressive-approach-to-web-security-fails/},
urldate = {2026-08-12},
} Yesudeep Jose Mangalapilly. “Why Your Progressive Approach to Web Security Fails.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/why-your-progressive-approach-to-web-security-fails/. TY - ELEC
AU - Mangalapilly, Yesudeep Jose
TI - Why Your Progressive Approach to Web Security Fails
T2 - Saṃhitā Notes
PY - 2026
UR - https://yesudeep.com/blog/why-your-progressive-approach-to-web-security-fails/
Y2 - 2026-08-12
ER - Why "harden it gradually" is the wrong shape, and what the right one is. The closing article in the web security series argues with the others. By the end you'll know why security policies compose in one direction only, what that asymmetry does to a policy over time, why deny-by-default is a structural claim rather than a stylistic preference, why report-only is the retrofit that made strict policies deployable at all, and which control in your envelope has no dry run and therefore needs a different discipline entirely.
Here is the plan almost everyone starts with. Ship a permissive policy so nothing breaks. Watch for problems. Tighten it a bit each quarter. Arrive, eventually, at something strict.
It is a sensible-sounding plan and it does not work. We know roughly how well it does not work, because the measurement exists: a 2016 study of CSP deployment across 1,680,867 hosts found that 94.72% of distinct policies were bypassable. Not weak — bypassable. And 99.34% of hosts running CSP had policies offering no benefit against the attack CSP exists to stop.
"CSP Is Dead, Long Live CSP!" — Weichselbaum, Spagnuolo, Lekies, and Janc, CCS 2016. The paper that established host allowlists as structurally unsound and proposed 'strict-dynamic' in their place. Learn more.
Those policies were not written by careless people. They were written by teams doing exactly the incremental thing, one exception at a time, each one locally reasonable. The failure is in the shape of the process, not the diligence of the people running it.
The asymmetry
Start with a fact about CSP that sounds like trivia and turns out to be the whole argument.
If a response carries two Content-Security-Policy headers, both are enforced. The spec works the example (CSP Level 3 §8.1, a section marked non-normative but describing normative behavior):
Is a connection to example.com allowed or not? The short answer is that the connection is not allowed. Enforcing both policies means that a potential connection would have to pass through both unscathed. […] The impact is that adding additional policies to the list of policies to enforce can only further restrict the capabilities of the protected resource.
Order policies by what they let through: policy is below policy when everything admits, also admits. Sending two headers gives you the greatest lower bound — the largest policy sitting below both, admitting exactly the requests both would admit. In the vocabulary of order theory, that is a meet.
Meet — for two elements of an ordered set, their greatest lower bound: the largest thing that sits below both. Its mirror image is the join, the least upper bound. A structure with both is a lattice; with only one, a semilattice. Learn more.
Now ask for the other direction. You have two policies and you want the one that admits what either allows — their join. What header combination produces it?
There isn't one. Adding a policy can only restrict, so no amount of adding ever loosens. To permit a union you must go and edit the single policy by hand, in place.
Tightening composes. Loosening does not. Every restriction is one more header; every exception is a hand edit of the policy everyone shares.
That is the gradient the incremental plan is walking against. Consider what it means on a Thursday afternoon when a launch is blocked because a vendor script is being refused. The correct fix — narrow the exception, scope it to the one page, revisit next sprint — requires editing the shared policy carefully. The fast fix is to widen the shared policy: add the host, or when the host list has grown unwieldy, reach for the keyword that stops the complaints altogether.
Both fixes edit the same line. One of them takes an afternoon and one takes a minute, and the mechanism offers no help in telling them apart, because it has no notion of a local loosening. Repeat across a few dozen teams and a few years, and you arrive at the measured result: policies that exist, that parse, that appear in a compliance checklist, and that stop nothing.
Imagine a building where anyone may add a lock to the front door, and every lock must be opened for anyone to enter. Adding security is easy — bring your own lock, hang it on the door, done.
Now someone legitimately needs in and one lock refuses their key. There is no way to add anything that lets them through. Somebody has to take a lock off, and the lock they take off is shared by everyone. Locks accumulate until the first time someone is in a hurry — and then the door is simply propped open, because that is the only move the design makes cheap.
The counterexample that catches people mid-tightening
There is a second, sharper version of this that is worth stating precisely, because the false version of it circulates widely.
"Adding a policy can only restrict" is true. "Adding a directive can only restrict" is false, and the counterexample is one line.
Content-Security-Policy: default-src 'none'
Scripts are refused: default-src is the fallback and it allows nothing. Now add a directive:
Content-Security-Policy: default-src 'none'; script-src 'self'
Scripts from your own origin are now allowed. The policy got longer and weaker at the same time. The spec is explicit about why — a specific directive replaces the fallback for its resource type rather than intersecting with it:
If a
default-srcdirective is present in a policy, its value will be used as the policy's default source list. That is, givendefault-src 'none'; script-src 'self', script requests will use'self'as the source list to match against. Other requests will use'none'.
Warning
The replacement mistake happens while someone believes they are tightening. Adding img-src https://cdn.example to a policy whose default-src is 'self' does not narrow images — it widens them, because the new directive supersedes the fallback instead of intersecting it. Monotone across policies, non-monotone across directives. A review that checks only "did the policy get longer?" will approve this.
One envelope, two algebras
Worth noticing, because it is cheap to state and quietly useful: the headers in your envelope do not agree about what multiplicity means.
CSP takes the meet — every policy is enforced. HSTS takes the first: per RFC 6797 §8.1, if a response carries more than one Strict-Transport-Security field, "the UA MUST process only the first such header field."
Same response, two headers, two entirely different composition rules. There is no general law like "more headers means more security" to lean on; each control has its own algebra and you have to know which.
What the evidence actually says
It would be convenient if the story were "allowlists bad, nonces good." The literature does not support that, and the truth is more useful.
Host allowlists fail because each entry is an independent chance to admit something that turns out to host a JSONP endpoint or a vulnerable library. The 2016 study found 14 of the 15 most commonly allowlisted script domains contained unsafe endpoints, and 75.81% of distinct policies used allowlists that let an attacker bypass the policy entirely.
Important
A caveat about a claim that is not established: nobody has published a study regressing allowlist size against bypass probability, so there is no measured dose-response curve to cite. The argument is mechanistic — each added host is an independent opportunity to admit a gadget, so bypassability is non-decreasing in list length — and that is how it should be stated. It is a good argument. It is not a measurement.
But the boundary moved again. Roth and colleagues (ASIA CCS 2020) showed that script gadgets bypass 'strict-dynamic' too: a library like jQuery that takes attacker-influenced markup and passes it to createElement will happily propagate the page's own trust to it. Their what-if analysis over the Tranco Top 10,000 estimated that around one-third of sites would remain bypassable even under sensibly generated policies, because of heavy reliance on third parties that also host gadget-bearing libraries.
Script gadget — a fragment of legitimate, trusted code that an attacker can feed data to in order to get their own code executed. The gadget is not a vulnerability in the ordinary sense; the policy trusts it, and it does what it was written to do with data it should not have received.
The deny-by-default boundary keeps getting relocated, and each relocation is only measured after deployment. That is an argument for a process that expects to be wrong, not for a cleverer policy.
Adoption data makes the same point from the other end. The Web Almanac's 2025 security chapter finds CSP on about 21.9% of pages — and 92% of those policies use unsafe-inline. The header is present, the checklist is satisfied, and the protection is not there.
The unit of protection is the envelope
There is a second argument against one-header-at-a-time, and it is independent of the composition asymmetry: the headers do not defend against the same attacks. Each control refuses one class and is blind to every other, and the blind spots are not accidental overlap — they are the reason the other controls exist.
| control | refuses | cannot see |
|---|---|---|
| CSP | markup asking the page to run a script it should not | a compromised file on an origin the policy allows |
| Subresource Integrity | a tampered artifact from an allowed origin | a script injected inline, which references nothing |
| Trusted Types | a string reaching innerHTML or document.write | a script the network delivered openly |
| HSTS | a downgrade to plaintext after first contact (preload extends it to the first) | everything that happens over the HTTPS it enforced |
| COOP, COEP and CORP | a cross-origin secret read out of a shared process | an injection already running same-origin |
Read the table by columns and the incremental plan's cost becomes concrete. A serial rollout is an ordering of the rows, and at every point in it the classes still open are exactly the rows not yet shipped. Choosing the order is choosing which attacks your application accepts longest — a decision the plan makes implicitly, without anyone ranking the attackers.
Spectre — the class of speculative-execution attacks that lets code read memory it shares a process with; in a browser, that means a page can potentially read cross-origin data loaded into its renderer. The isolation trio exists to keep secrets out of that shared process. Learn more: spectreattack.com.
It also says something about what "done" means. A team that ships a strict CSP and stops has closed the injection row and left the artifact row open: the policy admits an origin, and an origin is not an artifact — the CDN file that origin serves tomorrow is inside the allowlist too. A team that ships CSP and SRI and stops still hands every string that reaches a DOM sink the page's own authority, which is the class the sanitizer article is about. None of these is a misconfiguration. Each is a correctly deployed control doing exactly its job, next to a job nobody deployed.
None of this argues for enforcing everything on day one — the sections that follow are about why that is neither necessary nor, for one control, even possible. It argues against serializing coverage. Report-only lets the whole envelope ship at once, unenforced, so the calendar is spent observing each control rather than living without it; what gets staged is enforcement, not protection-by-protection blindness.
The envelope is the unit of protection. A single header can be adopted incrementally; a defense cannot, because the gaps between correctly deployed controls are attack classes, not rough edges.
Deny by default, allow by exception
The alternative is not a cleverer policy. It is the opposite starting point, and it has been written down since 1975.
Saltzer and Schroeder's fail-safe defaults principle says to "base access decisions on permission rather than exclusion," so that "the default situation is lack of access, and the protection scheme identifies conditions under which access is permitted." Their reasoning is worth reading in full, because it anticipates the entire deployment story:
A design or implementation mistake in a mechanism that gives explicit permission tends to fail by refusing permission, a safe situation, since it will be quickly detected. On the other hand, a design or implementation mistake in a mechanism that explicitly excludes access tends to fail by allowing access, a failure which may go unnoticed in normal use.
Read that against the asymmetry. Deny-by-default puts your mistakes on the side of the ledger that composes cheaply and announces itself. An enumerate-the-bad policy fails silently and invisibly: you find out you missed a case when someone else finds out first.
There is also a plain epistemic argument. To enumerate what you forbid, you must know every bad thing — including the ones invented after you shipped. To enumerate what you allow, you need only know your own application, which is finite, yours, and knowable. One list can be complete. The other cannot.
A guest list is short and you wrote it. A list of everyone who must not come in is infinite and someone else keeps adding to it.
The retrofit that made it deployable
There is an obvious objection: deny-by-default is unshippable. Turn everything off on a real application and you break it in a hundred places you cannot enumerate in advance — which is precisely the fear the incremental plan was answering.
The answer is report-only, and it deserves more credit than it gets. A report-only policy is evaluated and not enforced: violations are reported and nothing breaks. The CSP spec is unusually direct about the intended workflow:
This header field allows developers to piece together their security policy in an iterative fashion, deploying a report-only policy based on their best estimate of how their site behaves, watching for violation reports, and then moving to an enforced policy once they've gained confidence in that behavior.
Notice what this changes. You do not have to know your application's resource usage; you have to observe it. The browser does the enumeration for you, on real traffic, including the paths QA never exercises. That is the step the incremental approach skips, and it is why the incremental approach has to guess.
Report-only is not a nicety. It is the mechanism that makes deny-by-default deployable, by turning "enumerate everything you allow" from an act of imagination into an act of measurement.
The right shape, then, is not "start loose and tighten." It is:
- Deny everything, in report-only. Nothing breaks, because nothing is enforced. The browser starts telling you what your application actually does.
- Read the reports. Every violation is either a legitimate resource that needs an exception, or a thing you did not know your site was doing. Both are findings; the second kind is the more valuable.
- Add exceptions explicitly, each one justified by a specific report, as a list a reviewer can read.
- Enforce, and keep the reporting endpoint. Violations after enforcement are either a regression or an attack, and both are worth a page.
Note
Keep the sink after you enforce. A violation report from an enforced policy is a different signal than one from a report-only policy: something was blocked, which means either your allowlist has a gap or someone tried something. Treat the endpoint as a low-volume, high-value feed — and remember its input is attacker-influenceable, so it should be rate-limited, sampled, never reflected, and answered with a fixed status.
Not every control has a dry run
Here is where the recommendation needs a caveat, and where this piece connects to the last one.
Report-only is not universal. CSP has it. Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy have it. Several other controls do not — and the pattern in which ones do is itself informative.
The controls that acquired a report-only mode are the ones whose blast radius turned out to be unmanageable in practice. That is not a coincidence: report-only was added because deny-by-default was undeployable without it.
Which makes HSTS the sharp exception. There is no Strict-Transport-Security-Report-Only. You cannot dry-run includeSubDomains to discover which of your subdomains would break — you assert it and find out. And it is simultaneously the control with the slowest rollback: a header policy expires on max-age, and a preloaded one waits on browser release trains.
Warning
The control you cannot rehearse is the one with the widest blast radius and the slowest undo. For HSTS the substitute for report-only is a staged max-age — five minutes, then a day, then a week, then a year — with a real subdomain inventory before includeSubDomains and a deliberate decision before preload. Where the mechanism gives you no dry run, the discipline has to supply one.
The shape of the whole thing
The incremental plan fails for a reason that has nothing to do with discipline. It asks people to walk uphill against the gradient of the mechanism, every day, forever, under deadline. Each individual widening is defensible. The sum of them is the 94.72%.
Deny-by-default works because it aligns the easy direction with the safe one. Restrictions compose, so the default state is strong; exceptions require a deliberate, reviewable edit, so weakening is visible and rare. And report-only removes the reason the strict start was impractical, by replacing a guess about what your application does with a measurement of it.
The same principle has a corollary for anyone who ships defaults rather than deployments — a framework, a headers library, a static host. Fail-safe defaults means zero configuration yields the strictest envelope the platform can express, and every relaxation is a named, reviewable exception that carries its reason. A tool whose empty config is permissive has made the Thursday-afternoon edit the default state for every user who never opens the manual — it has shipped the sprawl pre-built.
None of this makes the policy correct. The gadget research is a standing reminder that the boundary will move again, and that any finite enumeration can become insufficient. What deny-by-default buys is the kind of wrongness that fails loudly and locally, rather than silently and everywhere — which was Saltzer and Schroeder's point in 1975 and has not needed revision since.
Lessons
- Security policies compose in one direction. Multiple CSP headers give their meet; per CSP3 §8.1, adding policies "can only further restrict." There is no operation producing their join.
- That asymmetry sets the gradient. Tightening is one more header; loosening is a hand edit of the shared policy. Under deadline the cheap edit is the broad one, which is how incremental hardening produces permissive sprawl.
- Adding a directive can loosen a policy.
default-src 'none'; script-src 'self'admits scripts thatdefault-src 'none'alone refused, because a specific directive replaces the fallback rather than intersecting it. Monotone across policies, non-monotone across directives. - The measured outcome is 94.72% of distinct policies bypassable across 1.68 million hosts — and 92% of the CSPs deployed in 2025 still use
unsafe-inline. Adoption is not protection. - The boundary keeps moving. Script gadgets bypass
'strict-dynamic'too; roughly a third of the Tranco Top 10,000 would remain bypassable under sensibly generated policies. Plan for being wrong rather than for being right. - The envelope is the unit of protection. Each header refuses one attack class and is blind to the rest, and every blind spot is another header's job. A serial rollout is therefore an implicit ranking of which attacks you accept longest.
- Enumerate what you allow. The allow-list is finite and yours; the deny-list is infinite and someone else's. Saltzer and Schroeder's fail-safe defaults principle is the 1975 statement of it.
- Report-only is what makes it deployable — it converts "know everything your app does" into "measure everything your app does," and the browser does the measuring on real traffic.
- Some controls have no dry run. HSTS has no report-only mode, the widest blast radius, and the slowest rollback. Where the mechanism gives no rehearsal, staged rollout is the discipline that substitutes for one.
Practice
References
- Weichselbaum, Spagnuolo, Lekies, Janc. “CSP Is Dead, Long Live CSP! On the Insecurity of Whitelists and the Future of Content Security Policy.” ACM CCS, 2016. — the 94.72% figure, the 1,680,867-host corpus, and the case against host allowlists
- Saltzer, J. H. and Schroeder, M. D.. “The Protection of Information in Computer Systems.” Proceedings of the IEEE 63(9), 1278–1308, 1975. — fail-safe defaults, and why a permission-based mistake fails safely and detectably
- W3C. “Content Security Policy Level 3.” W3C. — §8.1 on multiple policies, and §6.1.3 on how a specific directive replaces the default-src fallback
- Roth, Barron, Calzavara, Nikiforakis, Stock. “Complex Security Policy? A Longitudinal Analysis of Deployed Content Security Policies.” ASIA CCS, 2020. — script gadgets bypassing strict-dynamic, and the Tranco Top 10,000 what-if analysis
- HTTP Archive. “Web Almanac 2025: Security.” HTTP Archive, 2025. — adoption rates for CSP, HSTS, COOP, COEP and Permissions-Policy, and the unsafe-inline share
- IETF. “RFC 6797: HTTP Strict Transport Security.” RFC Editor, 2012. — the first-header-wins rule, for contrast with CSP's meet
- “HSTS: It's Time to Go HTTPS-Only.” — the control with no report-only mode and the slowest rollback
- “Trust No Script.” — what a strict CSP looks like once you have decided to deny by default
How to cite
Mangalapilly, Y. J. (2026, August). Why Your Progressive Approach to Web Security Fails. Saṃhitā Notes. https://yesudeep.com/blog/why-your-progressive-approach-to-web-security-fails/ @online{mangalapilly2026why,
author = {Yesudeep Jose Mangalapilly},
title = {Why Your Progressive Approach to Web Security Fails},
journal = {Sa\d{m}hit\=a Notes},
year = {2026},
month = {August},
url = {https://yesudeep.com/blog/why-your-progressive-approach-to-web-security-fails/},
urldate = {2026-08-12},
} Yesudeep Jose Mangalapilly. “Why Your Progressive Approach to Web Security Fails.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/why-your-progressive-approach-to-web-security-fails/. TY - ELEC
AU - Mangalapilly, Yesudeep Jose
TI - Why Your Progressive Approach to Web Security Fails
T2 - Saṃhitā Notes
PY - 2026
UR - https://yesudeep.com/blog/why-your-progressive-approach-to-web-security-fails/
Y2 - 2026-08-12
ER - Webmentions
Annotations
Thank you — your note is held for review and will appear once approved.
Thank you — your note is published.
Please sign in below to leave a note.
