Before you tighten the Content-Security-Policy on a live app, ship it as Content-Security-Policy-Report-Only and, as my working rule, read the violation reports for about a week. If content security policy unsafe inline went back into your header after a page went blank, the report-only stage lists the inline scripts and styles that need a nonce, and nothing breaks while you look.
What content security policy unsafe inline allows, and why the header exists
The unsafe-inline keyword in a Content-Security-Policy allows all inline scripts and inline event handlers under script-src, and inline styles under style-src. MDN calls disallowing inline scripts and styles one of the biggest security wins CSP provides, and recommends a nonce-source or a hash-source, with a nonce unique to each request, instead of allowing all inline scripts.
In practice, that is what 'unsafe-inline' means: a policy that allows every inline script cannot tell your script from one an attacker slipped into the page. The header is a second wall behind input handling and authorization, never a replacement for them, which is why it sits beside the other checks in web app security for an AI-built app.
A content security policy is a response header whose primary use, in MDN’s guide to Content Security Policy, is to control which resources, JavaScript in particular, a document is allowed to load, mainly as a defense against cross-site scripting (XSS). Four source expressions carry the word “unsafe”, and MDN’s Content-Security-Policy header reference describes what each one lets through:
| Source expression | What MDN says it allows |
|---|---|
'unsafe-inline' | Inline <script> tags, inline event handler attributes and javascript: URLs; under style-src, inline <style> tags and style attributes. MDN warns it “defeats much of the purpose of having a CSP” |
'unsafe-hashes' | Hash expressions for inline event handlers and style attributes. MDN calls it unsafe, but “much safer than ‘unsafe-inline‘“ |
'unsafe-eval' | ”Dynamic evaluation of strings as JavaScript”: eval(), the code argument to setTimeout(), the Function() constructor |
'wasm-unsafe-eval' | WebAssembly compilation, without enabling general evaluation of JavaScript |
In CSP terms, 'unsafe-inline' is about code written into the page and 'unsafe-eval' is about code built from strings while the page runs (my summary of MDN’s two entries). MDN’s script-src reference adds that 'wasm-unsafe-eval' controls WebAssembly execution and is more specific than 'unsafe-eval'.
Google’s strict CSP page prints this policy for an app that still has to support old browsers:
Content-Security-Policy:
script-src 'nonce-{random}' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'none';
The 'unsafe-inline' in it is a fallback: Google says all recent browsers ignore unsafe-inline if a CSP nonce or hash is present, and it is there for very old browser versions (4+ years). The https: entry is the same kind of fallback, for earlier versions of Safari, and a browser that supports 'strict-dynamic' ignores both. Google’s policy has no 'unsafe-eval'; if the app still calls eval(), the page says you have to add it, which makes the policy slightly less secure. The {random} placeholder is a new value on every response, generated with a cryptographically secure random token generator.
The second header in the set is HSTS, the Strict-Transport-Security header that tells browsers to reach the host only over HTTPS; its values and rollout have their own section below.
For scale: the Input, Injection & Abuse pillar averages 61.9 out of 100 across the 21 third-party apps I audited in June and July 2026, 11th of 12 pillars counting from the weakest. Those 21 apps are a set I chose to audit, not a random sample, so the figure is not a rate for AI-built apps in general, and it says nothing about whether headers moved the score.
What goes wrong without it
Four situations show what the header set is there to stop. Each one has a different fix, which is why a single header is never the whole answer.
| Situation | What happens | What closes it |
|---|---|---|
Stored or reflected XSS, with 'unsafe-inline' in the policy | The injected inline script runs, because the policy allows every inline script | A per-request nonce or a hash in place of 'unsafe-inline' |
| A CSP bypass through an allowlisted script host | The attacker loads code through an unsafe endpoint on a host the policy trusts | Nonces with 'strict-dynamic' instead of host lists |
| The first visit over plain HTTP | The initial request goes out before the browser knows the host wants HTTPS | Strict-Transport-Security, and preloading for that first request |
| Your page inside another site’s frame | Your page sits in an invisible frame over a decoy button, and the visitor clicks yours without knowing | frame-ancestors 'none' plus X-Frame-Options: DENY |
The first row is the one 'unsafe-inline' decides. MDN calls allowing all inline scripts a security risk, and here is my reading of that line: an injected <script> block looks exactly like yours, so the wall you added for XSS does nothing for the attack it was meant to catch. The injection itself, and how to stop it at the source, is covered in how to mitigate cross site scripting.
The second row is why the strict pattern trusts nonces rather than lists of hosts. Google’s 2016 study of allowlist policies looked at 26,011 unique policies on 1,680,867 hosts and identified flaws that result in bypasses in 94.72% of all distinct policies; 14 out of the 15 domains most commonly whitelisted for loading scripts contain unsafe endpoints. The same paper proposed 'strict-dynamic' for policies based on cryptographic nonces, without relying on domain whitelists.
Then there is the failure that puts the keyword into a policy in the first place. It goes like this: an enforcing header ships straight to production, a page goes blank because the new CSP broke the inline scripts the builder generated, and 'unsafe-inline' goes back in to get the app working again. The rollout below starts with Report-Only for that reason.
One pattern is not a tuning job at all. A stream of violations for a script your own pages served, which nobody on your side wrote, is an incident: start with what to do when my app got hacked before you touch the policy.
How to add the production security headers on the common stacks
A Content-Security-Policy without unsafe-inline goes live in 6 steps: send it as Report-Only, collect violations for about a week (my working rule), move inline code to files or give it a nonce, add strict-dynamic, switch the header to enforcing, and leave style-src for last.
These are the steps MDN, Google, Next.js and the hosts document, put in order; they are not a report of a migration I ran. Read together, the five parts below work as a security headers checklist for production: the rollout, the wiring per host, HSTS, framing, and the rest of the set.
Removing unsafe-inline without breaking the app: report-only first
Content-Security-Policy-Report-Only carries the same policy without enforcing it: with a report-to endpoint set, the browser reports each violation and blocks nothing, so a week or so of reports, my working rule, shows which inline scripts and styles need a nonce before any visitor sees a blank page.
MDN’s Content-Security-Policy-Report-Only reference is the source for step 1, and the report fields in step 3 come from MDN’s CSPViolationReport page.
- 01 Send the policy as report-only Send
Content-Security-Policy-Report-Onlywith the policy you want to end up with, aReporting-Endpointsheader naming your endpoint, and bothreport-toand the deprecatedreport-uri: MDN declares both because report-to does not yet have full cross-browser support. It must be a response header; MDN says it is not supported inside a<meta>element. - 02 Collect for about a week My working rule is about a week, long enough to cover every flow the app has, the checkout and the password reset included.
- 03 Read each report and fix the source Each report names the directive that fired (
effectiveDirective), the blocked resource (blockedURL) and, for inline code, asampleof its first characters. Move inline code into files where you can, and give what must stay inline a nonce that is unique for each HTTP request. - 04 Add strict-dynamic
'strict-dynamic'passes the trust given to a nonced or hashed script on to the scripts it loads, and makes the browser ignore allowlists,'self'and'unsafe-inline'. - 05 Switch to enforcing Rename the header to
Content-Security-Policyonce the report stream has been quiet for a couple of days. You can keep a report-only header beside it to trial the next change; MDN says both policies are honored. - 06 Leave style-src for last The Next.js guide lists inline styles first among common violations and says to use CSS-in-JS libraries that support nonces or move styles to external files.
Steps 3 and 4 together are the CSP alternative to 'unsafe-inline': a nonce on each inline block you keep, and 'strict-dynamic' for whatever those scripts load. For a fixed inline block that never changes, a hash works instead of a nonce: CSP supports sha256, sha384 and sha512, and the hash covers the exact contents, where capitalization and whitespace matter. Keeping 'unsafe-inline' on style-src for a while after scripts are clean is a smaller give than keeping it on script-src; that is my reading, not a vendor line. Some reports may not be yours at all: Google’s page says that when you enable CSP for production traffic, you might see some noise in the violation reports from browser extensions and malware.
CSP in Next.js and on static hosts
In the Next.js CSP guide, the nonce comes from the proxy file: your proxy creates a unique nonce for the request, adds it to the Content-Security-Policy header, and Next.js attaches it automatically to the inline styles and scripts it generates during server-side rendering. Proxy is the old middleware file: Next.js 16.0.0 deprecated middleware and renamed it to proxy. The trade-off is rendering: with nonces, pages must be dynamically rendered, so static optimization and Incremental Static Regeneration are disabled. For pages that must stay static, the guide offers experimental hash-based Subresource Integrity, App Router only. The guide’s own policy adds 'unsafe-eval' only in development, where React uses eval for debugging, and says it is not required for production.
The next-safe repository describes a package that provides sensible defaults for the most common security headers, and its defaults include X-XSS-Protection, which OWASP says not to set or to turn off (the last H3 below). The repository was archived by the owner on December 20, 2025 and is now read-only, so set the header in your proxy instead. It is also unrelated to next-safe-action, a different library that shares its search results.
Static hosts run no code per request for a static file, so the header lives in a file or a setting, and Netlify, Cloudflare and Azure each document where theirs stops applying.
Netlify’s custom headers docs put a _headers file in the publish directory, with URL paths and their headers indented below them, and say custom headers are not applied to a URL handled by a function or edge function such as a server-side rendered page, where the function should return them. The full walk-through is in how Netlify security headers are configured. On Vercel, Vercel’s headers setting is the headers array in vercel.json, and Vercel’s default response headers already include HSTS; the split is in what Vercel sets for you and what it leaves to your project.
Cloudflare Pages headers come from a _headers file in the static asset directory, and Cloudflare says they are not applied to responses generated by Pages Functions, so with an SSR framework you will likely need to attach them in the Functions code. On Azure Static Web Apps, the content security policy goes in globalHeaders inside staticwebapp.config.json, which Azure Static Web Apps configuration describes as headers applied to each response, API responses excepted.
| Host | Where the header is set | What the host adds on its own |
|---|---|---|
| Next.js | proxy.ts, with a nonce per request; pages render dynamically | Applies the nonce to the scripts and inline styles Next.js generates during server-side rendering |
| Netlify | _headers in the publish directory, or netlify.toml; not applied to function or edge function responses | Not stated in Netlify’s headers docs |
| Vercel | headers in vercel.json | strict-transport-security: max-age=63072000 (2 years) by default |
| Cloudflare Pages | _headers in the static asset directory; not applied to Pages Functions responses | Not stated in Cloudflare’s Pages headers docs |
| Azure Static Web Apps | globalHeaders in staticwebapp.config.json; not applied to API responses | Not stated in Microsoft’s configuration docs; nonces are not mentioned there, so a fixed file points to hashes (my reading) |
| Base44 | Only X-Frame-Options and Permissions-Policy, as toggles under Security Headers in the app dashboard | CSP and HSTS have no app-level control |
Some builders host the app and keep most headers themselves. On Base44, CSP and HSTS are not app-level settings and only X-Frame-Options and Permissions-Policy can be turned on per app, which is covered in what Base44 lets you control. A CDN or firewall in front of the origin can still add headers at the edge; that setup is a separate job: how to set up a web application firewall in front of your app.
What is HSTS and how to enable it on a website
HSTS is the Strict-Transport-Security response header: once a browser has received it over HTTPS, it uses HTTPS for that host until max-age runs out. The preload list asks for a max-age of at least 31536000 seconds, one year, with includeSubDomains and preload.
Getting there safely takes a month or more. The deployment recommendations on the HSTS preload list start before the header: examine every subdomain and nested subdomain, internal ones included, and make sure each works over HTTPS. Then add the header to all HTTPS responses and ramp up max-age in stages: max-age=300 (5 minutes), then 604800 (1 week), then 2592000 (1 month), each with includeSubDomains. During each stage, check for broken pages, watch the site’s metrics, fix what comes up, and wait the full max-age of the stage before moving on.
OWASP’s cheat sheet goes further and recommends max-age=63072000; includeSubDomains; preload, with a warning: if the value is very long and the certificate expires or is revoked, legitimate users might be unable to reach the site until the header’s duration has expired. The preload site says HSTS is recommended but HSTS preloading is not, and that inclusion in the list cannot easily be undone, since removal takes months to reach users with a Chrome update. My working rule: leave preload off unless you have a reason to submit, and never add it before the whole domain and every subdomain serve HTTPS.
MDN’s Strict-Transport-Security reference explains the gap that preloading closes: browsers ignore the header when it arrives over plain HTTP, and until a browser has made one secure connection and received it, the first http:// request is vulnerable to network attacks.
For a web app, securing data in motion means TLS encryption on every connection that carries data: browser to app, app to database, and each third-party API call. HSTS is the part of encrypting data in motion that the browser enforces, so the secure transmission of data from the visitor no longer depends on how they typed the address. The certificate, the redirect and the preload submission are in how to add HTTPS to a website properly, and what an interception attack looks like is a separate subject: man-in-the-middle attacks on a modern web app.
Clickjacking protection: frame-ancestors and X-Frame-Options
Clickjacking protection is the frame-ancestors directive in the Content-Security-Policy, set to ‘none’ unless the app frames its own pages, backed by X-Frame-Options: DENY for older browsers. OWASP’s headers cheat sheet says frame-ancestors obsoletes X-Frame-Options for the browsers that support it.
OWASP’s clickjacking page defines clickjacking, also known as a “UI redress attack”, as an attacker using transparent or opaque layers to trick a user into clicking a button or link on another page. A clickjacking example, described rather than coded: the attacker’s page shows a harmless button, your app sits on top in an invisible frame, and its “delete account” button lines up under the visitor’s click. If a scanner reports that your site can be loaded in an iframe, that is the clickjacking vulnerability it means, and the fix is the header, not a script.
MDN’s frame-ancestors reference says frame-ancestors 'none' is similar to X-Frame-Options: DENY, which older browsers also support. When the app frames its own pages, use frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN. Three details from MDN decide whether the header works at all:
frame-ancestorsdoes not fall back todefault-src, so a policy withdefault-src 'none'still lets anyone embed the page.ALLOW-FROMis obsolete, and MDN says modern browsers that meet it ignore the wholeX-Frame-Optionsheader.- Neither works from a
<meta>tag:frame-ancestorsis not supported there andX-Frame-Optionsset there has no effect, so both have to be response headers.
Clickjacking is not its own category in the OWASP Top 10: CWE-1021, Improper Restriction of Rendered UI Layers or Frames, is among the CWEs mapped to Insecure Design, A04:2021 and A06:2025. Clickjacking attacks need nothing from your code except a page that some other site is allowed to frame, and without either header the browser allows exactly that.
The rest of the set: Referrer-Policy, X-Content-Type-Options, Permissions-Policy
The production header set I use for a small SaaS is six headers: Content-Security-Policy, Strict-Transport-Security, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, and a Permissions-Policy that turns off the camera, microphone and geolocation the app does not use.
The values come from OWASP’s HTTP Headers Cheat Sheet, which gives its security headers recommendations one header at a time; the six-header selection is mine.
| Header | Value for a small SaaS | What OWASP’s cheat sheet says it is for |
|---|---|---|
Content-Security-Policy | The strict policy above, with nonces | An added layer that helps detect and mitigate attacks such as XSS and data injection; OWASP points to its CSP cheat sheet for the options |
Strict-Transport-Security | max-age=63072000; includeSubDomains (OWASP adds preload; see the HSTS section) | Tells browsers to use only HTTPS, even when a user connects over HTTP |
X-Frame-Options | DENY | Lets a site avoid clickjacking by keeping its pages out of other sites’ frames |
X-Content-Type-Options | nosniff | Blocks MIME type sniffing, which can turn a non-executable type into an executable one |
Referrer-Policy | strict-origin-when-cross-origin | Controls how much referrer information goes out with requests |
Permissions-Policy | geolocation=(), camera=(), microphone=() | Stops an injection, an XSS for example, from turning on the camera, the microphone or other browser features |
Leave one header out on purpose. For X-XSS-Protection, OWASP’s advice is to not set it or to turn it off with X-XSS-Protection: 0, because “in some cases, this header can create XSS vulnerabilities in otherwise safe websites”; a template that still turns it on needs changing. nosniff earns its place most where users upload files, since an upload served with the wrong type is exactly what sniffing would misread; the upload side is in file upload testing for type, size and bucket permissions.
One control is not a header but belongs beside CSP. MDN’s Subresource Integrity page describes an integrity attribute on a <script> or <link> that carries a cryptographic hash, which the browser compares with the file before running the script or applying the stylesheet; the element also needs the crossorigin attribute. The third-party script incidents behind it are a separate story: npm malware package incidents.
Web app security headers leave three jobs to other controls. They do not stop cross-site request forgery, which is cookie attributes and tokens (CSRF mitigation for a production app). They do not stop bots filling in your forms (stopping bots submitting our signup form). And the security.txt file you may see next to them on a checklist is a file, not a header (a security.txt file example).
How to verify it: check the response security headers you actually send
Security headers are verified on the deployed site, not in the repository: curl -sI against the production URL prints the six headers and their values, the browser console shows no CSP violations across the five main flows, and a local page that frames your app does not display it.
Two views check website security headers from different sides: the browser shows what a real visit receives, and curl shows what the server sends with no browser in between. A test of your security headers is only worth something later if you can show its result, so each check below names the evidence to keep.
- 01 Browser first Open Chrome DevTools on the production URL, go to the Network panel, tick Disable cache and reload. Select the document request under Name, open the Headers tab and scroll to Response Headers. Evidence: a screenshot of your own screen showing the six headers.
- 02 curl on HTTPS Run
curl -sIagainst the HTTPS URL (-Ifetches the headers only,-shides the progress meter) and read the same six. Evidence: the output, saved with the date. - 03 The console on the five main flows Sign up, sign in, the paid action, an upload and a password reset, each with the console open: no CSP violation on any of them. Evidence: one console screenshot per flow.
- 04 The framing test Open the three-line file below from your own disk. The app must not display inside the frame. Evidence: the file and a screenshot.
- 05 curl on plain HTTP Run
curl -sIagainst thehttp://URL: it should answer with a permanent redirect whoseLocationis the HTTPS URL, and the HTTPS response from the second check carriesStrict-Transport-Security. Evidence: both outputs. - 06 The headers are in git The headers file or the proxy code is committed, so a redeploy from the repository keeps them. Evidence: the commit. On a builder that owns the headers there is nothing to commit; the first two checks still show what is sent.
The two curl lines, with your own domain in place of the example:
curl -sI https://yourapp.example/
curl -sI http://yourapp.example/
The framing test file, saved as frame-test.html and opened in the browser:
<!doctype html>
<title>Framing test</title>
<iframe src="https://yourapp.example/" width="800" height="600"></iframe>
In a correct build the app does not render inside the frame: frame-ancestors 'none' means the page may not be embedded, and DENY means it cannot be loaded in any frame. If the app renders there, neither header is doing its job on production. If you also run a header scanner such as securityheaders.com or Snyk’s checker, keep its report beside these; checks 3 and 4 still need your own browser on your own flows.
In the Production Hardening Sprint, deliverable 3.4 is verified this way: inspect response headers and exercise the application to confirm intended protections without broken flows.
Where the sprint does this
In the sprint, deliverable 3.4 is where we configure and test CSP, HSTS, framing restrictions and other appropriate browser security headers, and we check them with the steps in the section above. The result goes into the production readiness report, deliverable 13.1, which accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. Formal third-party certifications and independent audit opinions are separate from the sprint deliverables. The full list is at all 123 deliverables of the sprint.
Common questions about security headers
What to use instead of unsafe-inline?
Use a nonce that is new on every response, or a sha256, sha384 or sha512 hash of an inline block’s exact contents, and add 'strict-dynamic' so the scripts a trusted script loads are trusted too. Code that can live in a separate file needs neither, and moving it out is the cleanest fix.
How to solve Content-Security-Policy error?
Read the violation: the report’s effectiveDirective names the rule that blocked something, blockedURL names what was blocked, and for inline code sample shows its first characters, usually the first 40. Then give that code a nonce or a hash, or move it into a file, and reload. Never widen the policy or drop it to make the message go away; the message is the policy doing its job.
What is the difference between the unsafe-eval and unsafe-inline CSP values?
'unsafe-inline' lets code that is written into the page run: inline script tags, event handler attributes, javascript: URLs and, under style-src, inline styles. 'unsafe-eval' lets the page turn strings into code at run time through eval(), setTimeout() with a code string, or the Function() constructor, and MDN tells developers to avoid both.
How can I test for clickjacking?
Open a local HTML file that frames your own production URL, like the three-line file in the verify section, and confirm the app does not display inside the frame; then confirm with curl -sI that the response carries frame-ancestors in its Content-Security-Policy or an X-Frame-Options header. That is my method for your own app, not a procedure from OWASP, whose page defines the attack and names the defenses but gives no test.
What happens if HSTS is not enabled?
The browser has no instruction to insist on HTTPS, so a visit that starts from an http:// link makes its first request over plain HTTP, which MDN says is vulnerable to network attacks. You also lose a second protection: on a host that has sent HSTS, the browser offers no way to click through a certificate error.
What are the top 10 security headers recommended by OWASP?
OWASP’s HTTP Headers Cheat Sheet does not rank a top 10; it gives a recommendation per header, and some recommendations are to leave a header out, such as X-XSS-Protection and Expect-CT. The six I send on a small SaaS are in the table under “The rest of the set”.
The checks in this guide show you where the app is open. The sprint below closes those gaps, tests the result and writes the evidence down.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase