How to check security headers

Send one HEAD request, save the response headers, then print a value or the word MISSING for each header you care about. The six that carry weight are strict-transport-security, content-security-policy, x-frame-options, x-content-type-options, referrer-policy and permissions-policy. The printed list is your verdict.

Checker offline. Follow the manual steps below, they give the same answer.

Why check this

Security headers are set by the CDN, the reverse proxy or a framework middleware, and all three change without touching application code. Run this sweep at release sign-off and again after any infrastructure change. It catches the case where a proxy rule is scoped to one path prefix, so the marketing pages keep their content-security-policy while the authenticated dashboard, served from another upstream, ships with none.

Prerequisites

Steps

  1. Step 1.

    Sweep a route that sets nothing, so you know what an absent header looks like.

    curl -sI https://example.com/ | tr -d '\r' > h.txt; for n in strict-transport-security content-security-policy x-frame-options x-content-type-options referrer-policy permissions-policy; do v=$(grep -i "^$n:" h.txt | cut -d: -f2- | sed 's/^ //' | cut -c1-38); printf '%-26s %s\n' "$n" "${v:-MISSING}"; done
    
    strict-transport-security  MISSING
    content-security-policy    MISSING
    x-frame-options            MISSING
    x-content-type-options     MISSING
    referrer-policy            MISSING
    permissions-policy         MISSING
  2. Step 2.

    Run the same sweep against the route you are signing off.

    curl -sI https://www.cloudflare.com/ | tr -d '\r' > h.txt; for n in strict-transport-security content-security-policy x-frame-options x-content-type-options referrer-policy permissions-policy; do v=$(grep -i "^$n:" h.txt | cut -d: -f2- | sed 's/^ //' | cut -c1-38); printf '%-26s %s\n' "$n" "${v:-MISSING}"; done
    
    strict-transport-security  max-age=31536000; includeSubDomains
    content-security-policy    default-src 'self'; script-src 'self' 
    x-frame-options            SAMEORIGIN
    x-content-type-options     nosniff
    referrer-policy            strict-origin-when-cross-origin
    permissions-policy         geolocation=(), camera=(), microphone=

    The last two values are cut at 38 characters by cut -c1-38. Read a full value with a plain grep once a name is on your list.

  3. Step 3.

    Read the HSTS lifetime in full, because its number decides whether the header does anything.

    curl -sI https://www.cloudflare.com/ | tr -d '\r' | grep -i '^strict-transport-security:'
    
    strict-transport-security: max-age=31536000; includeSubDomains
  4. Step 4.

    Repeat the sweep on a response that is not a 200, using a GET so the redirect itself is shown.

    curl -s -D - -o /dev/null https://www.cloudflare.com/h2check-no-such-page | tr -d '\r' > r.txt; head -1 r.txt; for n in strict-transport-security content-security-policy x-frame-options x-content-type-options; do v=$(grep -i "^$n:" r.txt | cut -d: -f2- | sed 's/^ //' | cut -c1-38); printf '%-26s %s\n' "$n" "${v:-MISSING}"; done
    
    HTTP/2 301 
    strict-transport-security  MISSING
    content-security-policy    MISSING
    x-frame-options            MISSING
    x-content-type-options     MISSING

    The same host that passed step 2 sets none of the four on its 301. Record the status code next to every verdict.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | A value on every line | The route sets all six | Read the values. Presence is not correctness, and step 3 shows why. | | MISSING on one line | That header is absent on this route | Find the layer that should set it: CDN rule, proxy config, or middleware. | | MISSING everywhere | No header layer is applied to this route | The route is served by an upstream the header config never reached. | | Values differ between two routes | The header rule is path scoped | Sweep every route class: HTML, API, static asset, error page. | | max-age=0 in HSTS | The policy is switched off | Someone disabled HSTS deliberately. Confirm it was intended before release. |

Common mistakes

Sign: The sweep passes on the home page, and a penetration test later reports missing headers.Cause: The sweep ran against one route. CDN and proxy header rules are scoped by path or by origin, so an API subdomain or an authenticated route served by another upstream receives none of them.
Sign: Headers are present on the 200 and absent on the redirect that led to it.Cause: curl -sI follows nothing by default and curl -L prints only the final response unless you pass -D -. Step 4 reads the 301 itself, which is where HSTS matters most because that response can arrive over plain HTTP.
Sign: An online scanner grades the site A and the sweep here shows a missing header.Cause: Scanners fetch the domain root over GET with a browser user agent. A WAF or a bot rule can return a different response to that request than to the one your application serves.

Thresholds

max-age=31536000

is the minimum HSTS lifetime accepted for the preload list, which is one year in seconds. A shorter value leaves a window where the first request of a session can still go out over plain HTTP.

Source: hstspreload.org submission requirements

What to check next

FAQ

How to check security headers of a website?

Run step 1 and step 2 against the URL. Repeat for each route class the site serves: an HTML page, an API endpoint, a static asset and an error page. A single sweep of the domain root describes one route, not the site.

How to check security headers in Chrome DevTools?

Open DevTools, Network tab, reload, click the document request, then the Headers panel and the Response Headers section. DevTools shows the headers the browser received, including those added by a service worker, which curl cannot see.

Which headers should be in the sweep?

The six in step 1 cover transport, script execution, framing, MIME sniffing, referrer leakage and browser feature access. Add cross-origin-opener-policy and cross-origin-resource-policy when the application uses cross-origin isolation.

Does a missing header always mean a defect?

No. permissions-policy on a static documentation site changes nothing. Decide per route what the header would protect, then file the absence as a defect only where something is exposed.

Can I run this against a staging host behind basic auth?

Yes. Add -u user:pass to the curl command. The header sweep reads the response to the authenticated request, which is the response the release will serve.

Verified

Verified by Maks Vernycurl 8.21.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic6 minpublished updated Maks Verny