Security misconfiguration, headers and CSP
Send security headers with nuxt-security, build a Content Security Policy with a nonce, roll it out in report-only mode first, and know what changes on a static site.
What you'll learn
When you finish, you can do these.- 1Add security headers to a Nuxt app with nuxt-security
- 2Build a Content Security Policy with a nonce
- 3Roll a new policy out in report-only mode first
- 4Stop other sites from framing a static Nuxt site
The Corner Shop, the small online shop this track keeps safe, is a Nuxt app: Vue with its own server. Its code can be perfect and the site still unsafe because of a missing header (an instruction the server sends the browser with each page) or a debug route left on. The OWASP Top 10, the list most teams use to name web risks, calls this Security Misconfiguration and ranks it second.
You need one idea from Injection and cross-site scripting: if a page shows a customer's review as HTML, code hidden in it runs for every visitor. Sanitising, keeping only harmless tags, stops most of it; here you add the net behind it. In the Code tab, the shop's settings file, nuxt.config.ts, turns on safe headers and a Content Security Policy, a header that says which scripts may run.
Headers from one module
The nuxt-security module (2.6.0), an add-on for Nuxt, sends safe headers. npx nuxt module add security, run in the project's folder, installs it and adds it to the modules list in nuxt.config.ts. With no options it adds these headers to every response:
Strict-Transport-Security(HSTS): visit this host over HTTPS only, for the next 180 days (the shop's config makes it a year).X-Frame-Options: SAMEORIGIN: only your own site may show your pages in a frame, a window inside another page. This stops clickjacking, where an invisible frame tricks a visitor into clicking your buttons.X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer, and aPermissions-Policythat turns off the camera, microphone and location.- A default
Content-Security-Policy, which you tighten in the next section.
In a terminal, curl -I followed by a site's address prints the headers it sends.
A Content Security Policy with a nonce
Here you tighten the default policy, so that most code hidden in a review cannot run. A Content Security Policy (CSP) is a header whose rules, called directives, list where the page may load scripts, styles, images and connections from. Anything else, the browser refuses.
The hard part is inline scripts, code written inside the page's HTML. Injected code is inline, so a strict policy refuses every inline script. Yet Nuxt writes its own into every page, such as window.__NUXT__={}..., to start the app. The answer is a nonce: a random value, new for every response.
- Servera new nonce in the CSP
- Nuxt's scriptscarry the same nonce
- Injected handlerrefused
- Report-Onlyfirst, for a week
nuxt-security puts the nonce in the header as 'nonce-...' and on Nuxt's own <script> tags, so the browser runs those. These are the policy's script, style and frame lines in the shop's nuxt.config.ts:
contentSecurityPolicy: {
'script-src': ["'self'", "'strict-dynamic'", "'nonce-{{nonce}}'"], // a new nonce per response
'script-src-attr': ["'none'"], // no onclick="" or onerror="" attributes
'style-src': ["'self'", "'unsafe-inline'"], // :style bindings render as style="..."
'frame-ancestors': ["'none'"], // nobody may show the shop inside a frame
},
'strict-dynamic' lets a trusted script load the other JavaScript files it imports. frame-ancestors 'none' keeps every site, the shop's own included, from showing the shop in a frame.
script-src-attr 'none' forbids inline handlers, JavaScript in an attribute such as onerror="...". When a review with a hidden onerror slips past sanitising, Chrome refuses it: Executing inline event handler violates the following Content Security Policy directive 'script-src-attr 'none''. The nonce rule in script-src also refuses a javascript: link, whose address is code.
Know the limit. nuxt-security adds the nonce to every <script> tag in the server-rendered HTML, including one that v-html (Vue's way to insert raw HTML) wrote there unsanitised, and that script runs. The CSP is the net under sanitising, not a replacement for it.
The style-src line keeps 'unsafe-inline', which allows inline styles, because server rendering writes each :style binding as a style="..." attribute, and a nonce cannot sit on an attribute. Without it, each one fails with Applying inline style violates the following Content Security Policy directive.
Roll it out, then check static builds
A strict policy can break a page you forgot about, so ship it in two steps. First send it as Content-Security-Policy-Report-Only: the option contentSecurityPolicyReportOnly: true in nuxt.config.ts switches the header's name. The browser blocks nothing and logs each violation, ending in The policy is report-only, so the violation has been logged but no further action has been taken.
The policy's report-uri directive also makes the browser post each report to /api/csp-report, where the Code tab's server/api/csp-report.post.ts logs the page, the directive and what was blocked. After a week of reports you understand, delete the option line, and the policy blocks.
nuxt generate builds every page ahead of time as static files. With no server per request there is no nonce, so the module writes the policy into a <meta http-equiv="Content-Security-Policy"> tag, with a hash (a fingerprint of the code) of each script. It leaves out frame-ancestors, which browsers ignore there, and the other headers need a server. So framing protection and HSTS come from the host: Netlify and Cloudflare Pages send the headers listed in a plain file, public/_headers, also in the Code tab.
Before each release, also check:
- Nuxt DevTools (Nuxt's panel for development) off, and no test or debug routes in
server/api. - Error responses without a stack trace or a query.
- No secrets in
runtimeConfig.public, which reaches the browser. .env(your secrets) and.git(your code's history) not served.
What goes wrong
A policy without the nonce, written to be strict.
contentSecurityPolicy: {
'script-src': ["'self'"],
},
The page renders, then stops responding: buttons do nothing. The console shows Executing inline script violates the following Content Security Policy directive 'script-src 'self''. twice, for Nuxt's own startup scripts, and the app never hydrates: Vue never takes over the HTML the server sent. Keep the nonce and 'strict-dynamic':
contentSecurityPolicy: {
'script-src': ["'self'", "'strict-dynamic'", "'nonce-{{nonce}}'"],
},
Nuxt's startup scripts now carry the nonce, and the app hydrates.
On a static build, framing protection written as a meta tag.
app: {
head: {
meta: [{ 'http-equiv': 'Content-Security-Policy', content: "frame-ancestors 'none'" }],
},
},
Another site can still frame the shop. With nuxt-security, nuxt generate replaces your tag with its own meta policy, which leaves frame-ancestors out. Without the module the tag survives, and Chrome logs The Content Security Policy directive 'frame-ancestors' is ignored when delivered via a <meta> element. Either way only a real header works, so send it from the host:
/*
Content-Security-Policy: frame-ancestors 'none'As a real header it works: the other site's frame stays empty.
What to remember
npx nuxt module add securitygives sensible headers at once; read what it sends withcurl -I.- A CSP with a per-response nonce and
'strict-dynamic'lets Nuxt's scripts run and refuses inline handlers andjavascript:links. - The nonce lands on every script in the server-rendered HTML, injected ones included. Sanitising stays the first defence.
- Ship
Content-Security-Policy-Report-Onlyfirst and read the reports before you enforce. nuxt generateuses a meta tag with hashes;frame-ancestorsand HSTS must come from the host as real headers.
In your project
Do these in a real project. Each one says how to confirm it worked.- Deploy with
contentSecurityPolicyReportOnly: truefor a week. Each lineserver/api/csp-report.post.tslogs names adirective, such asscript-src-elemfor a blocked script. Allow or remove each source, then delete the option:curl -sInow showscontent-security-policyinstead ofcontent-security-policy-report-only. - Run
curl -sI https://your-site.exampletwice. Both answers showstrict-transport-security: max-age=31536000; includeSubDomainsand acontent-security-policywhosenonce-value differs between the two. - Save
<iframe src="https://your-site.example"></iframe>as a file and open it in Chrome. The frame stays empty and the console saysFraming 'https://your-site.example/' violates the following Content Security Policy directive: "frame-ancestors 'none'". The request has been blocked.