Skip to content
TutorialSecurity & OWASP

Security headers and CSP in Nuxt with nuxt-security

Missing security headers are a misconfiguration, and the fix is mostly configuration. One module sets sensible ones on every response; the work is tuning them without breaking your app.

vuejs.school team14 Apr 20267 min readnuxt-security 2.5 · Nuxt 4.4

The 2025 edition of the OWASP Top 10 lists Security Misconfiguration second, as A02. For a Nuxt app, a large part of that category is what your server leaves out of its responses: no policy that stops injected scripts from running, no rule against being framed by another site, no instruction to stay on HTTPS. Browsers enforce all of these, but only when the server asks.

The nuxt-security module asks for all three on every response, and it installs with one command. The real work is tuning its Content Security Policy so your own images, APIs and third-party scripts still load, tightening its request limits on routes such as login, and checking with curl that the server sends what you meant. Everything below is for nuxt-security 2.5.1, the current release, on Nuxt 4.4; the defaults come from its configuration docs and source.

Install the module and read its defaults

One command installs the package and adds it to modules in nuxt.config.ts:

npx nuxt module add security

With no configuration at all, the module registers server middleware and route rules that apply to every response. Among other things, it sends:

  • Content-Security-Policy, covered in the next section.
  • Strict-Transport-Security: max-age=15552000; includeSubDomains, which tells browsers to use HTTPS only, for 180 days.
  • X-Frame-Options: SAMEORIGIN and a frame-ancestors 'self' directive, so other sites cannot put your pages in a frame.
  • Referrer-Policy: no-referrer, so other sites never learn which page a visitor came from.
  • X-Content-Type-Options: nosniff, cross-origin isolation headers and a Permissions-Policy that turns off the camera, microphone, geolocation, fullscreen and screen capture.

It also hides the X-Powered-By header, removes console calls and debugger statements from production builds, limits request bodies to 2 MB (8 MB for uploads) and sets a rate limit of 150 requests per IP address every five minutes. The defaults are chosen so that a typical app keeps working, which is why some of them are looser than they could be.

How the default Content Security Policy works

A Content Security Policy (CSP) is a list of rules that says where scripts, styles, images and other resources may come from. If an attacker manages to inject a <script> tag, the browser refuses to run it because it breaks the rules. The module's default script-src directive has five sources: 'self' https: 'unsafe-inline' 'strict-dynamic' 'nonce-{{nonce}}'.

On each server-rendered request, the module generates a random nonce, a one-time token, puts it in place of {{nonce}} and adds the same value to every <script> tag Nuxt renders. A script without that token does not run. 'strict-dynamic' then lets those trusted scripts load others, which is how Nuxt's entry script can pull in route chunks during hydration. Browsers that understand 'strict-dynamic' ignore 'self', https: and 'unsafe-inline'; those three are only fallbacks for old browsers.

The nonce exists only when the server renders the page. With nuxt generate, the module switches to hashes of each inline script and writes the policy into a <meta> tag instead, because there is no server to send a header.

style-src is the one directive where the default 'unsafe-inline' still takes effect. Vue writes style attributes for :style bindings, and Nuxt can insert styles during hydration, which 'strict-dynamic' cannot authorise because it only applies to scripts. Leave styles as they are, and do not add a nonce to style-src: a nonce switches 'unsafe-inline' off.

One more default helps a Vue app without costing anything. script-src-attr 'none' blocks inline handlers such as onclick="...". Vue's @click is attached with addEventListener, so it is not affected, but an onclick hidden in content rendered with v-html is.

Allow the origins you need

The first thing most apps hit is an image from another domain, because the default img-src is 'self' data:. Add the origin in nuxt.config.ts:

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-security'],
  security: {
    headers: {
      contentSecurityPolicy: {
        'img-src': ["'self'", 'data:', 'https://images.example.com'],
        'connect-src': ["'self'", 'https://api.example.com'],
        'frame-ancestors': ["'none'"],
      },
      xFrameOptions: 'DENY',
    },
  },
})

Objects merge with the defaults, so every directive you do not mention keeps its default value. Arrays replace them, which is why 'self' and data: are repeated in img-src; leave them out and your own images stop loading.

connect-src controls where fetch and $fetch may send requests. The default policy does not set it, so listing your API here is a tightening, not a fix.

Scripts are different. With 'strict-dynamic' in place, adding a domain to script-src does nothing, because the browser ignores host lists. Load third-party scripts with useScript() instead: Nuxt's own trusted script inserts them, and 'strict-dynamic' lets them through. useScript() comes from the Nuxt Scripts module (npx nuxt module add @nuxt/scripts); without it, Nuxt 4.4 auto-imports a placeholder that throws an error in the browser.

On a live site, try each new directive in report-only mode before you enforce it, as the last section shows.

Choose the framing, HTTPS and referrer settings

The config above also tightens framing. frame-ancestors is the modern CSP directive, and 'none' means no site, not even yours, may frame the page. X-Frame-Options: DENY says the same to browsers that predate it. Keep the default 'self' and SAMEORIGIN only if your app frames its own pages.

Keep the HSTS (Strict-Transport-Security) default. Browsers ignore the header on plain HTTP, including http://localhost, so it cannot lock you out during development. Add preload: true only when every subdomain serves HTTPS; the docs note that preloading needs a maxAge of at least 31536000 (one year) and includeSubdomains.

no-referrer is the most private referrer setting. If a partner site needs to see that visitors came from you, switch to referrerPolicy: 'strict-origin-when-cross-origin', which sends other sites only your origin, never the full path, and sends nothing when a link leads from HTTPS to HTTP.

Limit request rates and sizes on API routes

The global limits are generous on purpose. Tighten them on the routes that need it with routeRules, in the same nuxt.config.ts:

nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/api/login': {
      security: {
        rateLimiter: { tokensPerInterval: 5, interval: 60_000 },
      },
    },
    '/api/avatar': {
      security: {
        requestSizeLimiter: { maxUploadFileRequestInBytes: 2_000_000 },
      },
    },
  },
})

The login route now accepts five attempts a minute from one IP address and answers the next one with 429 Too Many Requests. It keeps its own count, separate from the global one, and the minute is counted from the first attempt. One exception in nuxt-security 2.5.1: the very first request from an address only creates its record and uses up no attempt, so that address's first minute lets a sixth attempt through. The avatar route rejects uploads over 2 MB with 413 Payload Too Large.

Before you rely on these limits:

  • Storage. The default store is an in-memory cache, so each server instance counts separately. On serverless hosting, set a shared unstorage driver in security.rateLimiter.
  • The client's address. The limiter reads it from the first X-Forwarded-For entry, which any client can set unless your proxy overwrites it. If your host passes the real address in its own header, such as Cloudflare's cf-connecting-ip, set ipHeader to it. If the proxy passes no address at all, every visitor shares one limit.
  • Request size. The check reads Content-Length on POST, PUT and DELETE requests, so a chunked request without that header, or a PATCH, gets through. Check an upload's size in its handler too.

For a complex production app, the module's docs recommend protection against brute forcing at the infrastructure layer instead.

Check the headers with curl and the browser

Configuration describes what you meant. The response shows what happened. Build the app and run the production server, because some headers, such as the cross-origin embedder policy, are relaxed in development:

npx nuxt build
node .output/server/index.mjs
# in a second terminal: a GET request that prints only the response headers
curl -s -D - -o /dev/null http://localhost:3000/

Look for content-security-policy with a fresh 'nonce-...' value on every request, x-frame-options: DENY, and strict-transport-security. Then open the page in the browser, open the developer tools, and select the document request in the Network panel to see the same headers. The Console is where a policy shows its effect: each blocked resource prints a message saying it was refused because it violates a named directive.

Try changes in report-only mode first

A wrong policy breaks a page silently for every visitor. So before you enforce a new rule, ship it as Content-Security-Policy-Report-Only. The browser evaluates that header, logs every violation, and blocks nothing. The module does not build this header for you; set it as a plain route header, as its FAQ shows:

nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/**': {
      headers: {
        'Content-Security-Policy-Report-Only':
          "img-src 'self' data: https://images.example.com; connect-src 'self' https://api.example.com; frame-src https://www.youtube-nocookie.com",
      },
    },
  },
})

Browse every part of the app, watch the Console for [Report Only] messages, and adjust. Change one directive at a time, so each message points to one change. When a full pass produces none, move the directives into security.headers.contentSecurityPolicy, delete the report-only header, and check the enforced policy with curl.

Check it stuck3 questions from the Security misconfiguration, headers and CSP quiz.
Practice now
Was this helpful?

Keep reading