# Security headers checker

Source: https://penetrationtestservices.com/tools/security-headers-checker/
Publisher: Excession Labs (penetrationtestservices.com)
Last updated: 2026-11-09

Free security headers checker for any public site. See which headers are missing, the recommended value for each, and config for nginx, Apache, Next.js and more.

> **Short answer:** A security headers checker reads the HTTP response headers of a page and compares them with recommended values for HSTS, Content-Security-Policy, framing, content type sniffing, referrer policy and permissions policy. This free checker makes one GET request per redirect, reads headers only and stores nothing. It is not a penetration test.

Enter a public address to check its security headers, then use the fix for each result below.

## What the checker does and does not do

The checker sends one GET request per redirect, like a browser visit, and reads the response headers. It does not read or store the page body. It does not log in, crawl, fuzz or send attack payloads.

- It follows up to 5 redirects and checks each hop.
- It accepts public http and https addresses on the default ports. It refuses IP addresses, internal names and names that resolve to private addresses.
- It grades each check as pass, warn, fail or info.
- It is not a penetration test. It cannot find broken access control, injection or logic flaws. See [when headers are not enough](#when-headers-are-not-enough).

Use it only on sites you own or have permission to test. A header check is a normal page request, but you should still ask first for a site that is not yours.

## What each check looks for and the value to set

Each section gives the value we recommend, the reason, and what the checker reports. Values follow the [OWASP HTTP Headers Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html) unless noted.

### HTTPS

The final page must load over https. The checker passes an http address that redirects to https. It fails a final page served over plain http, because anyone on the network can then read or change it.

### Strict-Transport-Security (HSTS)

Set `Strict-Transport-Security: max-age=63072000; includeSubDomains`. HSTS tells the browser to use https for your site even if a user types http ([RFC 6797](https://datatracker.ietf.org/doc/html/rfc6797)). Without it, the first request can still go out over http, which allows a downgrade.

The checker passes a `max-age` of at least 31536000 seconds (one year). It warns on a shorter value and fails when the header is missing or `max-age` is zero. Browsers ignore the header over plain http, so it only counts on https responses.

### Content-Security-Policy (CSP)

Set a policy that limits where scripts load from. A starting point for a site with no inline scripts is `default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'`. CSP reduces the impact of cross-site scripting ([W3C CSP Level 3](https://www.w3.org/TR/CSP3/)).

The checker fails when no policy exists and warns when only `Content-Security-Policy-Report-Only` is set, because that mode reports violations but blocks nothing. It warns on `'unsafe-inline'` without a nonce or hash, `'unsafe-eval'`, wildcard or scheme sources in `script-src`, no `script-src` or `default-src`, and no `object-src`.

### Clickjacking protection

Use `frame-ancestors` in your CSP, or `X-Frame-Options: DENY` (or `SAMEORIGIN` if you frame your own pages). OWASP prefers `frame-ancestors`. This stops other sites from loading your page in a frame and tricking users into clicking. The checker passes either control and fails when neither is set.

### X-Content-Type-Options

Set `X-Content-Type-Options: nosniff`. It stops browsers from guessing a file's type, which can turn an uploaded file into a script. Pair it with correct `Content-Type` headers. The checker passes only the exact value `nosniff`.

### Referrer-Policy

Set `Referrer-Policy: strict-origin-when-cross-origin`. It keeps full URLs, including query strings, from reaching other sites. Modern browsers use this value by default, and OWASP advises setting it explicitly. The checker passes `no-referrer`, `same-origin`, `strict-origin` and `strict-origin-when-cross-origin`, fails `unsafe-url`, and warns on other values or a missing header.

### Permissions-Policy

Set `Permissions-Policy: geolocation=(), camera=(), microphone=()`. List every browser feature your site does not use. An injected script then cannot switch them on. The checker passes when the header is present and warns when it is missing.

### Cross-Origin-Opener-Policy

`Cross-Origin-Opener-Policy: same-origin` isolates your page from windows it opens or that open it. The checker reports this as info, not a failure. Test sign-in pop-ups and payment windows before you add it, because they can break.

### Version disclosure

Remove `X-Powered-By`, `X-AspNet-Version`, `X-Generator` and any version number in `Server`. OWASP suggests removing them or using a neutral value. This is a small gain, because attackers can fingerprint a stack in other ways. The checker warns when it finds one.

### Cookie flags

Session cookies need `Secure`, `HttpOnly` and an explicit `SameSite`. The checker reads the cookies set on the response and warns if any lacks `Secure` on https or an explicit `SameSite`. It reports at most 20 cookies.

### Other items you may see

- **X-XSS-Protection:** shown as info when set to anything but 0. OWASP says to set 0 or omit it.
- **CORS:** shown when `Access-Control-Allow-Origin` is present. A warning appears when credentials are allowed cross-origin. Use a fixed list of origins and never reflect the request origin.

## Copy-paste config by stack

These blocks set the same baseline. Replace the CSP with one your site can run, and test it before release. Start with `Content-Security-Policy-Report-Only` if you are unsure.

| Stack | Where it goes |
|---|---|
| nginx | `server` block |
| Apache | virtual host or `.htaccess` (needs `mod_headers`) |
| Caddy | site block in the Caddyfile |
| Cloudflare | Worker, or a Transform Rule (Modify Response Header) |
| Next.js | `next.config.js` |
| Express | `helmet` middleware |
| Vercel | `vercel.json` |
| Netlify | `_headers` file |

**nginx.** Add `always` so headers also appear on error pages.

```nginx
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
server_tokens off;
```

nginx only inherits `add_header` lines from a parent level when the current level has none ([nginx docs](https://nginx.org/en/docs/http/ngx_http_headers_module.html)). One `add_header` inside a `location` block drops all the parent headers for that location.

**Apache** (`mod_headers`).

```apache
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), camera=(), microphone=()"
Header always unset X-Powered-By
ServerTokens Prod
```

**Caddy.**

```caddy
header {
    Strict-Transport-Security "max-age=63072000; includeSubDomains"
    Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'"
    X-Content-Type-Options "nosniff"
    Referrer-Policy "strict-origin-when-cross-origin"
    Permissions-Policy "geolocation=(), camera=(), microphone=()"
    -Server
    -X-Powered-By
}
```

**Cloudflare Worker.** A Transform Rule with "Modify Response Header" sets the same values without code. This Worker does it in code.

```js
export default {
  async fetch(request, env, ctx) {
    const res = await fetch(request);
    const out = new Response(res.body, res);
    out.headers.set("Strict-Transport-Security", "max-age=63072000; includeSubDomains");
    out.headers.set("Content-Security-Policy", "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'");
    out.headers.set("X-Content-Type-Options", "nosniff");
    out.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
    out.headers.set("Permissions-Policy", "geolocation=(), camera=(), microphone=()");
    out.headers.delete("X-Powered-By");
    return out;
  },
};
```

**Next.js** (`next.config.js`).

```js
module.exports = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
          { key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" },
          { key: "X-Content-Type-Options", value: "nosniff" },
          { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
          { key: "Permissions-Policy", value: "geolocation=(), camera=(), microphone=()" },
        ],
      },
    ];
  },
  poweredByHeader: false,
};
```

Next.js injects inline scripts, so a strict `script-src 'self'` will block them. Use a per-request nonce, as the Next.js documentation describes, or start in Report-Only mode.

**Express with helmet.**

```js
import helmet from "helmet";

app.disable("x-powered-by");
app.use(
  helmet({
    strictTransportSecurity: { maxAge: 63072000, includeSubDomains: true },
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        objectSrc: ["'none'"],
        baseUri: ["'self'"],
        frameAncestors: ["'self'"],
      },
    },
    referrerPolicy: { policy: "strict-origin-when-cross-origin" },
  })
);
```

Helmet sets `X-Content-Type-Options: nosniff` by default. Add `Permissions-Policy` with a small middleware, because helmet does not set it.

**Vercel** (`vercel.json`).

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

**Netlify** (`_headers` file in the publish directory).

```text
/*
  Strict-Transport-Security: max-age=63072000; includeSubDomains
  Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: geolocation=(), camera=(), microphone=()
```

## Common mistakes

**CSP with `'unsafe-inline'`.** It allows any inline script, so an injected script runs. That removes most of the value of the policy. Move scripts to files, or use a nonce or hash for each inline script. The checker does not warn on `'unsafe-inline'` when a nonce, hash or `'strict-dynamic'` is also present.

**Adding `preload` too early.** The preload list is built into browsers. hstspreload.org requires `max-age` of at least 31536000, `includeSubDomains` and `preload`, and https on every subdomain, including internal ones. It states that removal takes months to reach users. Test HSTS without `preload` first.

**Duplicate headers from proxy and app.** A CDN, load balancer and app can each add the same header with different values. Browsers may combine or pick one, depending on the header. Set each header in one layer. Compare the checker output with a request straight to the app to find the extra layer.

**Headers missing on errors and redirects.** In nginx, add `always`. In Apache, use `Header always`. Many scanners and attackers see 404 and 301 responses first.

**Setting CSP and breaking the site.** Deploy `Content-Security-Policy-Report-Only` first, read the reports, fix the sources, then switch to enforcement.

## When headers are not enough

Headers harden the browser side of a site. They do not show whether an attacker can read another user's records, skip a payment step or take over an account. Those flaws sit in application logic, and only testing finds them.

If you want that testing, read how [web application penetration testing](/web-application-penetration-testing/) works. For related reading, see [penetration testing tools](/guides/penetration-testing-tools/), [DAST](/guides/dast-dynamic-application-security-testing/) and [vulnerability assessment](/guides/vulnerability-assessment/). Before a launch, see [pre-launch security testing](/pre-launch-security-testing/). To collect what providers ask before they quote, use the free [pentest scoping checklist](https://pentestcompare.com/tools/scoping-checklist/) from PentestCompare.

## Questions

### Does a good grade mean my site is secure?

No. Headers reduce the impact of some browser-side attacks. They do not test login, access control, injection or business logic. A site can pass every header check and still let one customer read another customer's data.

### Do I need a Content-Security-Policy on a JSON API?

It matters little there. OWASP notes that CSP is aimed at pages that run scripts and may be pointless on REST responses that a browser never renders. Set nosniff and remove version headers on APIs, and put CSP on your HTML pages.

### Should I submit my domain to the HSTS preload list?

Only if every subdomain will serve HTTPS for the long term. hstspreload.org says removal takes months to reach users and that preloading is not recommended in general. Run HSTS with includeSubDomains for a while first.

### Why does the checker show a header my app does not set?

A proxy, CDN or host can add headers between your app and the browser. The checker sees the final response, which is what a browser sees. Compare it with your app's own output to find who sets what.

### Is X-XSS-Protection still needed?

No. OWASP recommends setting it to 0 or leaving it out, because the old browser filter could create XSS in safe pages. Use a Content-Security-Policy that blocks inline scripts.

## Sources

- [OWASP Secure Headers Project](https://owasp.org/www-project-secure-headers/)
- [OWASP HTTP Headers Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html)
- [RFC 6797: HTTP Strict Transport Security (HSTS)](https://datatracker.ietf.org/doc/html/rfc6797)
- [W3C Content Security Policy Level 3](https://www.w3.org/TR/CSP3/)
- [MDN: Strict-Transport-Security header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security)
- [MDN: Content-Security-Policy header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy)
- [MDN: X-Frame-Options header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Frame-Options)
- [MDN: X-Content-Type-Options header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Content-Type-Options)
- [MDN: Referrer-Policy header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy)
- [MDN: Permissions-Policy header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy)
- [MDN: Cross-Origin-Opener-Policy header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cross-Origin-Opener-Policy)
- [HSTS Preload List submission requirements](https://hstspreload.org/)
- [nginx: ngx_http_headers_module (add_header)](https://nginx.org/en/docs/http/ngx_http_headers_module.html)
