Web security

CSP and security headers

Content Security Policy in practice — directives, nonces versus hashes versus strict-dynamic, report-only rollout, how Nuxt's inline payload and scripts interact with it and what nuxt-security automates — plus HSTS, nosniff, Referrer-Policy, Permissions-Policy, frame-ancestors, the cross-origin isolation headers and subresource integrity.

Security headers are the cheapest defence you can ship and the most common gap in a "we use a framework" codebase. Interviewers ask "which headers do you set" as a quick competence check, and "how would you roll out CSP" as the follow-up that separates people who have done it from people who have read about it.

Know

  • Content Security Policy tells the browser which sources may load and execute. Key directives: default-src (fallback), script-src, style-src, img-src, font-src, connect-src (fetch/XHR/WebSocket/EventSource targets), frame-src, frame-ancestors (who may embed you; replaces X-Frame-Options), base-uri (stops <base> hijacking of relative URLs), form-action, object-src 'none', upgrade-insecure-requests, report-to/report-uri.
  • Scripts: nonces, hashes or strict-dynamic. 'unsafe-inline' defeats the purpose. A nonce (script-src 'nonce-abc123') is a per-request random value the server puts on every legitimate <script>; hashes ('sha256-…') allow specific inline scripts and suit static sites; 'strict-dynamic' lets nonce-approved scripts load further scripts without listing every CDN. A strict policy is script-src 'nonce-…' 'strict-dynamic'; object-src 'none'; base-uri 'self'.
  • Nuxt specifics: the payload is a <script type="application/json"> that browsers never execute, so it needs no nonce; Nuxt's module scripts and any inline scripts (useHead inline, onPrehydrate, third-party tags) do. Per-request nonces require SSR (a static prerender cannot vary the nonce; use hashes there, or Content-Security-Policy via meta with hashes). The nuxt-security module generates a nonce per request (useNonce()), injects it into Nuxt's scripts and useHead entries, sets sensible defaults for every other header, and offers hash-based CSP for SSG (ssg.hashScripts).
  • Rollout: start with Content-Security-Policy-Report-Only and a reporting endpoint (report-to with a Reporting-Endpoints header, or report-uri to your own /api/csp-report); fix violations (third-party tags, inline handlers, style-src for inline styles from UI libraries); then enforce. Expect style-src to be the painful one: Tailwind and Nuxt UI are fine, but inline style="" attributes need 'unsafe-inline' for styles or a hash strategy; style-src-attr and style-src-elem allow separating them.
  • HSTS: Strict-Transport-Security: max-age=63072000; includeSubDomains; preload forces HTTPS for future visits; only enable once every subdomain works over HTTPS; preload is a one-way door.
  • X-Content-Type-Options: nosniff stops MIME sniffing (scripts served as text stay text, uploads do not become HTML). Referrer-Policy: strict-origin-when-cross-origin (the browser default now; set it explicitly) keeps paths and query strings from leaking to other origins. Permissions-Policy disables features you do not use (camera=(), microphone=(), geolocation=(), interest-cohort=()). X-Frame-Options: DENY for old browsers, superseded by frame-ancestors. X-XSS-Protection is obsolete and should be removed.
  • Cross-origin isolation: Cross-Origin-Opener-Policy: same-origin (protects against tab-nabbing and enables isolation), Cross-Origin-Embedder-Policy: require-corp (needed for SharedArrayBuffer; breaks third-party embeds without CORP/CORS), Cross-Origin-Resource-Policy: same-origin (stops other sites from loading your resources).
  • Subresource Integrity: integrity="sha384-…" on third-party <script>/<link> with crossorigin="anonymous", so a compromised CDN file fails to load; irrelevant for your own hashed assets served from the same origin.
  • Cookies are headers too: Secure, HttpOnly, SameSite, __Host- prefix (fetch, CORS and cookies).
  • Where to set them: routeRules headers in nuxt.config, a Nitro plugin on beforeResponse for dynamic values (nonces), the nuxt-security module, or the CDN (Cloudflare Transform Rules, _headers files for static hosts, as this app does for caching). Test with securityheaders.com, Google's CSP Evaluator, and the browser console's violation reports.

How it works

A strict, nonce-based CSP for a server-rendered Nuxt app with the nuxt-security module:

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-security'],
  security: {
    nonce: true, // per-request nonce injected into Nuxt's scripts and useHead entries
    headers: {
      contentSecurityPolicy: {
        'default-src': ["'self'"],
        'script-src': ["'self'", "'nonce-{{nonce}}'", "'strict-dynamic'"],
        'style-src': ["'self'", "'unsafe-inline'"], // inline style attributes from UI libraries; tighten with hashes if you can
        'img-src': ["'self'", 'data:', 'https://images.example.com'],
        'font-src': ["'self'"],
        'connect-src': ["'self'", 'https://api.example.com', 'wss://realtime.example.com'],
        'frame-ancestors': ["'none'"],
        'base-uri': ["'self'"],
        'form-action': ["'self'"],
        'object-src': ["'none'"],
        'upgrade-insecure-requests': true,
        'report-to': 'csp',
      },
      strictTransportSecurity: { maxAge: 63072000, includeSubdomains: true, preload: true },
      xContentTypeOptions: 'nosniff',
      referrerPolicy: 'strict-origin-when-cross-origin',
      permissionsPolicy: { camera: [], microphone: [], geolocation: [] },
      crossOriginOpenerPolicy: 'same-origin',
      crossOriginResourcePolicy: 'same-origin',
      xFrameOptions: 'DENY',
    },
  },
  routeRules: {
    // Report-only for a new policy on part of the site while it is being tuned
    '/beta/**': { security: { headers: { contentSecurityPolicy: false } } },
  },
})

Using the nonce for a third-party script and collecting violation reports:

app/components/Analytics/Tag.vue
<script setup lang="ts">
const nonce = useNonce() // from nuxt-security
useHead({
  script: [{ src: 'https://analytics.example.com/tag.js', nonce, async: true }],
})
</script>
server/api/csp-report.post.ts
export default defineEventHandler(async (event) => {
  const reports = await readBody<{ body?: { blockedURL?: string, effectiveDirective?: string, documentURL?: string } }[]>(event)
  for (const r of Array.isArray(reports) ? reports : [reports]) {
    console.warn('[csp]', r.body?.effectiveDirective, r.body?.blockedURL, r.body?.documentURL)
  }
  setResponseStatus(event, 204)
})
server/plugins/reporting.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('beforeResponse', (event) => {
    setResponseHeader(event, 'Reporting-Endpoints', 'csp="/api/csp-report"')
  })
})

Static hosting without SSR (this app's model): hashes instead of nonces, headers from the host:

public/_headers
/*
  Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=()
  Cross-Origin-Opener-Policy: same-origin
  Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-<hash-of-inline-script>'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
Gotcha· Report-only forever

Teams roll out Report-Only, get busy, and never enforce. Time-box the tuning phase, fix the third-party tags (most violations are them), and switch to enforcement per route group so a regression cannot silently ship.

Gotcha· A nonce that does not change

A nonce reused across requests, or a CSP served from a cached response with the nonce baked in, is a fixed string an attacker can read from the HTML and reuse: no protection. Nonces need per-request rendering, which also means nonce-based CSP and CDN-cached HTML do not mix; cached pages use hashes.

Verify before the interview:

The current nuxt-security option names (nonce, ssg.hashScripts, header keys) and whether it supports Nuxt 4.5 without workarounds; also whether Chrome has shipped Reporting-Endpoints-only reporting (deprecating report-uri).

docs ↗

Exercise

Exercise
  • Run securityheaders.com against this app's deployed URL and against a project of yours; fix every missing header via _headers or route rules.
  • Enable a report-only strict CSP on a Nuxt app with one third-party tag; collect a day of reports, then enforce.
  • Try Cross-Origin-Embedder-Policy: require-corp on a page with a YouTube embed; observe what breaks and why.

Be able to say

Be able to say· Which security headers do you ship by default, and how do you introduce a Content Security Policy?

"HSTS with a long max-age once every subdomain is HTTPS, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, a Permissions-Policy that turns off features we do not use, frame-ancestors 'none' or a specific allow-list with X-Frame-Options for old browsers, Cross-Origin-Opener-Policy: same-origin, and cookies with Secure, HttpOnly and SameSite. CSP I introduce in report-only mode with a reporting endpoint: a strict policy with a per-request nonce and strict-dynamic for scripts, object-src 'none', base-uri 'self', connect-src limited to our APIs, then I fix the violations, which are almost always third-party tags and inline styles, and enforce it route group by route group. In Nuxt the nuxt-security module injects the nonce into Nuxt's scripts and useHead entries, and for a prerendered site I use script hashes instead, because nonces need per-request rendering."

Be able to say· Why is a nonce-based CSP stronger than an allow-list of domains?

"A domain allow-list says 'scripts from cdn.example.com may run', and any script on that domain, including a JSONP endpoint or an old library with an eval gadget, becomes a bypass; research has shown most allow-list policies are bypassable. A nonce says 'only the script tags that carry this request's random value may run', which an injected script cannot know, and strict-dynamic lets those approved scripts load their dependencies without listing every host. Combined with object-src 'none' and base-uri 'self', an XSS that manages to inject markup still cannot execute, which is the point: CSP is the layer for when output encoding has already failed."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.