Skip to content

Instantly share code, notes, and snippets.

@devinschumacher
Last active May 23, 2025 23:47
Show Gist options
  • Save devinschumacher/fabb57f9860c91de5922cfd0919202f0 to your computer and use it in GitHub Desktop.
Save devinschumacher/fabb57f9860c91de5922cfd0919202f0 to your computer and use it in GitHub Desktop.
Guide to Web Security Headers + nuxt.config.ts settings for nuxt-config

Web Security Headers: Beginner's Guide (

What is This All About?

Content Security Policy (CSP) is like a bouncer for your website. It tells the browser: "Only allow content (scripts, images, etc.) from these approved sources." This prevents malicious code from running on your site.


Abbreviations & What They Mean

Short Form Full Name What It Does
CSP Content Security Policy Controls what resources can load on your page
XSS Cross-Site Scripting Malicious scripts injected into your site
CSRF Cross-Site Request Forgery Tricks users into making unwanted requests
CORS Cross-Origin Resource Sharing Controls API access between different domains
COEP Cross-Origin-Embedder-Policy Controls embedding content from other sites
COOP Cross-Origin-Opener-Policy Controls popup/window interactions
CORP Cross-Origin-Resource-Policy Controls who can embed your resources
HSTS HTTP Strict Transport Security Forces HTTPS connections

Common CSP Directives (The Rules)

Directive Controls Example
default-src Fallback for everything 'self' = only your own domain
script-src JavaScript files Where scripts can load from
script-src-elem <script> tags specifically More specific than script-src
style-src CSS files Where stylesheets can load from
img-src Images Where images can load from
connect-src API calls, websockets Where your code can make requests to
frame-src Iframes, embeds What you can embed (YouTube, etc.)
font-src Web fonts Where fonts can load from

Specificity Rules (Most Important!)

The browser checks the MOST SPECIFIC rule first:

Priority Directive When It Applies
🥇 Highest script-src-elem <script> tags
🥈 script-src-attr onclick="..." attributes
🥉 Fallback script-src All JavaScript
🏁 Last Resort default-src If nothing else matches

Translation: If you have both script-src and script-src-elem, the browser ignores script-src for <script> tags.


Special Keywords

Keyword Meaning Security Level
'self' Your own domain only Secure
'none' Block everything Very Secure
'unsafe-inline' Allow inline code ⚠️ Risky
'unsafe-eval' Allow eval() functions ⚠️ Very Risky
https: Any HTTPS site Too Permissive
data: Base64 embedded content ⚠️ Moderate Risk

Other Security Headers Explained

Header What Attack It Prevents What It Does
X-Frame-Options Clickjacking Stops others from embedding your site in frames
X-Content-Type-Options MIME sniffing attacks Forces browser to trust your file type declarations
X-XSS-Protection Cross-site scripting Browser's built-in XSS filter (mostly legacy)
Referrer-Policy Information leakage Controls what info is sent when users click links
Permissions-Policy Feature abuse Controls access to camera, microphone, location, etc.

Domain Patterns

Pattern Matches Example
https://example.com Exact domain only Just example.com
https://*.example.com Any subdomain api.example.com, cdn.example.com
https: Any HTTPS site ⚠️ Too broad - avoid!
'self' Your current domain Whatever domain the page is on

Quick Troubleshooting

When You See CSP Errors:

  1. Look at the error message - it tells you exactly what failed
  2. Identify the directive - which rule was violated?
  3. Add the domain to the right place - don't just add it everywhere
  4. Test locally first - don't wait for deployment

Common Error Patterns:

Error Contains Add Domain To Why
"script" script-src-elem JavaScript loading
"img-src" img-src Image loading
"connect-src" connect-src API calls, websockets
"frame-src" frame-src YouTube embeds, iframes

The Big Picture

Think of CSP like airport security:

  • default-src 'self' = "Only people from this terminal"
  • script-src 'https://cdn.example.com' = "Also allow people from this specific airline"
  • 'unsafe-inline' = "Let anyone through without checking" ⚠️

The goal: Allow what you need, block everything else.


Pro Tips

Start strict, then add permissions as needed
Test in development with CSP enabled
Be specific - avoid wildcards when possible
Check browser console for violation reports

Don't disable CSP entirely
Don't use 'unsafe-inline' unless absolutely necessary
Don't add domains to every directive - be precise

Scenario 1: Everything in default-src

contentSecurityPolicy: {
  'default-src': [
    "'self'", 
    "https://embed.tawk.to", 
    "https://*.youtube.com",
    "https://*.google-analytics.com"
    // ... all your domains
  ]
}

What happens:

  • Scripts work from all those domains
  • Images work from all those domains
  • Styles work from all those domains
  • API calls work to all those domains
  • Frames work from all those domains

The problem: This is overly permissive! You're basically saying "YouTube can load scripts, images, make API calls, AND embed frames on my site" when YouTube probably only needs to embed videos.


Scenario 2: Everything in script-src-elem only

contentSecurityPolicy: {
  'default-src': ["'self'"],  // Only your domain for everything else
  'script-src-elem': [
    "'self'",
    "https://embed.tawk.to", 
    "https://*.youtube.com",
    "https://*.google-analytics.com"
    // ... all your domains
  ]
}

What happens:

  • Scripts work from all those domains
  • Images FAIL from external domains (falls back to default-src: 'self')
  • Styles FAIL from external domains (falls back to default-src: 'self')
  • API calls FAIL to external domains (falls back to default-src: 'self')
  • Frames FAIL from external domains (falls back to default-src: 'self')

The problem: You'll get errors like:

Refused to load image 'https://youtube.com/thumbnail.jpg' because it violates img-src directive
Refused to display 'https://youtube.com/embed/video' in a frame because it violates frame-src directive  

What Actually Happens (Concrete Example)

Let's say you have a YouTube embed on your page:

With everything in default-src:

<!-- This works, but gives YouTube too much power -->
<iframe src="https://youtube.com/embed/video123"></iframe>
<img src="https://youtube.com/thumbnail.jpg">
<script src="https://youtube.com/analytics.js"></script>

With everything in script-src-elem only:

<script src="https://youtube.com/analytics.js"></script>  <!-- Works --><iframe src="https://youtube.com/embed/video123"></iframe>  <!-- Blocked --><img src="https://youtube.com/thumbnail.jpg">             <!-- Blocked -->

The Right Way

Be specific about what each domain actually needs:

contentSecurityPolicy: {
  'default-src': ["'self'"],
  
  // Scripts: Only domains that actually serve JavaScript
  'script-src-elem': ["'self'", "https://embed.tawk.to", "https://*.google-analytics.com"],
  
  // Images: Domains that serve images/thumbnails  
  'img-src': ["'self'", "https://*.youtube.com", "https://*.googleapis.com"],
  
  // Frames: Only YouTube for video embeds
  'frame-src': ["https://*.youtube.com"],
  
  // API calls: Only what your code actually connects to
  'connect-src': ["'self'", "wss://embed.tawk.to", "https://*.google-analytics.com"]
}

This follows the principle of least privilege - each domain only gets the minimum permissions it needs to function.


Quick Test

Want to see this in action? Try putting everything in default-src first - your site will work but be less secure. Then move domains to specific directives and watch certain things break until you put them in the right place. It's a great way to learn what each service actually needs!

nuxt.config.ts settings and config explainer

// nuxt.config.ts - Complete Nuxt Security Configuration Guide

export default defineNuxtConfig({
  // ===========================================
  // BASE SECURITY CONFIG (applies to all environments)
  // ===========================================
  security: {
    strict: true, // Enables all security features by default
    rateLimiter: {
      // API rate limiting (optional)
      tokensPerInterval: 150,
      interval: 300000, // 5 minutes
      headers: false, // Don't expose rate limit in headers
    },
    
    headers: {
      // CONTENT SECURITY POLICY - The main security layer
      contentSecurityPolicy: {
        // Baseline - everything falls back to this
        'default-src': ["'self'"],
        
        // Scripts - BE VERY CAREFUL HERE
        'script-src-elem': [
          "'self'",
          // Add domains that serve JavaScript files ONLY
          // DON'T add image or frame domains here
        ],
        
        // Styles - Usually need 'unsafe-inline' for CSS-in-JS
        'style-src': [
          "'self'", 
          "'unsafe-inline'" // Most apps need this for dynamic styles
        ],
        
        // Images - Can be more permissive
        'img-src': [
          "'self'",
          'data:', // Base64 images
          'blob:', // Generated images
          // Add CDNs and image services here
        ],
        
        // API calls and websockets
        'connect-src': [
          "'self'",
          // Add your API endpoints here
          // Add websocket endpoints (wss://)
        ],
        
        // Embedded content (YouTube, maps, etc.)
        'frame-src': [
          // Only add domains you actually embed
          // Don't add unless you use iframes
        ],
        
        // Web fonts
        'font-src': [
          "'self'",
          'data:', // Inline fonts
          // Add font CDNs like Google Fonts
        ],
        
        // Audio/Video
        'media-src': ["'self'"],
        
        // Web Workers (if you use them)
        'worker-src': ["'self'"],
        
        // Forms - where forms can submit to
        'form-action': ["'self'"],
        
        // Base URI for relative URLs
        'base-uri': ["'self'"],
        
        // Manifest files
        'manifest-src': ["'self'"]
      },
      
      // CROSS-ORIGIN POLICIES
      crossOriginEmbedderPolicy: 'unsafe-none', // or 'require-corp' for stricter
      crossOriginOpenerPolicy: 'same-origin-allow-popups', // Allows OAuth popups
      crossOriginResourcePolicy: 'same-origin', // or 'cross-origin' if needed
      
      // LEGACY SECURITY HEADERS (still useful)
      xContentTypeOptions: 'nosniff', // Prevent MIME sniffing
      xDNSPrefetchControl: 'off', // Disable DNS prefetching
      xDownloadOptions: 'noopen', // IE download security
      xFrameOptions: 'SAMEORIGIN', // Prevent clickjacking (CSP frame-ancestors is better)
      xPermittedCrossDomainPolicies: 'none', // Block Flash/PDF policies
      xXSSProtection: '1; mode=block', // Legacy XSS protection
      
      // REFERRER POLICY - Controls what info is sent in referrer header
      referrerPolicy: 'strict-origin-when-cross-origin',
      
      // PERMISSIONS POLICY - Controls browser features
      permissionsPolicy: {
        camera: [], // Block camera access
        microphone: [], // Block microphone  
        geolocation: [], // Block location
        'picture-in-picture': [], // Allow PiP if needed
        fullscreen: ["'self'"], // Allow fullscreen on your domain
        'payment': [], // Block payment API
        'usb': [], // Block USB API
        // Add others as needed: 'accelerometer', 'gyroscope', etc.
      },
      
      // STRICT TRANSPORT SECURITY - Force HTTPS
      strictTransportSecurity: {
        maxAge: 31536000, // 1 year
        includeSubdomains: true,
        preload: true // Submit to browser preload lists
      }
    },
    
    // SSG-specific settings (for static generation)
    ssg: {
      meta: true, // Add security meta tags
      hashScripts: true, // Add integrity hashes to scripts
      hashStyles: false, // Usually causes issues with dynamic styles
      nitroHeaders: true, // Add headers at build time
      exportToPresets: true // Export security config to preset files
    },
    
    // REQUEST SIZE LIMITS
    requestSizeLimiter: {
      maxRequestSizeInBytes: 2000000, // 2MB
      maxUploadFileRequestInBytes: 8000000, // 8MB
    },
    
    // HIDE SERVER INFO
    hidePoweredBy: true, // Remove X-Powered-By header
    
    // BASIC AUTH (for staging environments)
    basicAuth: false // Enable in staging config below
  },

  // ===========================================
  // PRODUCTION ENVIRONMENT - STRICTEST SECURITY
  // ===========================================
  $production: {
    security: {
      headers: {
        contentSecurityPolicy: {
          'default-src': ["'self'"],
          
          // Production scripts - be VERY specific
          'script-src-elem': [
            "'self'",
            // Analytics
            'https://www.googletagmanager.com',
            'https://*.googletagmanager.com',
            'https://www.google-analytics.com',
            'https://*.google-analytics.com',
            
            // Ads (if needed)
            'https://pagead2.googlesyndication.com',
            'https://*.googlesyndication.com',
            'https://googleads.g.doubleclick.net',
            'https://*.doubleclick.net',
            
            // Third-party services
            'https://embed.tawk.to', // Chat widget
            'https://static.cloudflareinsights.com', // Cloudflare analytics
            
            // Your CDNs/services
            // 'https://cdn.yourservice.com',
          ],
          
          'style-src': [
            "'self'",
            "'unsafe-inline'", // Usually needed
            // Add CSS CDNs here
            // 'https://fonts.googleapis.com',
          ],
          
          'img-src': [
            "'self'",
            'data:',
            'blob:',
            // Image CDNs and services
            'https://*.googleapis.com',
            'https://*.youtube.com', // YouTube thumbnails
            'https://*.google-analytics.com',
            // 'https://cdn.yourservice.com',
          ],
          
          'connect-src': [
            "'self'",
            // Analytics endpoints
            'https://www.google-analytics.com',
            'https://*.google-analytics.com',
            'https://analytics.google.com',
            
            // Websockets
            'wss://embed.tawk.to', // Tawk.to chat
            
            // Your APIs
            // 'https://api.yourservice.com',
          ],
          
          'frame-src': [
            // Only what you actually embed
            'https://*.youtube.com',
            'https://*.youtube-nocookie.com',
            // Google Ads iframes (if needed)
            'https://*.googlesyndication.com',
          ],
          
          'font-src': [
            "'self'",
            'data:',
            'https://fonts.gstatic.com', // Google Fonts
          ]
        },
        
        // Strictest transport security
        strictTransportSecurity: {
          maxAge: 63072000, // 2 years
          includeSubdomains: true,
          preload: true
        }
      }
    }
  },

  // ===========================================
  // DEVELOPMENT ENVIRONMENT - LOOSER BUT STILL PROTECTIVE
  // ===========================================
  $development: {
    security: {
      // Keep most security enabled but allow dev tools
      headers: {
        contentSecurityPolicy: {
          'default-src': ["'self'"],
          
          'script-src-elem': [
            "'self'",
            "'unsafe-eval'", // Required for Vite HMR
            "'unsafe-inline'", // Sometimes needed for dev tools
            
            // Include all your production domains for testing
            'https://www.googletagmanager.com',
            'https://*.googletagmanager.com',
            'https://embed.tawk.to',
            'https://static.cloudflareinsights.com',
            // ... all your production script sources
          ],
          
          'style-src': [
            "'self'",
            "'unsafe-inline'", // Needed for CSS HMR
            'blob:', // Vite injects styles as blobs
          ],
          
          'img-src': [
            "'self'",
            'data:',
            'blob:',
            'https:', // More permissive for development
          ],
          
          'connect-src': [
            "'self'",
            // Dev server connections
            'ws://localhost:*', // Vite HMR websocket
            'ws://127.0.0.1:*',
            'http://localhost:*',
            'https://localhost:*',
            
            // Include production endpoints for testing
            'https://www.google-analytics.com',
            'wss://embed.tawk.to',
            // ... your production connect sources
          ],
          
          'frame-src': [
            "'self'",
            // Same as production
            'https://*.youtube.com',
            'https://*.youtube-nocookie.com',
          ],
          
          'font-src': [
            "'self'",
            'data:',
            'https:', // More permissive for dev
          ]
        },
        
        // Disable HSTS in development (allows HTTP)
        strictTransportSecurity: false
      }
    }
  },

  // ===========================================
  // STAGING ENVIRONMENT - MIDDLE GROUND
  // ===========================================
  $staging: {
    security: {
      // Same as production but with basic auth and some debugging
      basicAuth: {
        name: 'staging',
        pass: 'your-staging-password',
        enabled: true,
        message: 'Staging Environment - Enter credentials'
      },
      
      headers: {
        // Same CSP as production
        contentSecurityPolicy: {
          // ... copy from production but maybe add:
          'report-uri': ['/csp-violation-report'], // CSP violation reporting
        }
      }
    }
  },

  // ===========================================
  // TEST ENVIRONMENT - MINIMAL SECURITY FOR E2E TESTS
  // ===========================================
  $test: {
    security: {
      // Disable rate limiting for tests
      rateLimiter: false,
      
      headers: {
        // Very permissive CSP for testing
        contentSecurityPolicy: {
          'default-src': ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
          'script-src-elem': ["'self'", "'unsafe-inline'", "'unsafe-eval'", 'https:'],
          'style-src': ["'self'", "'unsafe-inline'"],
          'img-src': ["'self'", 'data:', 'https:', 'blob:'],
          'connect-src': ["'self'", 'https:', 'wss:', 'ws:'],
          'frame-src': ['https:']
        }
      }
    }
  },

  // ===========================================
  // COMMON PATTERNS FOR SPECIFIC SERVICES
  // ===========================================
  
  // If you use Google Services, add this to your environment configs:
  /*
  Google Analytics + Tag Manager + Ads:
  'script-src-elem': [
    'https://www.googletagmanager.com',
    'https://*.googletagmanager.com',
    'https://www.google-analytics.com', 
    'https://*.google-analytics.com',
    'https://pagead2.googlesyndication.com',
    'https://*.googlesyndication.com',
    'https://googleads.g.doubleclick.net',
    'https://*.doubleclick.net'
  ],
  'connect-src': [
    'https://www.google-analytics.com',
    'https://*.google-analytics.com',
    'https://analytics.google.com'
  ],
  'img-src': [
    'https://*.google-analytics.com',
    'https://*.googletagmanager.com',
    'https://*.doubleclick.net'
  ],
  'frame-src': [
    'https://*.googlesyndication.com' // For ad iframes
  ]
  */

  // If you use YouTube embeds:
  /*
  'frame-src': [
    'https://*.youtube.com',
    'https://*.youtube-nocookie.com'
  ],
  'img-src': [
    'https://*.youtube.com' // For thumbnails
  ]
  */

  // If you use Stripe:
  /*
  'script-src-elem': ['https://js.stripe.com'],
  'connect-src': ['https://api.stripe.com'],
  'frame-src': ['https://js.stripe.com', 'https://hooks.stripe.com']
  */

  // If you use Google Fonts:
  /*
  'style-src': ['https://fonts.googleapis.com'],
  'font-src': ['https://fonts.gstatic.com']
  */
})

Google Ads CSP Requirements

Google's Official Recommendation

Google recommends using strict CSP with nonces rather than domain allowlists because "the domains that Google Publisher Tag (GPT) uses change over time". However, if you must use domain-based CSP, here are the known requirements:

Core Google Ads Domains

Script Sources (script-src-elem)

'script-src-elem': [
  "'self'",
  // Core AdSense/Ads domains
  "https://pagead2.googlesyndication.com",
  "https://partner.googleadservices.com", 
  "https://tpc.googlesyndication.com",
  "https://www.googletagservices.com",
  "https://securepubads.g.doubleclick.net", // GPT
  
  // Analytics (if using)
  "https://www.googletagmanager.com",
  "https://*.googletagmanager.com"
]

Frame Sources (frame-src) - For Ad Iframes

'frame-src': [
  "https://googleads.g.doubleclick.net",
  "https://tpc.googlesyndication.com",
  "https://*.googlesyndication.com"
]

Image Sources (img-src) - For Tracking Pixels

'img-src': [
  "'self'",
  "https://pagead2.googlesyndication.com",
  // Problem: Google uses geo-specific domains
  "https://www.google.com",
  "https://www.google.co.uk", 
  "https://www.google.ca",
  // ... potentially hundreds more
]

Connect Sources (connect-src) - For API Calls

'connect-src': [
  "'self'",
  "https://pagead2.googlesyndication.com"
]

The Geographic Domain Problem

Google uses different domains based on user location (google.com, google.co.uk, google.ca, etc.) which makes domain-based CSP very difficult. The community solutions are:

Option 1: Use https: for images (less secure)

'img-src': ["'self'", "https:"]

Option 2: Add hundreds of country domains (from the search results)

'script-src-elem': [
  "https://adservice.google.com",
  "https://adservice.google.ad", 
  "https://adservice.google.ae",
  "https://adservice.google.com.af",
  // ... continue for all 200+ countries
]

Option 3: Google's Recommended Approach - Nonces

// Use nonces instead of domains
'script-src': ["'nonce-{random-value}'", "'unsafe-inline'", "'strict-dynamic'"]

Why This Is So Hard

  1. Google uses hundreds of domains for geographic optimization
  2. These domains change over time without notice
  3. Different Google services use different domain patterns
  4. Most documentation is incomplete or outdated

Practical Recommendation

Start with the core domains above, then use browser dev tools to catch what you miss. Google's own recommendation is to use nonces instead of trying to maintain domain lists.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment