Cross-Site Scripting (XSS) is one of the most common and damaging vulnerabilities in web applications. If an attacker injects malicious scripts into your site, they can hijack user sessions, steal sensitive cookies, exfiltrate private API data, or deface the user interface. While input validation and output encoding are important defenses, a robust Content Security Policy (CSP) provides a critical layer of defense-in-depth by restricting what resources the browser can load and execute.
Traditional CSP configurations relied on domain allowlists. However, research has shown that allowlists are easily bypassed. Modern security standards recommend implementing a Strict CSP based on cryptographic nonces (number used once) and hashes.
This guide analyzes strict Content Security Policy directives, provides a complete Hono/TypeScript middleware implementation for dynamic nonce generation, and details production failure modes and mitigations.
Directives Breakdown and Scoping Rules
A Content Security Policy is delivered via the Content-Security-Policy HTTP response header or a <meta> tag. Directives define the policy boundaries for different resource types.
Strict CSP Script Execution Flow:
1. Request: Client requests page from the server.
2. Response: Server generates a unique cryptographic nonce (e.g., 'a8f9b2d8').
├─► Injects header: Content-Security-Policy: ... 'nonce-a8f9b2d8' 'strict-dynamic' ...
└─► Injects HTML: <script nonce="a8f9b2d8">console.log("Allowed!");</script>
3. Evaluation: Browser intercepts script tags.
├─► Nonce matches header: Script executes.
├─► Script dynamically creates new script: Allowed by 'strict-dynamic'.
└─► Attacker injects <script> without nonce: Blocked.
1. default-src
Acts as the fallback directive for resources that lack an explicit policy. Setting default-src 'self' ensures that, by default, resources (images, fonts, stylesheets) are only loaded from the site’s origin.
2. script-src
Restricts script execution. In a strict CSP, this directive utilizes a nonce combined with strict-dynamic:
script-src 'nonce-random_base64_value' 'strict-dynamic' 'self' https:;
- Nonce: The browser only executes
<script>tags that contain a matchingnonceattribute. - ‘strict-dynamic’: Instructs the browser to trust dynamically created scripts appended by an already trusted script. This simplifies loading third-party widgets and analytics libraries without bloating the CSP header.
- ‘self’ and https:: Fallbacks for older browsers that do not support nonces or
strict-dynamic. Modern browsers ignore these fallback values when a nonce is present.
3. object-src
Restricts plugins like Flash, Silverlight, or Java applets. Modern web applications do not require these formats. Setting object-src 'none' prevents attackers from using vulnerable plugins to bypass XSS protections.
4. base-uri
Restricts the URLs that can be used in the <base> tag. Setting base-uri 'self' or base-uri 'none' prevents attackers from altering relative path resolutions and redirecting link targets or script loads.
5. frame-ancestors
Defines which origins can embed the page inside <frame>, <iframe>, <embed>, or <object> elements. Setting frame-ancestors 'none' or frame-ancestors 'self' prevents clickjacking attacks, where an attacker embeds your site in a transparent frame to steal user interactions.
Production Integration Code
Below is a production-grade, highly secure implementation. It includes a TypeScript middleware for the Hono web framework that generates secure nonces and headers, and an HTML template that renders the generated nonce correctly.
1. Secure Hono Middleware (TypeScript)
This middleware runs on Bun or Node.js. It generates a cryptographically secure random base64 nonce for each incoming request, injects the security headers, and attaches the nonce to the request context.
// src/middleware/security.ts
import type { Context, Next } from "hono";
// Extend Hono context variable types
declare module "hono" {
interface ContextVariableMap {
cspNonce: string;
}
}
/**
* Generates a cryptographically secure random base64 string
*/
function generateSecureNonce(): string {
const buffer = new Uint8Array(16);
crypto.getRandomValues(buffer);
// Convert byte array to base64 securely
let binary = "";
for (let i = 0; i < buffer.byteLength; i++) {
binary += String.fromCharCode(buffer[i]);
}
return btoa(binary);
}
export async function securityHeadersMiddleware(c: Context, next: Next) {
// 1. Generate unique nonce for this request
const nonce = generateSecureNonce();
c.set("cspNonce", nonce);
// 2. Build Content Security Policy header values
const cspDirectives = [
`default-src 'self'`,
`script-src 'nonce-${nonce}' 'strict-dynamic' 'self' https:`,
`style-src 'self' 'unsafe-inline' https:`, // unsafe-inline is a fallback for older styling systems
`img-src 'self' data: https:`,
`font-src 'self' data: https:`,
`connect-src 'self' https://api.dexnox.com`, // Limit client API connections
`object-src 'none'`,
`base-uri 'self'`,
`frame-ancestors 'none'`,
`block-all-mixed-content`,
`upgrade-insecure-requests`,
`report-to csp-endpoint`
];
// 3. Attach headers to response
c.header("Content-Security-Policy", cspDirectives.join("; "));
// Attach supplementary security headers
c.header("X-Frame-Options", "DENY");
c.header("X-Content-Type-Options", "nosniff");
c.header("Referrer-Policy", "strict-origin-when-cross-origin");
c.header("Permissions-Policy", "geolocation=(), camera=(), microphone=()");
// Set up Report-To configuration header
const reportToHeader = {
group: "csp-endpoint",
max_age: 10886400,
endpoints: [{ url: "https://report.dexnox.com/csp-violations" }],
include_subdomains: true
};
c.header("Report-To", JSON.stringify(reportToHeader));
await next();
}
2. Context-Injected HTML Renderer
This controller displays how to extract the generated nonce from the Hono context and inject it into the HTML document shell and scripts.
// src/routes/dashboard.ts
import { Hono } from "hono";
import { securityHeadersMiddleware } from "../middleware/security";
const app = new Hono();
app.use("*", securityHeadersMiddleware);
app.get("/dashboard", (c) => {
const nonce = c.get("cspNonce");
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Secure Dashboard | DexNox</title>
<!-- Safe stylesheet reference -->
<link rel="stylesheet" href="/styles/main.css">
</head>
<body>
<div id="app">
<h1>Secure Enterprise Portal</h1>
<p>This workspace is protected by a strict Content Security Policy.</p>
<button id="action-btn">Trigger Action</button>
</div>
<!-- Secure script tag using the generated nonce -->
<script nonce="${nonce}">
document.getElementById('action-btn').addEventListener('click', () => {
console.log("Action performed securely. Nonce validated.");
});
</script>
<!-- External script dependencies run safely via strict-dynamic if loaded by a trusted script -->
<script nonce="${nonce}">
(function() {
const script = document.createElement('script');
script.src = 'https://analytics.thirdparty.com/tracker.js';
script.async = true;
// Dynamically appended scripts execute because of 'strict-dynamic'
document.head.appendChild(script);
})();
</script>
</body>
</html>
`;
return c.html(html);
});
export default app;
CSP Architecture Comparison
The table below evaluates different Content Security Policy designs across security, runtime, and maintenance dimensions:
| Strategy | No CSP | Domain Allowlist CSP | Strict Nonce-Based CSP | Report-Only Strict CSP |
|---|---|---|---|---|
| XSS Defense Level | None | Low (Easily bypassed) | High (Cryptographic safety) | None (Monitoring only) |
| Deployment Friction | None | Medium | High (Requires templating) | Low |
| Asset Compatibility | Complete | High | Requires modifying inline tags | Complete |
| Third-Party Script Support | Complete | Poor (Requires whitelists) | High (via strict-dynamic) | Complete |
| Maintenance Overhead | None | High (Regular whitelist audits) | Low (Template-bound) | Low |
| Browser Execution Cost | None | Low | Low (Cryptographic verification) | Low |
| Leakage Monitoring | None | No | Yes (via Violation Reports) | Yes (via Violation Reports) |
What Breaks in Production: Failure Modes and Mitigations
Implementing a strict CSP requires planning. Below are four common production issues with tested engineering solutions.
1. Third-Party Analytics and Chat Widgets Blocked
Many analytics tools and chat widgets inject inline script tags or styles directly into the DOM when initialized. Under a strict CSP, these inline injections fail to run because they lack the required cryptographic nonce, resulting in silent load errors.
- Root Cause: The third-party loader script appends arbitrary, un-nonced scripts to the document, which are blocked by the browser.
- Mitigation: Leverage the
'strict-dynamic'directive. When'strict-dynamic'is enabled in thescript-srcheader, any script tag dynamically created by an already trusted script (one that was loaded with a valid nonce) is allowed to run. Ensure the initial loader script is loaded with the correct nonce:<script nonce="${nonce}" src="https://analytics.google.com/loader.js"></script>
2. Inline Style Violations (unsafe-inline Blocks)
Many UI frameworks and libraries use inline styles (style="display:none" or dynamic height calculations) to manage animations and visibility. A strict CSP that excludes 'unsafe-inline' from style-src blocks these inline styles.
- Root Cause: The browser blocks inline
styleattributes to prevent style-injection attacks, which can be used to exfiltrate data. - Mitigation: If refactoring away from inline styles is not feasible, use CSS Custom Properties or generate cryptographic hashes of the specific inline style values and add them to the
style-srcheader:
Alternatively, declare a fallbackstyle-src 'self' 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=''unsafe-inline'in yourstyle-srcif your application contains legacy libraries, recognizing that this exposes a minor styling injection risk.
3. Server-Side Rendering (SSR) Cache Hits and Nonce Re-use
When pages are cached in a reverse proxy (like Cloudflare, Varnish, or Fastly), the HTML is cached along with its headers. If the generated CSP header contains a static nonce, that nonce is cached and served to subsequent users.
- Root Cause: The cached page contains a nonce that matches the cached HTML, but if a user accesses a dynamic route where the header is updated but the HTML is cached (or vice-versa), the nonces mismatch, blocking all scripts.
- Mitigation: Configure your CDN to run Edge Workers (such as Cloudflare Workers) to strip the cached nonces and inject a new, cryptographically unique nonce into both the HTML and the CSP headers on the fly:
// Example Edge Worker logic for runtime nonce injection async function handleRequest(request) { const response = await fetch(request); const nonce = generateSecureNonce(); // Rewrite CSP headers on the fly const newHeaders = new Headers(response.headers); let csp = newHeaders.get("Content-Security-Policy"); csp = csp.replace(/nonce-[a-zA-Z0-9+/=]+/g, `nonce-${nonce}`); newHeaders.set("Content-Security-Policy", csp); // HTML rewriter to replace nonce placeholder const rewriter = new HTMLRewriter() .on("script", { element(el) { if (el.hasAttribute("nonce")) { el.setAttribute("nonce", nonce); } } }); return rewriter.transform(new Response(response.body, { ...response, headers: newHeaders })); }
4. Violation Report Floods and Server Exhaustion
When deploying a new CSP, browser extensions, adware, and misconfigured clients can trigger thousands of violation reports. If your reporting endpoint (report-uri or report-to) is hosted on your main application server, the volume of reports can overwhelm your database.
- Root Cause: Browsers send CSP violation reports as HTTP POST requests with a JSON payload. A high volume of reports can consume server threads and disk space.
- Mitigation: Route violation reports to a dedicated ingestion service (such as Sentry, Report-URI, or an isolated serverless function) that runs outside of your primary database and web application infrastructure. Set up rate-limiting on the ingestion endpoint.
Frequently Asked Questions
What is a CSP nonce?
A CSP nonce is a cryptographically secure, random, single-use token generated by the server for each HTTP request. It is included in the Content-Security-Policy header and added to trusted script tags in the HTML: <script nonce="random_value">. The browser blocks any script tag that does not contain a matching nonce.
How do you test a CSP header before deploying it?
Use the Content-Security-Policy-Report-Only header instead of Content-Security-Policy. This instructs the browser to evaluate the policy and send violation reports to the specified endpoint, without blocking scripts or resources.
Why are domain allowlists discouraged in modern CSP?
Domain allowlists are difficult to maintain and vulnerable to bypasses. If a whitelisted domain hosts open redirectors, JSONP endpoints, or allows user-uploaded content (like Amazon S3 or jsDelivr), an attacker can use these resources to load and execute malicious scripts, rendering the CSP ineffective.
How does strict-dynamic simplify CSP maintenance?
The strict-dynamic directive instructs the browser to trust scripts created by a dynamically executing trusted script. This allows you to load third-party scripts and dependencies (like Tag Managers or widgets) without having to manually list every domain or script source in your CSP header.
Wrapping Up
Implementing a strict Content Security Policy is a highly effective way to defend against Cross-Site Scripting (XSS) attacks. By shifting from domain allowlists to a nonce-based model with strict-dynamic, developers can secure their applications while maintaining compatibility with modern dynamic scripts. Mitigating inline style blocks, using edge workers for nonce injection on cached pages, and isolating violation report handlers ensures a robust, production-ready security posture.