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.
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.'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'.<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).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.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-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).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.Secure, HttpOnly, SameSite, __Host- prefix (fetch, CORS and cookies).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.A strict, nonce-based CSP for a server-rendered Nuxt app with the nuxt-security module:
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:
<script setup lang="ts">
const nonce = useNonce() // from nuxt-security
useHead({
script: [{ src: 'https://analytics.example.com/tag.js', nonce, async: true }],
})
</script>
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)
})
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:
/*
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'
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.
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.
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).
_headers or route rules.Cross-Origin-Embedder-Policy: require-corp on a page with a YouTube embed; observe what breaks and why."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."
"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."
Nothing yet. Notes filed here show up on this page and the section overview.
XSS and output encoding
The three kinds of cross-site scripting, exactly what Vue escapes and what it does not, the sinks to watch — v-html, href and src schemes, style bindings, user templates, JSON in script tags, SVG uploads, postMessage — sanitisation with DOMPurify on both sides, Trusted Types, and how CSP limits the blast radius.
Authentication, sessions and CSRF
Server sessions versus stateless sealed cookies versus JWTs and where each breaks, OAuth 2.1 and OIDC with PKCE, passkeys, password hashing and login hardening, CSRF defences from SameSite to origin checks and tokens, and authorisation done on the server for every object.