
My interest in CSP started from a real product problem. I work on a web SDK that gets embedded into other companies' sites. The code has to run inside someone else's application, under someone else's security rules, headers, and constraints. At some point, we got a customer bug: our SDK did not work with their new CSP configuration. That changed how I thought about CSP. Before that, it was just a security header — important, but external to the code. Something configured by a security team, or treated as a part of the infrastructure. But when your JavaScript has to run inside a customer’s site, CSP becomes part of the environment your code must be designed for. So I no longer think of CSP as "just a header." I think of it as a browser-enforced contract: This page is only allowed to load resources and execute code in specific ways. This is the first post in a series that explains Content Security Policy through working examples. Written for frontend and web developers — and especially for anyone whose JavaScript has to run inside someone else's site. Before things get complicated, I want to start with the simplest example that shows the core mechanic of CSP clearly. Later posts will look at scenarios where CSP affects how the code itself has to be written. A Vulnerable Search Page Let's look at a simple Express route. It takes a search query from the URL and reflects it directly into the HTML response. import express from "express"; const app = express(); app.get("/search", (req, res) => { const query = req.query.term ?? ""; res.send(` <!doctype html> <html> <head> <title>Search</title> </head> <body> <h1>Search results</h1> <p id="status">Page loaded normally.</p> <div> You searched for: ${query} </div> </body> </html> `); }); app.listen(3000); This code is intentionally unsafe. The value of term comes from the URL and is inserted directly into the HTML response without any sanitization. A normal request looks harmless: /search?term=cats The page shows: You searched for: cats But the server does not know that cats is supposed to be plain text. It places whatever it receives into the HTML. So if the query contains HTML, the browser parses it as HTML: <strong>cats</strong> And if the query contains HTML with JavaScript, the browser may execute it. This URL: /search?term=<script>alert('XSS')</script> causes the server to reflect it into the HTML: <div> You searched for: <script>alert('XSS')</script> </div> (In a real browser, the URL would be URL-encoded as /search?term=%3Cscript%3Ealert('XSS')%3C%2Fscript%3E , but the server decodes it before inserting it into the HTML.) Often we expect a harmful script to be loaded from a different domain, but this script is reflected by our own server into our own HTML response. The browser receives an HTML document from a trusted site, sees a <script> tag, and runs it. This is called reflected XSS — the injected script is reflected off the web server as a part of the response. The browser runs someone else's JavaScript as if it belonged to your site, in the context of your page. Depending on the application, that script may be able to read page state, send authenticated requests, interact with forms, or access browser storage. The attack does not come from a bad domain — it comes from our own server. Untrusted data was placed directly into the HTML without being correctly sanitized first. The bug is this line: You searched for: ${query} The application should have encoded query as text before inserting it into the HTML. Adding CSP to the Page Content Security Policy is a set of instructions from a website to the browser. These instructions restrict what the page is allowed to load or execute. One of CSP's main use cases is reducing the risk of XSS. Now we will add CSP to our page and see how the behavior will change. Let’s add this header to our page: Content-Security-Policy: default-src 'self' default-src is the fallback directive for many resource-loading directives, which means it is used when policy for a specific type of resource being loaded is not defined. Since our policy does not define specific policy for scripts — script-src , the browser uses default-src as the script policy. 'self' means resources may only be loaded from the page's own origin. In other words, this policy tells the browser: Scripts may only be loaded from this origin, and inline scripts are not allowed. So this is allowed since it will be loaded from the same origin: <script src="/app.js"></script> But inline JavaScript like this will be blocked: <script>alert('XSS')</script> 'self' controls where script files can be loaded from. Inline scripts have no URL, and with this policy, the browser blocks them by default. Allowing inline execution requires an explicit opt-in: 'unsafe-inline' (which defeats the protection), a nonce, or a hash — both of which allow specific trusted scripts while keeping injected ones blocked. Those are topics for a separate post. With this CSP in place, our server may still return unsafe HTML, but the browser refuses to execute the injected inline script. ==The bug is still there, but the exploit path is blocked.== What CSP Changed, and What it Did Not. CSP did not inspect the input, sanitize the HTML, or remove the injected <script> tag from the response. The browser still receives the HTML, but when it reaches the inline script, it checks the page's policy and blocks the execution. That is why CSP is useful. It gives the browser the ability to enforce a security rule even after something has already gone wrong in the application. In this case, the rule is: Inline JavaScript is not allowed on this page. It’s worth noting that this rule blocks both malicious inline scripts and legitimate inline scripts. The browser does not know which one you intended. It only enforces the policy. This is why CSP can be painful when you add it to an existing application. Many apps rely on inline scripts for normal reasons: <script> window.appConfig = { apiUrl: "/api" }; </script> or: <button onclick="save()">Save</button> Those patterns are common, but default-src 'self' will not allow them. CSP does not care that the code is convenient. It only checks whether the code is allowed by the policy. So CSP is not just a header that is easy to add at the last minute. ==It is something that is worth considering during the development phase, especially when working on external SDKs or scripts.== Try it yourself The scenario above is running as a live demo : Just as in our example, the server doesn’t sanitize the query parameter and adds it directly into the page. When you toggle CSP off, the script is reflected right into the page and the injected script is executed. When you toggle it on, you can see that the browser is blocking the malicious script. CSP-protected page still reflects the input (the bug itself is still there), but it also sets Content-Security-Policy: default-src 'self' . The browser receives the same kind of unsafe HTML, but refuses to execute the injected inline script. The console will show what was blocked and why: The source code for the demo is on GitHub . CSP is not the fix for XSS CSP should not be the only thing protecting this page. The real fix is to stop inserting raw user input into HTML. The application should escape the value before rendering it, so this is shown as text, and not interpreted as HTML. <script>alert("xss")</script> OWASP recommends output encoding when you want to display data as the user typed it, so variables are treated as text instead of code. CSP is a second layer. It helps when something was missed — a dangerous innerHTML , a legacy dependency or unsafe external script. It does not remove the bug, but it can reduce what the browser is allowed to execute when the bug is reached. CSP does not make unsafe rendering safe. It limits what the browser will execute if unsafe rendering happens. MDN says this directly : setting a CSP is not an alternative to sanitizing input. Sites should sanitize input and set a CSP as defense in depth against XSS. OWASP describes a strong CSP in the same way : it does not remove vulnerabilities from the application, but it can make them harder to exploit. CSP exposes your frontend assumptions The visible part of CSP is small: Content-Security-Policy: default-src 'self' But adopting it raises frontend questions: Do we have inline scripts? Do we use inline event handlers? Do our dependencies generate code at runtime? Do we load scripts from third-party domains? Do we inject runtime configuration into HTML? Do our framework and build setup work without unsafe script patterns? I like to think about CSP as a contract between your application and the browser. To keep your side of the agreement, you may need to revisit development decisions that conflict with the security guarantees you want the browser to reinforce. Conclusion The example is simple: user input inserted into HTML without escaping. Adding CSP does not fix the bug — the server still returns the unsafe HTML. But the browser refuses to run the injected script because the policy does not allow inline JavaScript. CSP does not replace escaping, sanitization, or safe DOM APIs. It adds a browser-enforced layer that limits what can happen when something goes wrong — and it makes your frontend assumptions visible, because the browser has to enforce them. That is why CSP is not just a header. It is a browser-enforced contract — a rule set that describes what your page is allowed to load and execute, and that the browser will enforce even when something in the application goes wrong. In future posts, I'll look at more of CSP in practice: directives, nonces and hashes, reporting, and the limits of what CSP can protect against.
View original source — Hacker Noon ↗


