Security headers: what each HTTP header does and how to set it

Security headers are HTTP response headers that tell the browser what your page may do. Six matter today: Content-Security-Policy (which scripts run), Strict-Transport-Security (HTTPS only), frame-ancestors or X-Frame-Options (no framing by other sites), X-Content-Type-Options, Referrer-Policy and Permissions-Policy. They limit damage in the browser. They do not protect your database or your keys.

The team behind Keybrum's attacksUpdated 14 min read

In short

  • Send six headers: Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options: nosniff, frame-ancestors (with X-Frame-Options as a backup), Referrer-Policy and Permissions-Policy.
  • Drop X-XSS-Protection, Expect-CT, Public-Key-Pins and Feature-Policy: they are obsolete or renamed, and MDN warns that X-XSS-Protection can create XSS holes in otherwise safe sites.
  • Start the Content-Security-Policy in Report-Only, with the Supabase, Stripe and analytics hosts listed, and enforce it once real use brings no surprise reports.
  • Wait before adding HSTS preload: every subdomain must serve HTTPS first, and removal from the list takes months to reach Chrome users.
  • Headers act in the browser only. A stranger who calls your Supabase API or your AI endpoint with curl never reads them.
On this page

What are security headers?

Security headers are HTTP response headers that your server or host sends and the visitor's browser enforces. They decide which scripts may run, whether the browser uses HTTPS only, whether another site may frame your page, and how much of the address goes to other sites. Without them, the browser's default for scripts and framing is to allow everything.

App builders leave this to you. Next.js sends X-Powered-By out of the box and leaves the protective headers to your config, and the hosting built into Lovable and Bolt documents no setting for response headers. The short list that matters today:

HeaderWhat it stopsValue to start with
Content-Security-Policy (CSP)Injected scripts running in your page (cross-site scripting, or XSS), and code or data going to hosts you never listedA policy built for your app, sent as Report-Only first (see below)
Strict-Transport-Security (HSTS)Plain HTTP visits, where someone on the same network can read or change the trafficmax-age=63072000; includeSubDomains (two years)
X-Content-Type-OptionsThe browser guessing a file's type and running an uploaded file or a text response as a scriptnosniff
frame-ancestors (a CSP directive) or X-Frame-OptionsAnother site loading your page in an invisible frame to trick your users into clicking (clickjacking)frame-ancestors 'none', plus X-Frame-Options: DENY
Referrer-PolicyFull addresses, with any token in the query string, sent to other sites. OWASP advises setting it rather than trusting browser defaultsstrict-origin-when-cross-origin
Permissions-PolicyInjected code or an embedded frame asking for the camera, the microphone or the locationcamera=(), microphone=(), geolocation=()
The nosniff, Referrer-Policy, Permissions-Policy and HSTS values follow the OWASP HTTP Headers Cheat Sheet, except that HSTS leaves out preload until you have read the trap further down. Keep includeSubDomains only if every subdomain serves HTTPS.

OWASP notes that frame-ancestors obsoletes X-Frame-Options in browsers that support it; send both, with matching values, for older browsers. Also remove X-Powered-By and version numbers in Server, as OWASP recommends, and set Secure, HttpOnly and SameSite on session cookies in the code that creates them. One small extra, Cross-Origin-Opener-Policy: same-origin-allow-popups, stops windows on other sites from keeping a handle on yours while your own pop-ups keep working; the checker gives it 2 points of 100.

Which security headers are obsolete?

Old checklists and some scanners still ask for headers that browsers have dropped or replaced. Remove them:

HeaderStatusWhat to do
X-XSS-ProtectionNon-standard. MDN warns that it can create XSS vulnerabilities in otherwise safe websitesDo not set it, or turn it off with 0, as OWASP advises. A CSP that blocks inline scripts replaces it
Expect-CTDeprecated. Only Chromium browsers implemented it, and Chromium deprecated it in version 107 because it now enforces Certificate Transparency by defaultRemove it
Public-Key-Pins (HPKP)Unsupported by all modern browsers, according to OWASPRemove it. OWASP points to Certificate Transparency and DNS CAA records instead
Feature-PolicyRenamed Permissions-PolicySend Permissions-Policy instead

A scanner that flags a missing X-XSS-Protection runs on old rules. The security headers checker does not grade it.

How to test security headers on your own app

  1. 1

    Run the free checker

    Paste your address into the security headers checker. It grades the headers from A+ to F, writes the line to add for your host, and also checks the HTTPS redirect, session cookie flags and CORS.

  2. 2

    Read the raw headers with curl

    curl -I is the quick version, but it sends a HEAD request. The commands below send a GET, like a browser, and print only the status and headers.

  3. 3

    Look in the browser

    In DevTools, open the Network tab, reload, click the first request and read Response Headers: what your browser got after every redirect and CDN.

  4. 4

    Check more than the home page

    API routes, error pages and signed-in pages can come from another part of the stack, with other headers. Test one of each.

bash
# Your own app only. Replace the address.
curl -s -D - -o /dev/null https://your-app.com
curl -s -D - -o /dev/null https://your-app.com/api/health
curl -s -D - -o /dev/null https://your-app.com/this-page-does-not-exist
curl -s -D - -o /dev/null http://your-app.com   # expect a 301 or 308 to https://
Look for the six names from the table above, and for X-Powered-By or a version number in Server that you can remove.

How to set security headers on each host

Set headers in one place, as close to the edge as you can. Each block sends the five fixed headers; the static-host blocks add a first CSP for framing, plugins and base URLs only, which rarely breaks anything. The script policy comes in the next section.

Next.js: headers() in next.config

Fixed headers go in headers() in next.config.ts (or next.config.js). They apply to every path that matches source, and /:path* matches your /api routes too. The Next.js security guide covers the rest of a Next.js app.

ts
// next.config.ts
import type { NextConfig } from 'next'

const securityHeaders = [
  { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains' },
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'X-Frame-Options', value: 'DENY' },
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
]

const nextConfig: NextConfig = {
  poweredByHeader: false,
  async headers() {
    return [{ source: '/:path*', headers: securityHeaders }]
  },
}

export default nextConfig
The CSP is left out on purpose: with a nonce, proxy sets it per request. If you do not use a nonce, add a Content-Security-Policy entry to this list.

Next.js: the CSP nonce in proxy.ts or middleware.ts

A nonce is a random value, new on every request, that marks your own scripts as allowed. Your proxy puts it in the Content-Security-Policy request header, and Next.js adds it to its framework scripts and bundles. Next.js 16 renamed middleware to proxy (proxy.ts, function proxy) and deprecated the old name; on 15 and earlier, use middleware.ts and a function named middleware.

The cost: pages that use a nonce render on each request. The Next.js docs say static optimization and ISR are disabled, CDN caching needs extra configuration, and Partial Prerendering does not work. The alternatives are the docs' experimental Subresource Integrity mode for the App Router, which keeps pages static, or a weaker CSP with 'unsafe-inline' in script-src.

Vercel without Next.js: vercel.json

For a Vite app or any static site on Vercel, the same pairs go in the headers array of vercel.json at the root of the project. /(.*) matches every path.

json
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains" },
        { "key": "X-Content-Type-Options", "value": "nosniff" },
        { "key": "X-Frame-Options", "value": "DENY" },
        { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
        { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" },
        { "key": "Content-Security-Policy", "value": "frame-ancestors 'none'; base-uri 'self'; object-src 'none'" }
      ]
    }
  ]
}
vercel.json

Netlify and Cloudflare Pages: _headers

Netlify reads a plain _headers file from the publish directory, and Cloudflare Pages from the build output: in a Vite app, put it in public/ and the build copies it. On Netlify, a [[headers]] table in netlify.toml does the same job; pick one.

text
/*
  Strict-Transport-Security: max-age=63072000; includeSubDomains
  X-Content-Type-Options: nosniff
  X-Frame-Options: DENY
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=()
  Content-Security-Policy: frame-ancestors 'none'; base-uri 'self'; object-src 'none'
public/_headers
toml
[[headers]]
  for = "/*"
  [headers.values]
    Strict-Transport-Security = "max-age=63072000; includeSubDomains"
    X-Content-Type-Options = "nosniff"
    X-Frame-Options = "DENY"
    Referrer-Policy = "strict-origin-when-cross-origin"
    Permissions-Policy = "camera=(), microphone=(), geolocation=()"
    Content-Security-Policy = "frame-ancestors 'none'; base-uri 'self'; object-src 'none'"
netlify.toml

Both hosts' docs set a limit: these headers apply to static files only, not to responses from functions, edge functions or server-rendered pages, which must set their own.

Cloudflare: response header transform rules

When your domain runs through Cloudflare's proxy (the orange cloud in the DNS settings), Cloudflare can add headers to every response, whatever sits behind it: the fix for hosts with no header setting.

  1. 1

    Open the rules

    In the dashboard, open your domain, go to Rules > Overview and select Create rule > Response Header Transform Rule.

  2. 2

    Match your app

    Match your app's hostname, such as app.example.com, or all incoming requests.

  3. 3

    Add one Set static operation per header

    Set static writes your value and replaces any header of the same name. Add the six lines of the _headers file above.

  4. 4

    Deploy, then test

    Run the curl commands again. These rules also apply to Cloudflare's own error pages.

Cloudflare does not let you change a few headers, such as server, or any header whose name starts with cf- or x-cf-.

Lovable and Bolt hosting

Neither Lovable's nor Bolt's hosting docs describe a setting for response headers. Run the curl commands on your published app to see what it already sends, then cover the gaps:

  • Lovable: its custom domain docs support running behind your own proxy, such as Cloudflare. Connect the domain with Advanced > Uses Cloudflare or similar proxy enabled, then add the Cloudflare rule above. Custom domains need a paid plan, so a lovable.app address cannot do this.
  • Bolt: a project that runs entirely in the browser can publish to Netlify through Bolt's integration and use the _headers file above. Bolt says projects that need a backend or its built-in database should stay on Bolt hosting. For those, the remaining route is a proxy you control in front of the custom domain, which Bolt does not document, so test it on a spare domain first.
  • Either one, as a stopgap: a CSP meta tag in index.html restricts scripts and connections without touching the host, but it cannot carry frame-ancestors or report-only mode, so test every flow before publishing.

A Content-Security-Policy for Supabase, Stripe and analytics

A CSP lists directives, each naming the allowed sources for one kind of resource: script-src for scripts, connect-src for fetch calls and WebSockets, frame-src for iframes, img-src for images. Start from default-src 'self' (your own origin only) and add each service from its own docs:

ServiceDirectiveSources to add
Supabase (database API, auth, storage)connect-src, img-srchttps://YOUR_PROJECT_REF.supabase.co
Supabase Realtimeconnect-srcwss://YOUR_PROJECT_REF.supabase.co
Stripe.js and Elementsscript-srchttps://js.stripe.com, https://*.js.stripe.com, https://maps.googleapis.com
Stripe.js and Elementsframe-srchttps://js.stripe.com, https://*.js.stripe.com, https://hooks.stripe.com (needed for 3D Secure and other redirects)
Stripe.js and Elementsconnect-srchttps://api.stripe.com, https://maps.googleapis.com
Google Analytics 4, without Ads featuresscript-srchttps://www.googletagmanager.com
Google Analytics 4, without Ads featuresimg-src, connect-srchttps://www.googletagmanager.com, https://*.google-analytics.com, plus https://*.google.com in connect-src
Stripe's hosts come from its integration security guide (maps.googleapis.com is for the Address Element), Google's from its tag platform CSP guide, and the Realtime address from the Supabase protocol docs. Stripe Checkout, Link and Connect embedded components need more hosts: see Stripe's guide.

Next.js, with a nonce

ts
// proxy.ts on Next.js 16. On Next.js 15 or earlier: middleware.ts, with the function named middleware.
import { NextRequest, NextResponse } from 'next/server'

const SUPABASE = 'YOUR_PROJECT_REF.supabase.co'

export function proxy(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
  const isDev = process.env.NODE_ENV === 'development'
  const csp = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://js.stripe.com https://*.js.stripe.com https://maps.googleapis.com https://www.googletagmanager.com${isDev ? " 'unsafe-eval'" : ''};
    style-src 'self' ${isDev ? "'unsafe-inline'" : `'nonce-${nonce}'`};
    img-src 'self' blob: data: https://${SUPABASE} https://www.googletagmanager.com https://*.google-analytics.com;
    font-src 'self';
    connect-src 'self' https://${SUPABASE} wss://${SUPABASE} https://api.stripe.com https://maps.googleapis.com https://www.googletagmanager.com https://*.google-analytics.com https://*.google.com;
    frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com;
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
  `.replace(/\s{2,}/g, ' ').trim()

  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-nonce', nonce)
  // Next.js reads the nonce from this request header (it also accepts a
  // Content-Security-Policy-Report-Only request header) and adds it to its own scripts.
  requestHeaders.set('Content-Security-Policy', csp)

  const response = NextResponse.next({ request: { headers: requestHeaders } })
  // Report-Only first. Once the reports are clean, rename this response header to
  // Content-Security-Policy. The request header above keeps its name either way.
  response.headers.set('Content-Security-Policy-Report-Only', csp)
  return response
}

export const config = {
  matcher: [
    {
      source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
      missing: [
        { type: 'header', key: 'next-router-prefetch' },
        { type: 'header', key: 'purpose', value: 'prefetch' },
      ],
    },
  ],
}
Adapted from the Next.js CSP guide. Read the nonce in a Server Component with (await headers()).get('x-nonce') and pass it to every Script you add, such as the analytics tag.

With 'strict-dynamic', current browsers ignore host names in script-src, MDN explains, and trust only scripts that carry the nonce or that such a script loads; the hosts are a fallback for older browsers. Pass the nonce to every Script tag you write. As in the Next.js docs, 'unsafe-eval' and inline styles are allowed only in development, and the matcher skips /api, static files and prefetches.

Static hosts, without a nonce

Vercel, Netlify and Cloudflare Pages serve a Vite build as plain files, so nothing creates a nonce per request. Scripts from files on your domain are covered by 'self'. Check the page source of your published app: each inline script, such as the configuration snippet of Google's tag, must move into a file in your bundle or be allowed by its SHA-256 hash.

text
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' https://js.stripe.com https://*.js.stripe.com https://maps.googleapis.com https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://YOUR_PROJECT_REF.supabase.co https://www.googletagmanager.com https://*.google-analytics.com; connect-src 'self' https://YOUR_PROJECT_REF.supabase.co wss://YOUR_PROJECT_REF.supabase.co https://api.stripe.com https://maps.googleapis.com https://www.googletagmanager.com https://*.google-analytics.com https://*.google.com; frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests
One line, added next to the other headers in vercel.json, _headers or the Cloudflare rule. 'unsafe-inline' stays in style-src because a static host cannot hand out nonces and many UI libraries add inline styles; drop it only after a Report-Only test without it comes back clean.

Roll it out in Report-Only

  1. 1

    Send it as Report-Only

    Use the header name Content-Security-Policy-Report-Only: the browser blocks nothing and reports what it would block, so the checker scores it as blocking nothing yet. upgrade-insecure-requests has no effect in this mode (W3C spec), and X-Frame-Options: DENY keeps framing blocked meanwhile.

  2. 2

    Use the app for real

    Sign up, sign in, upload a file, pay in Stripe test mode, open a realtime screen. Each violation shows in the console with the directive and the blocked address. To collect reports from visitors, add a report-to endpoint, which MDN prefers, with report-uri as a fallback.

  3. 3

    Fix the policy, not the app

    Add each missing source to the right directive. If you do not recognise an address, find out what loaded it before you allow it.

  4. 4

    Enforce

    When a few days of use bring no unexpected report, rename the header to Content-Security-Policy. MDN says browsers honor both headers at once, enforcing one and reporting on the other, so a stricter Report-Only policy can test your next change.

The traps after the obvious fix

Meta tags do less than headers

MDN lists frame-ancestors as unsupported in a CSP meta tag and says a report-only policy cannot be delivered that way; X-Frame-Options in a meta tag has no effect at all. Use real headers wherever the host allows.

HSTS preload is hard to undo

'unsafe-inline' in script-src

MDN says 'unsafe-inline' defeats much of the purpose of a CSP: an injected inline script runs just like yours. Next.js without a nonce needs it for its own inline scripts. Browsers ignore 'unsafe-inline' in a directive that holds a nonce or a hash, so the nonce closes the hole. The checker counts 'unsafe-inline' in script-src as weak.

API routes, functions and error pages

The proxy matcher skips /api, which is fine for JSON, so keep HSTS and nosniff in next.config, which covers every path. _headers files skip functions and server-rendered pages, and your Supabase project is a separate origin whose headers you do not control. Run the curl lines on an API route and a 404 page.

What security headers do not fix

Headers are instructions to a browser. A stranger who calls your API with curl never reads them. They limit what an injected script or a hostile page can do to your visitors, and do nothing about the leaks we find most often in generated apps:

  • A table with row level security off. Anyone holding your public Supabase key reads it through the REST API, whatever your CSP says. CVE-2025-48757 was assigned after a researcher found 170 of 1,645 Lovable apps tested with missing or weak RLS. See the Supabase RLS guide.
  • A secret key in your JavaScript. A CSP controls what your page loads, not what it ships. See API keys exposed in the frontend.
  • A `.env` file served to anyone. See the guide to exposed .env files.
  • An AI endpoint anyone can call. See unprotected AI endpoints.
  • Another user's records behind a guessable ID. That is an IDOR, and it lives in your code.

Keybrum rates missing headers for what they are: its header check reports a missing HSTS as medium and three or more other missing headers as low, never as a way past sign-in. Another attack checks whether other sites can frame your admin, account and checkout pages. Both run alongside more than 100 attacks on the data itself. For the rest of a pre-launch audit, see the vibe coding security checklist.

Questions

What are security headers?

HTTP response headers that tell the browser how to treat your page: which scripts may run, whether to use HTTPS only, whether other sites may frame it, and how much of the address to share. Your server or host sends them and the visitor's browser enforces them. They change nothing on your server.

How do I test security headers?

Paste your address into the security headers checker for a graded list with the fix for your host. From a terminal, curl -s -D - -o /dev/null https://your-app.com prints the response headers, and the Network tab of your browser's DevTools shows them for any page. Test an API route and a 404 page too.

Are missing security headers a vulnerability?

On their own they are a hardening gap, not a way in: nobody reads your data because Referrer-Policy is missing. They matter when something else goes wrong, such as an injected script or a page framed by another site. Fix a missing Content-Security-Policy or HSTS first.

How do I add security headers in Next.js?

Put the fixed ones in headers() in next.config.ts and set poweredByHeader: false. For a Content-Security-Policy with a nonce, generate it in proxy.ts on Next.js 16, or in middleware.ts on 15 and earlier, as the Next.js CSP guide shows; pages that use the nonce must render on each request. More in the Next.js security guide.

Should I still set X-XSS-Protection?

No. It is non-standard, MDN warns that it can create XSS vulnerabilities in otherwise safe sites, and OWASP advises not setting it or turning it off with 0. A Content-Security-Policy that blocks inline scripts replaces it.

Can I set security headers in a meta tag?

Only part of a Content-Security-Policy: frame-ancestors and report-only mode do not work in a meta tag, according to MDN. X-Frame-Options in a meta tag has no effect. Set the others as HTTP headers from your server, host or CDN.

Can I add security headers to a Lovable or Bolt app?

Their hosting docs describe no header setting. On a Lovable custom domain, you can put Cloudflare in front with the proxy option Lovable documents and add the headers with a response header transform rule. A Bolt project that runs only in the browser can publish to Netlify and use a _headers file. See the Lovable security guide.

Sources

  1. 1.Content Security Policy (CSP), MDN Web Docs
  2. 2.CSP: frame-ancestors, MDN Web Docs
  3. 3.CSP: script-src ('strict-dynamic'), MDN Web Docs
  4. 4.X-Frame-Options, MDN Web Docs
  5. 5.X-XSS-Protection, MDN Web Docs
  6. 6.Expect-CT, MDN Web Docs
  7. 7.Cross-Origin-Opener-Policy, MDN Web Docs
  8. 8.Upgrade Insecure Requests, W3C
  9. 9.HTTP Headers Cheat Sheet, OWASP Cheat Sheet Series
  10. 10.HSTS Preload List Submission, hstspreload.org
  11. 11.How to set a Content Security Policy (CSP) for your Next.js application, Next.js Docs
  12. 12.proxy.js file convention, Next.js Docs
  13. 13.Project configuration (vercel.json), Vercel Docs
  14. 14.Custom headers, Netlify Docs
  15. 15.Headers (Cloudflare Pages), Cloudflare Docs
  16. 16.Response Header Transform Rules, Cloudflare Docs
  17. 17.Create a response header transform rule in the dashboard, Cloudflare Docs
  18. 18.Integration security guide: Content Security Policy, Stripe Docs
  19. 19.Use a Content Security Policy with Google tags, Google for Developers
  20. 20.Realtime Protocol, Supabase Docs
  21. 21.Set up a custom domain, Lovable Docs
  22. 22.How Lovable hosts your app, Lovable Docs
  23. 23.Netlify for hosting, Bolt Help Center

Your app is public. So are its mistakes.

Check what any attacker can already see in it, free, in ten seconds. The full attack, with proof and fixes: €49 once, or €29 a month on a plan.

or continue with Google or GitHub