Blog/Web engineering
Locking down a Nuxt app: CSP, headers and rate limits with Nuxt Security
How the Nuxt Security module behaves in practice: safe defaults, strict mode trade-offs, nonce CSP in SSR and SSG, route rules and limits.
Balázs Csorba··11 min read
- Nuxt
- Content Security Policy
- HTTP security headers
- Rate limiting
- Nitro

Key takeaways
- Nuxt Security is a useful default layer for headers and request limits, but its documented feature set does not include authentication, authorization, SSRF protection or a web application firewall.
- The defaults keep a typical app working. Strict mode is a different policy, so fix the breakages in report-only mode before you enforce it.
- Nonce CSP needs server rendering. Static builds use build-time hashes in a meta tag, so test each kind of page separately.
- The built-in rate limiter is an application-layer speed bump. Brute-force protection belongs at the infrastructure layer.
- Roll out in report-only mode, assert the real headers in end-to-end tests, and write exceptions per route rather than loosening the whole site.
Nuxt Security is a module that adds a hardening layer to a Nuxt application. It sets security response headers, including a Content Security Policy (CSP), adds request size and rate limiters, validates request input for cross-site scripting, handles CORS and removes a few framework leaks. As of October 2026 the latest release is 2.6.0, published on 12 May 2026, and the module works with Nuxt 3.x and newer, including 4.x. The defaults are designed to keep a typical app safe without breaking it. That is useful, but it is not the whole story: some protections depend on whether a page is rendered on the server or generated as static files, some break third-party widgets, and the rate limiter is explicitly not meant for complex production applications.
This article treats the module as an operations layer rather than a feature list. It explains what the defaults guarantee and what strict mode changes, how nonce-based CSP differs between server rendering and static generation, how to override rules per route, why the built-in rate limiter is a speed bump rather than protection against denial of service, and when hand-rolled Nitro middleware is the better choice. Every number comes from the module’s documentation or source as checked in October 2026.
What does Nuxt Security cover, and what does it leave out?
Nuxt Security covers the HTTP layer: security response headers including CSP for static sites, request size and rate limiters, XSS validation on GET and POST requests, CORS, hiding the X-Powered-By header, and removing console logger utilities. Allowed HTTP methods, basic auth and CSRF protection are optional. The module README lists this as the feature set and states that it works with Nuxt 3.x and newer, including 4.x.
The gaps matter as much as the list. The documented feature set contains no SSRF protection, no authentication or authorization, and no web application firewall. If your app fetches URLs that users supply, or serves private data behind a login, the module does not do that work for you. Treat the XSS validator as one filter on incoming request data, built on js-xss. Escaping output in your components remains your responsibility.
What do the defaults guarantee, and what does strict mode change?
The defaults give a policy that works with a typical app. Scripts may come from your own origin and from https: sources, styles may use inline CSS, and the script policy names a nonce together with 'strict-dynamic'. Browsers that understand nonces ignore the unsafe-inline fallback when a nonce is present. The other defaults are nonce: true, sri: true, a Cross-Origin-Embedder-Policy of credentialless in production, HSTS with a max-age of 15,552,000 seconds and includeSubdomains, no-referrer as the referrer policy, and SAMEORIGIN for X-Frame-Options. Rate limits, request size limits and CORS are on, while basic auth and CSRF are off.
Strict mode is a different policy, not a stricter set of the same values. strict: true sets default-src to 'none', removes unsafe-inline from scripts, allows styles only with the nonce, limits frames and workers to 'self', switches COEP to require-corp, raises HSTS to one year with preload, changes X-Frame-Options to DENY and denies every Permissions Policy feature by default. The docs describe a gradual path: start with the defaults, fix what breaks, and then migrate to strict.
| Feature | Default | Per route | Caveat you hit first |
|---|---|---|---|
| CSP for SSR | nonce plus strict-dynamic | yes, under security | nonce is ignored in static builds |
| CSP for SSG | build-time hashes in a meta tag | not documented per route | inline code must be hashed |
| Rate limiter | 150 requests per 300,000 ms per IP | yes, under security | LRU state lives in each process |
| Request size | 2,000,000 bytes, 8,000,000 for uploads | yes, under security | 413 on oversized requests |
| XSS validator | on for GET and POST | yes, under security | 400 when unsafe code appears |
| CORS | origin is the server URL | yes, under security | useRegExp treats origins as patterns |
| CSRF | off | exempt with csurf: false | opt-in through nuxt-csurf |
| Subresource integrity | on, hashes at build time | not documented per route | host rewrites break the hashes |
| COEP | credentialless in production | yes, under security | popup sign-in flows need relaxing |
How does nonce CSP work with SSR, SSG and hybrid apps?
With server rendering, the module generates a nonce for each response, puts it into the CSP header, and adds it to the script, link and style elements in the rendered HTML. With static generation that cannot work, because one file serves every visitor. The module computes hashes at build time and delivers the policy in a <meta http-equiv> tag. The documentation is explicit that the nonce option and the 'nonce-{{nonce}}' placeholders are ignored when you build for SSG with nuxi generate.
The rewriting happens inside the render pipeline, which is why application code rarely needs to know about nonces. The module parses inline elements as well as external resources. When your own code needs a nonce, for example for an inline script it creates, useNonce() returns the value from the server context:
<script setup lang="ts">
const nonce = useNonce() // set during SSR; empty on a static build
useHead({
script: [{ children: 'window.dataLayer = window.dataLayer || []', nonce }],
})
</script>A static build has no request, so the value is empty there, and inline code that must run on a prerendered page needs a hash instead. The hybrid case causes most of the confusion. A hybrid app mixes server-rendered and prerendered routes, so the two kinds of response can carry different policies, and a header check on one path says nothing about the other. Check both kinds of page.
Three breakages recur. Third-party code that creates script tags at runtime tends to produce elements the policy does not know about. The module’s FAQ says that the inline error-handling scripts of <NuxtImg> and <NuxtPicture> conflict with a nonce-only policy in SSR. And hosting providers that post-process your bundle after the build invalidate the subresource integrity hashes, which is covered in the next sections.
How do you harden individual routes?
Override the module per path under routeRules, using the security field. Every middleware and header can be set there, so an expensive API path can have a tighter rate limit than your marketing pages without a separate server.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-security'],
security: {
strict: true,
contentSecurityPolicyReportOnly: true,
rateLimiter: { tokensPerInterval: 150, interval: 300000 },
},
routeRules: {
// illustrative values: a tighter limit for one expensive endpoint
'/api/generate/**': { security: { rateLimiter: { tokensPerInterval: 30, interval: 60000 } } },
},
})Two details trip people up. First, security headers belong only in the security field. The docs advise against setting them through native routeRules.headers, and when both define the same header, the security field wins. Second, some values should not live in the repository at all. The nuxt-security:routeRules Nitro hook runs when the server restarts and can change the whole rule set, which suits a list of allowed connect-src origins fetched from a secret manager. Its build-time sibling, nuxt-security:prerenderedHeaders, covers static hosting.
This is where an app with LLM features grows its policy. Streaming responses need their origin in connect-src, and each vendor widget needs an entry in script-src. The per-route security field keeps each exception next to the route that needs it, instead of loosening the whole site. The same streaming setup is described in the Nuxt LLM streaming article, and it is exactly the kind of endpoint where a broad exception tends to creep in.
What do the rate and size limits actually protect?
They slow down one client that sends too many requests, or oversized bodies, to one running instance. The default allows 150 requests per IP within a 300,000 ms (five-minute) window, tracked in an LRU cache, and answers over-limit requests with 429 Too Many Requests. The request size limiter enforces 2,000,000 bytes on GET, POST and DELETE requests and answers larger ones with 413 Payload Too Large; upload routes get 8,000,000 bytes.
The module’s documentation states the limit of this approach. The built-in rate limiter “is not suitable for complex production applications”, it is meant to help simpler applications with brute forcing, and for production you “should use solutions that work on the infrastructure layer, not the application layer”. I read that as the working rule: the built-in limiter is a speed bump, not denial-of-service protection. A CDN, a load balancer or a web application firewall sees the traffic before the Node process does, and can refuse it at a cost your application never pays. This limiter is the thin application-layer part of the defense that the denial-of-wallet article describes for one expensive endpoint.
Two practical points follow. The LRU default lives inside one process, so with several instances, or serverless functions that do not share memory, each instance counts separately and the effective limit multiplies. That is my reading of the design, not a quoted statement, and a two-instance test will confirm it. The unstorage driver can move the counters to shared storage, such as the vercelKv driver the docs show. Second, behind a proxy the client address is the proxy address unless you set ipHeader, for example to cf-connecting-ip. Set it only when the app can be reached solely through that proxy, because otherwise a client can send the header itself.
How do CORS and CSRF behave for API routes?
CORS is enabled globally, and by default it allows your own server URL as the origin. CSRF protection is off until you turn it on, and it comes from an integration with nuxt-csurf. The CORS handler is built on H3’s built-in CORS functionality. You can set methods, allowHeaders, exposeHeaders, credentials and maxAge, and the preflight status defaults to 204. With useRegExp enabled, each origin string is compiled into a case-insensitive regular expression. That is convenient for preview deployments and dangerous when a pattern is too loose, so keep the list explicit wherever you can. The CORS handler docs show the full option list.
CSRF is opt-in. Set csrf: true, exempt individual routes with the separate csurf: false route rule, and on the client use the auto-imported useCsrfFetch and useCsrf() composables. The docs also show sending the token in a csrf-token header for a tRPC client. Basic auth is opt-in too. The module moves its credentials into runtimeConfig.private.basicAuth, which keeps them out of the client bundle. That is the right place for them, but it is still one shared password, so I would not use it as the login for a public product.
How do you roll out and test the header layer?
Roll out a policy in report-only mode, read the reports, and then enforce it. Set contentSecurityPolicyReportOnly: true and the response carries Content-Security-Policy-Report-Only instead of the enforcing header. The CSP options accept report-uri and report-to, both globally and per route. Report-only support arrived in release 2.6.0, which also changed how CSP hashes are computed, from the final HTML in the render hook. Read those release notes before you upgrade a static site.
For a one-off grade of a deployed site, MDN’s HTTP Observatory checks the headers quickly. It grades what it sees but cannot tell you whether your pages still work, so it complements browser testing rather than replacing it. The check that catches regressions is an assertion in an end-to-end test. Playwright’s Response class exposes the response headers, so a test can fail when a deploy drops the policy:
import { test, expect } from '@playwright/test'
test('the home page sends the security headers', async ({ page }) => {
const response = await page.goto('/')
const headers = response!.headers()
expect(headers['content-security-policy'] ?? headers['content-security-policy-report-only']).toContain("script-src 'self'")
expect(headers['strict-transport-security']).toContain('max-age=')
})Run the check against one server-rendered page and one prerendered page, because they produce different policies. The same Playwright setup I already use through Playwright MCP can run it on every deploy, so a header regression shows up in the same place as a broken page. The same habit of measuring before deciding is what I apply in the Core Web Vitals article, only here the metric is a response header.
When is hand-rolled Nitro middleware the better choice?
Use the module when you want nonce rewriting, hashing and header sets maintained for you. Hand-roll server/middleware when you need exact control over every response and no convention to work around. The module gives you things that are tedious to build well. Nonce insertion happens inside the render pipeline, so it reaches elements the framework emits. Hashes for static builds come from the final HTML. Header sets differ by resource type, with a full policy for HTML and a smaller set for other assets, and per-route rules merge with your global configuration. Those are the parts I would not rewrite.
Hand-rolled middleware wins elsewhere. A few headers and a small allowlist in server/middleware are easy to read, need no module conventions and add no dependency. If you only need HSTS, a referrer policy and frame options, write those three headers yourself.
Re-implementing nonce CSP by hand is the worst starting point. The nonce has to reach every script, link and style the render emits, the same value must match the header, and static builds need a different mechanism altogether. Mistakes do not show up in a unit test. They show up as a blank page in one browser. My recommendation is to take the module for the policy and write any exception yourself.
What does a production checklist look like?
Before enforcing a policy on a production app, I would work through these items in order:
- Run the defaults on every page type and fix what the browser console reports as a violation.
- Enable
contentSecurityPolicyReportOnlywith areport-uriorreport-toendpoint, and read the reports for a full release cycle. - Confirm which routes are server-rendered and which are prerendered, and check the built HTML for the nonce or the hashed meta tag.
- Load third-party scripts with
useScript(Nuxt 3.11 or newer), not by injecting tags by hand. - List external image hosts in
img-srcand every API origin inconnect-src, including LLM endpoints. - Check that your host does not rewrite bundles after the build, or the integrity hashes will fail.
- Put brute-force and volume protection at the edge, and treat the built-in limiter as a second layer.
- Assert the headers in an end-to-end test on one server-rendered and one prerendered page.
The module is a good default layer, and the work that remains is the same as before: knowing which scripts your pages load and which origins your app calls. If you want to see the kind of Nuxt work behind this article, my Vue and Nuxt projects are a good starting point.
Sources
- Nuxt Security README (main branch)
- Nuxt Security default configuration (src/defaultConfig.ts)
- Nuxt Security docs: Content Security Policy
- Nuxt Security docs: rate limiter
- Nuxt Security docs: CORS handler
- Nuxt Security docs: CSRF
- Nuxt Security docs: hooks
- Nuxt Security docs: security headers
- Nuxt Security docs: improving security
- Nuxt Security release v2.6.0 (12 May 2026)
- MDN: HTTP Observatory
- Playwright: Response class
Frequently asked questions
Does Nuxt Security protect against SSRF?
Its documented feature list does not include SSRF protection, and the module does not add authentication or authorization either. Validate outbound URLs in your own code, and re-check the release notes before you rely on a claim about a specific version.
Is the default Nuxt Security CSP enough for production?
The defaults are designed not to break a typical app, but they are a starting point. They keep unsafe-inline as a fallback and allow https: sources for scripts. Read the policy in your built output, fix what breaks, and then move to strict mode or a tighter custom policy.
Why does nonce CSP not work on a static site?
A nonce is generated per response, and a static file is the same for every visitor. Nuxt Security ignores the nonce option during nuxi generate and uses build-time hashes delivered in a meta tag instead.
Does the rate limiter stop denial-of-service attacks?
No. The documentation says it is meant for simpler applications and recommends infrastructure-layer solutions for brute-force protection. Put a CDN, load balancer or web application firewall in front for traffic volume, and keep the application limiter as a second layer.
How do I check that the headers survive a deploy?
Add an end-to-end test that reads response headers on one server-rendered page and one prerendered page, and fails when the Content-Security-Policy or Strict-Transport-Security header is missing.