How to Roll Out a Strict CSP on a Legacy ASP.NET WebForms App (Without Breaking Everything)
Content-Security-Policy is one of those headers that sounds like a five-minute fix — add one line to your response, drop 'unsafe-inline', done. Then you point it at a legacy ASP.NET WebForms app and discover the framework itself is one of the worst-behaved CSP citizens still in production anywhere. WebForms generates inline event handlers, inline script blocks, and postback wiring as a core part of how it works — none of it written by you, all of it now blocked by the policy you just turned on.
I recently took a WebForms line-of-business app (.NET Framework, VB.NET, well over a decade old) through a full CSP rollout — no unsafe-inline, no unsafe-eval, staged from Report-Only to full enforcement. Here's what actually works, in the order I'd do it again.
Step 1: Audit before you touch anything
Don't guess at scope — count it. Before writing a line of remediation code, grep the codebase for every category of thing CSP is going to block:
- Inline event handler attributes:
onclick,onchange, and WebForms' ownOnClientClick(which renders straight to an inlineonclick— easy to miss if you're only grepping raw HTML attributes) - Inline
<script>blocks in markup - Code-behind script injection:
RegisterClientScriptBlock,RegisterStartupScript, and friends - Anything eval-adjacent:
eval(),new Function(), and — easy to forget — string-formsetTimeout("doThing()", 0)/setInterval, which CSP treats identically toeval
On a real app this is usually a few dozen files and a few hundred individual sites. Knowing the number up front tells you whether you're looking at a week of work or a quarter, and it stops you from declaring victory after fixing the pages you happened to click through manually.
Step 2: Report-Only, always, no exceptions
Make the policy mode a config switch, not a code change:
CSPMode = Off | ReportOnly | EnforceOff should be a byte-identical rollback path. ReportOnly sends Content-Security-Policy-Report-Only instead of the enforcing header — the browser logs violations and hits your report endpoint, but nothing actually breaks. You do not flip to Enforce until Report-Only has run clean through a full business cycle. This is not caution for its own sake — it's the only way to catch the code paths your grep audit and your manual testing both missed (month-end batch jobs, a report page nobody opens except on the first of the month, that one workflow only one user touches).
Step 3: The three categories of WebForms offender, and what to do with each
Inline event handlers you wrote yourself. These are the easy ones, precisely because you have full control over them. Replace the pattern entirely rather than trying to nonce your way around it — inline handlers can't carry a nonce anyway, since the nonce only applies to <script> elements, not attribute values. The pattern that works well: a single external, nonce-free script that listens at the document level in the capture phase and dispatches based on data-* attributes.
<!-- before -->
<asp:Button OnClientClick="return confirm('Delete this record?');" ... />
<!-- after -->
<asp:Button CssClass="js-confirm" data-confirm="Delete this record?" ... />One listener, attached once, handles every confirm dialog, every "block copy-paste" field, every numeric-only input in the app. You're not writing CSP workarounds anymore, you're writing normal, testable JavaScript that happens to be CSP-compliant by construction.
Inline <script> blocks and code-behind script registration. Two options: externalize into a proper .js file (best, when the script is static), or generate a per-request nonce and stamp it onto both the script tag and the CSP header (when the script genuinely needs server-side values baked in). Build a small helper that wraps your registration calls and always applies the current request's nonce — don't rely on developers remembering to add it by hand on every call site, because they won't, consistently, six months from now.
Framework-generated script you don't control. This is the category that actually makes WebForms hard, and it's where a lot of naive CSP attempts go wrong. __doPostBack, WebForm_DoPostBackWithOptions, validator wiring, AutoPostBack dropdowns — all of this is emitted by the framework itself as inline script, and you cannot edit the framework. Don't try to convert it. Instead, put a narrow response-rewriting filter in front of it that injects nonces into exactly this class of framework-generated inline script, and nothing else.
The key word is narrow. A filter that's too broad will bite you — specifically, watch out for WebResource.axd and ScriptResource.axd, ASP.NET's built-in handlers for serving framework JavaScript. If your rewriting filter attaches to every response indiscriminately, it will mangle these too, and because they're aggressively cached by the browser, the breakage won't show up as an obvious page-load failure — it'll show up as a cryptic WebForm_AutoFocus is not defined console error on some unrelated page, sometimes only after the resource was cached from an earlier state. Scope your filter to actual Page handlers only. Leave .axd and other non-page handlers completely alone.
Step 4: Wire up violation reporting before you need it
Add a report-uri directive pointing at an endpoint in your own app, and have it write violations to a log file (rotated, size-capped — CSP reports are cheap to spam and you don't want them filling a disk). Two things people get wrong here:
- If your app uses Forms Authentication, the report POST will hit your login redirect before it hits your handler, unless you explicitly allow anonymous access to that one path. The browser is sending the report, not the logged-in user's session — there's no cookie context to authenticate against.
- If you've done Step 3 properly, expect your Report-Only logs to stay empty on fully-converted pages. That's the signal working as intended, not evidence the reporting is broken — don't waste time debugging a report pipeline that has genuinely nothing to report yet.
Two things that will confuse you (and your reviewers) mid-project
Console-typed script isn't a CSP bypass. If someone opens DevTools and types alert('hi') into the console on a CSP-protected page, it runs — every time, regardless of policy. This is not a hole in your implementation. Browsers deliberately exempt console-typed input from CSP, because CSP governs script arriving through the page (from the network, from the DOM, from an attacker), not commands a human types directly into their own browser's developer tools. It's also not attacker-exploitable in the way it looks: it requires the attacker to already be sitting at the victim's keyboard, which is the textbook "self-XSS" scenario browsers actively warn users about ("if someone told you to paste this here, it's a scam"). Know this going in, or you'll burn an afternoon convinced your policy is broken when it isn't.
Testing eval-based vectors from the console doesn't prove anything. Following on from the above: DOM-based injection tests — an inline <script> tag inserted via innerHTML, an onerror handler, an off-origin <script src> — are legitimate to test from the console, because they create real page-originated elements that CSP genuinely evaluates. But eval(), new Function(), and string-form setTimeout share the same console exemption as everything else typed directly into DevTools — testing those "for real" requires an actual page-originated payload (a stored or reflected injection), not a REPL command.
A quick reckoner for anyone touching the codebase mid-rollout
Once the policy is live in Report-Only, you need every developer touching the app — not just the person who did the rollout — to not reintroduce violations. A short, concrete list beats a long policy document:
- Never write a new inline event handler attribute. Use the
data-*dispatch pattern. - Never add a bare inline
<script>block. Externalize it, or nonce it through the shared helper. - Never use string-form
setTimeout/setInterval. Pass a function reference. - Don't touch the nonce filter's scope. If something's breaking under CSP, fix it at the source — don't widen the safety net to cover it.
- If you're not sure whether something will violate the policy, check it against the running app in Report-Only mode before merging. The console will tell you.
The rollout order, one more time
Audit and count the real scope. Convert everything you control at the source. Put a narrow, scoped filter in front of the framework-generated script you don't control. Wire up reporting and confirm it stays quiet on converted pages. Run Report-Only through a full business cycle — not a sprint, a full cycle, so you catch the monthly job and the quarterly report nobody opened during testing. Only then flip to enforcement.
None of this is exotic. It's a checklist, applied patiently, to a framework that was never designed with CSP in mind. The framework not being designed for it is exactly why the checklist matters more here than it would on a modern SPA — there's no framework-level "just add this one config flag" escape hatch. You do it file by file, or you don't do it at all.