Web Server security checklist

Web Server Security Headers Checklist: 8 Checks (2026)

Security headers are the instructions your server gives the browser about how to defend your users. This page documents all 8 checks in the CtrlOps Security Headers audit: what each one reads, the exact value that passes, the values that warn or fail, and the config line that fixes it.

Hiren KalariyaLast reviewed: Aug 22, 202615 min read
8
checks in this audit
0
rated high severity
10s
automated run time
0
need root or sudo

Every pass, warning and failure threshold on this page is transcribed from the web-server-security-security-headers audit script that CtrlOps runs, and a build check fails if the two ever disagree. See how the audit runs.

TLS makes the connection private. Security headers decide what the browser does once the page arrives: whether it will run an inline script, whether it will let another site frame you, whether it will hand your session cookie to JavaScript. This page documents the 8 checks in the CtrlOps Security Headers audit, in the order the audit runs them.

Every threshold below is transcribed from the audit script itself. The one mechanism worth understanding before reading the rest: this audit reads the live response, not just the config. It curls loopback and sends your configured server_name as the Host header, so the headers it grades are the ones your actual vhost returns, rather than whatever the default server block happens to send.

Key takeaways

Eight checks, no root needed for any of them, and only one can fail outright.

  • Five are MEDIUM: HSTS, frame protection, CSP, CORS and cookie flags. Three are LOW: nosniff, referrer policy and permissions policy.
  • None need sudo. Every check reads config text and an HTTP response, which any account can do. This is the one audit in the catalog you can run without elevated access at all.
  • Only HSTS and CORS have a FAIL branch. Everything else warns, because a missing header is a missing improvement rather than an active misconfiguration.
  • A config-only pass is a weaker statement than a live pass. Where the probe cannot reach the server, findings are suffixed "no live response, config only" and the check grades what the config says instead. Read that suffix.
  • The whole audit takes about 10 seconds when CtrlOps runs it over your existing SSH connection, against roughly 3 minutes of curl and grep per host by hand.
#CheckSeverityWhat it catchesRoot needed
1HSTSMEDIUMNo HSTS, or max-age under 6 monthsNo
2Content type optionsLOWMissing nosniffNo
3Frame protectionMEDIUMNo X-Frame-Options and no frame-ancestorsNo
4Content Security PolicyMEDIUMNo CSP, or a permissive oneNo
5Referrer policyLOWFull URLs leaking to third partiesNo
6Permissions policyLOWCamera, microphone and location requestableNo
7CORS policyMEDIUMWildcard origin, worst with credentialsNo
8Cookie flagsMEDIUMCookies missing HttpOnly, Secure or SameSiteNo
How the probe picks a vhost. The audit extracts the first real server_name or ServerName from your config, skipping _, localhost and wildcards, then curls https://127.0.0.1/ with that as the Host header, falling back to plain HTTP and then port 8080. Without it, every request would land on the default vhost and every header check on a name-based server would report a false failure. If no usable server name exists, findings say "default vhost only".

Check 1: Is HSTS configured with a long enough max-age?

Medium severityNo sudo needed

Strict-Transport-Security tells the browser to use HTTPS for this host and refuse to fall back, for as long as max-age says. Without it, the first request of every session is plain HTTP, and anyone in a position to intercept it can serve a downgraded copy of your site before the redirect ever happens.

The audit reads the header off the live response and compares max-age against 15552000 seconds, which is six months. Below that, the protection window is short enough that a visitor who has not been back in a while is unprotected again.

How to check manually

curl -sI https://yourdomain.com | grep -i 'strict-transport-security'
Result thresholds this check applies
ResultWhenWhat it means
PASSThe header is served with max-age of at least 15552000, or HSTS is configured but the probe got no live responseTwo distinct passes. The live one names the max-age it found. The config-only one says so in the finding and asks you to confirm the header is actually sent on HTTPS responses.
WARNThe header is served with max-age below 15552000The actual value is named. Raise it to 15552000 and add includeSubDomains once every subdomain has a certificate.
FAILNo Strict-Transport-Security header and nothing in the config that sets oneThe first visit stays downgradeable. This is the one header whose absence the audit treats as a failure rather than a warning.
SKIPNo web server detected on the hostNothing to evaluate.

How to fix it

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"

The always keyword in Nginx matters: without it the header is omitted on error responses, which is exactly when a downgrade attempt is most likely to be in play.

Do not add includeSubDomains until every subdomain has a valid certificate. One bare-HTTP subdomain, a staging host or an old internal tool, becomes completely unreachable for any browser that has cached the policy, and you cannot undo that by removing the header: the browser holds it for the full max-age.

Check 2: Does the server send X-Content-Type-Options nosniff?

Low severityNo sudo needed

X-Content-Type-Options: nosniff tells the browser to trust the declared Content-Type and stop guessing. Without it, a browser may inspect a file's contents and decide that the .txt a user uploaded is really JavaScript, then execute it in your origin.

The audit looks for the header on the live response, then falls back to looking for it in the effective config.

How to check manually

curl -sI https://yourdomain.com | grep -i 'x-content-type-options'
grep -rn 'X-Content-Type-Options' /etc/nginx/ /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'
Result thresholds this check applies
ResultWhenWhat it means
PASSnosniff is served, or the header is configured but the probe got no live responseTwo distinct passes, and the config-only one says so in the finding.
WARNNeither the response nor the config sets itBrowsers may MIME-sniff an upload into executable content.
SKIPNo web server detected on the hostNothing to evaluate.

How to fix it

add_header X-Content-Type-Options "nosniff" always;
Header always set X-Content-Type-Options "nosniff"
Bottom line: this is one line, it has no compatibility cost, and it breaks nothing that was working. It is rated LOW because it only matters when something else has already gone wrong, and it is still the cheapest header on the page to add.

Check 3: Can your pages be framed by another site?

Medium severityNo sudo needed

Clickjacking works by loading your page in an invisible frame on an attacker's site and positioning their controls over yours, so the victim's click lands on your "confirm" button while they believe they are clicking something else. Two headers prevent it: the older X-Frame-Options, and the frame-ancestors directive inside a Content Security Policy.

The audit accepts either. It checks the live response for X-Frame-Options or a CSP containing frame-ancestors, then falls back to the config text.

How to check manually

curl -sI https://yourdomain.com | grep -iE 'x-frame-options|content-security-policy'
Result thresholds this check applies
ResultWhenWhat it means
PASSX-Frame-Options is served, the CSP contains frame-ancestors, or either is present in the configTwo distinct passes. The live one names which mechanism it found; the config-only one says the probe returned nothing.
WARNNeither header nor CSP directive is present anywhereYour pages can be framed by any site. Set frame-ancestors to self.
SKIPNo web server detected on the hostNothing to evaluate.

How to fix it

# The modern form, and the one that wins where both are present
add_header Content-Security-Policy "frame-ancestors 'self';" always;

# The older header, still worth sending for legacy clients
add_header X-Frame-Options "SAMEORIGIN" always;
Header always set Content-Security-Policy "frame-ancestors 'self';"
Header always set X-Frame-Options "SAMEORIGIN"
Bottom line: frame-ancestors is the one to configure, because it supports a real allowlist rather than the two fixed values X-Frame-Options offers. Sending both costs nothing, and browsers that understand CSP ignore the older header when both are present.

Check 4: Is a Content Security Policy in place?

Medium severityNo sudo needed

A Content Security Policy names the origins a page may load scripts, styles, images and frames from. It is the difference between an XSS bug that steals a session and an XSS bug that fails because the injected script has nowhere to load from and no permission to run inline.

The audit grades presence and quality together. A CSP containing unsafe-inline, unsafe-eval or a wildcard is reported as present but permissive, because in practice those three let most real payloads through.

How to check manually

curl -sI https://yourdomain.com | grep -i 'content-security-policy'
Result thresholds this check applies
ResultWhenWhat it means
PASSA CSP is served with no unsafe-inline, unsafe-eval or wildcard, or a CSP is configured but the probe got no live responseTwo distinct passes. The config-only one asks you to verify it is sent and does not rely on unsafe-inline.
WARNA CSP is served but contains unsafe-inline, unsafe-eval or a wildcard, or no CSP exists at allTwo distinct warnings. The permissive one is worth reading carefully: the header is there and blocks very little.
SKIPNo web server detected on the hostNothing to evaluate.

That first warning catches the most common outcome of adopting CSP: a policy written to stop breaking the site, tuned until nothing was blocked, which is another way of saying nothing is blocked.

How to fix it

Start in report-only mode so violations are logged rather than enforced:

add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; report-uri /csp-report" always;

Then tighten and switch to enforcing:

add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'self';" always;

Inline styles are the usual last holdout. Keeping 'unsafe-inline' on style-src while script-src stays clean is a reasonable intermediate state, and the audit will still warn, correctly, that the policy is permissive.

CSP breaks things, and it breaks them silently for users rather than loudly for you. Run report-only for at least a full traffic cycle, including your checkout or login flow and whatever third-party widget marketing added last quarter. A policy that blocks your payment form is worse than no policy at all.

Check 5: Does the server control referrer leakage?

Low severityNo sudo needed

When a visitor follows a link off your site, the browser tells the destination where they came from. By default that is the full URL, including the path and query string, which routinely carries password reset tokens, search terms, internal document ids and customer identifiers.

The audit looks for a Referrer-Policy header on the live response, then in the config. It does not grade the value, because any explicit policy is a decision and the absence of one is not.

How to check manually

curl -sI https://yourdomain.com | grep -i 'referrer-policy'
Result thresholds this check applies
ResultWhenWhat it means
PASSA Referrer-Policy is served, or one is configured but the probe got no live responseTwo distinct passes. The live one names the policy value it found.
WARNNo Referrer-Policy anywhereFull URLs including tokens leak to third parties. Set strict-origin-when-cross-origin.
SKIPNo web server detected on the hostNothing to evaluate.

How to fix it

add_header Referrer-Policy "strict-origin-when-cross-origin" always;
Header always set Referrer-Policy "strict-origin-when-cross-origin"

strict-origin-when-cross-origin keeps the full URL for same-origin navigation, which analytics needs, and sends only the origin to other sites. It is the modern browser default, but defaults change and an explicit header is what makes the behaviour yours.

Bottom line: this is the header that decides whether a password reset link ends up in a third party's access logs. One line, no compatibility risk, and it preserves the same-origin referrer your own analytics depends on.

Check 6: Is Permissions Policy limiting browser APIs?

Low severityNo sudo needed

Permissions-Policy decides which browser capabilities a page and its embedded frames may even ask for: camera, microphone, geolocation, payment handlers and more. Without it, every third-party script on the page, the chat widget, the analytics tag, the ad slot, is free to prompt your visitor for their location or their camera.

The audit accepts Permissions-Policy or the older Feature-Policy spelling, from the live response or the config.

How to check manually

curl -sI https://yourdomain.com | grep -i 'permissions-policy'
Result thresholds this check applies
ResultWhenWhat it means
PASSA Permissions-Policy is served, or one is configured but the probe got no live responseTwo distinct passes. The audit does not grade which features are denied, only that a policy exists.
WARNNo Permissions-Policy or Feature-Policy anywhereDeny camera, microphone and geolocation by default if the site does not use them.
SKIPNo web server detected on the hostNothing to evaluate.

How to fix it

add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"

An empty allowlist denies the feature everywhere, including to your own page. If the site genuinely uses one, name the origins that may: geolocation=(self).

Bottom line: the value of this header is mostly about code you did not write. Your application probably never asks for the camera; the question is whether everything else loaded into the page can.

Check 7: Are the CORS headers safely scoped?

Medium severityNo sudo needed

Access-Control-Allow-Origin relaxes the browser's same-origin policy and names who may read your responses. A wildcard is correct for a genuinely public read-only API and wrong for anything behind a session. A wildcard combined with Access-Control-Allow-Credentials: true is the dangerous case: it is a request for any site on the internet to make authenticated requests on your users' behalf.

The audit reads both headers from the live response and grades the combination.

How to check manually

curl -sI -H "Origin: https://example.org" https://yourdomain.com | grep -i 'access-control'
Result thresholds this check applies
ResultWhenWhat it means
PASSThe header names a specific origin, or no CORS headers are sent at allTwo distinct passes. No CORS headers is a pass because cross-origin reads then stay blocked by the browser same-origin policy.
WARNThe origin is a wildcard, or CORS is configured but nothing came back on the probeTwo distinct warnings. A wildcard is fine for a public read-only API and wrong for anything behind a session. The configured-but-not-seen case asks you to check what the API routes actually send.
FAILThe origin is a wildcard and credentials are allowedAny site could make authenticated cross-origin requests. Name the permitted origins explicitly.
SKIPNo web server detected on the hostNothing to evaluate.

One caveat that matters for API servers: the probe requests /, so a CORS policy that only applies under /api/ will show up as the "configured but none came back" warning rather than being graded properly. That warning is a prompt to check the route yourself, not a finding about the root path.

How to fix it

# Name the origins rather than reflecting whatever arrives
map $http_origin $cors_origin {
    default "";
    "https://app.example.com" $http_origin;
    "https://admin.example.com" $http_origin;
}

add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;

The pattern to avoid is reflecting the incoming Origin header back unconditionally. It looks like a specific origin in every test you run, and it is a wildcard in practice.

Browsers already reject wildcard plus credentials, so why does this fail? Because the combination tells you what the configuration intends, and the next step from a blocked wildcard is usually origin reflection, which browsers accept and which grants exactly what the wildcard was refused. The finding is about the intent, not the immediate exploit.

Check 8: Do cookies carry HttpOnly, Secure and SameSite?

Medium severityNo sudo needed

Three flags decide how much damage a cookie can do once something goes wrong. HttpOnly keeps it out of reach of JavaScript, so an XSS bug cannot read the session. Secure stops it being sent over plain HTTP. SameSite stops it riding along on cross-site requests, which is most of what CSRF needs.

The audit reads every Set-Cookie header on the probed response and reports which of the three are missing across them.

How to check manually

curl -sI https://yourdomain.com | grep -i 'set-cookie'
Result thresholds this check applies
ResultWhenWhat it means
PASSEvery cookie on the probed response carries HttpOnly, Secure and SameSiteThe cookie count is named.
WARNOne or more flags are missing across the cookies seenThe count and the specific missing flags are listed.
SKIPNo web server detected, or the probed response set no cookies at allTwo distinct skips. The second is the common one: the home page rarely sets a session cookie, so exercise a login route to audit the cookie that matters.

That second skip is the important one to read correctly. Most applications only issue a session cookie after authentication, so a skip here means the audit never saw the cookie you actually care about, not that your cookies are clean.

How to fix it

Cookie flags normally belong to the application that sets the cookie, so fix them there first. Where a proxy sits in front of an application you cannot change:

# nginx 1.19.3 and later
proxy_cookie_flags ~ httponly secure samesite=lax;
Header always edit Set-Cookie (.*) "$1; HttpOnly; Secure; SameSite=Lax"
Bottom line: set the flags in the application, then verify at the edge. A reverse proxy that rewrites cookies, or a framework default that differs between environments, is how a cookie that is Secure in staging arrives without the flag in production.

What this audit does not cover

This audit reads HTTP response headers and the config that sets them. It does not check:

  • TLS itself. Protocol versions, cipher suites, certificate expiry and the HTTPS redirect are the TLS Configuration audit.
  • What the server exposes. Directory listing, dotfiles, backup files and status endpoints are the Content Exposure audit.
  • Server configuration hardening. File permissions, web root ownership, request limits and rate limiting are the Configuration Hardening audit.
  • Versions and patching. Server inventory, version disclosure, worker user and pending updates are the Identification and Patching audit.
  • Headers set at a CDN. If Cloudflare, CloudFront or Fastly adds or strips headers at the edge, this audit sees the origin response, which is the one your CDN forwards from but not necessarily the one your visitors receive.
  • Application-set headers on other routes. The probe requests the root path of one vhost. A header set only on /api/ is not seen.

The manual audit problem at scale

Checking eight headers by hand on one server is about three minutes, if you remember the header names and have a shell open. Across ten servers it is half an hour, the results live in terminal scrollback, and the question nobody can answer afterwards is which of the ten was the one missing CSP.

The subtler problem is the vhost. curl -sI https://127.0.0.1/ answers from the default server block, which on a name-based server is not the site you meant to test, so a manual check can report missing headers that are in fact present, or present headers that your real vhost never sends.

TaskBy hand, 8 hostsCtrlOps, 8 hosts
Run all 8 checksAbout 25 minutesAbout 80 seconds
Vhost handlingRemember to set the Host headerExtracted from the config automatically
Get a scored reportNot availableAutomatic
Fix what failedCopy-paste from your notesAI-generated commands behind an approval gate
Re-audit after fixingAnother 25 minutesAnother 80 seconds

How CtrlOps runs all 8 checks in one click

Instead of running these commands on every server by hand, CtrlOps runs the whole Security Headers audit over the SSH connection you already have open. Nothing is installed on the server: no agent, no daemon, no package, no new credentials. The checks are read-only POSIX shell that executes, streams its results back, and leaves nothing behind.

Watch it run

The recording below walks through the SSH and Access audit rather than this one, but the flow is identical for every audit in the catalog: pick it, watch the checks stream in, read the score, send the failures to the AI Terminal.

Step 1: Open the Audit tab

Connect to your server in CtrlOps and open the Audit tab in the left sidebar.

Step 2: Select Security Headers

Choose it from the Web Server category. All 8 checks are listed with their descriptions, severity levels and the estimated run time, about 10 seconds in total. None of them need root, which is unusual in this catalog and worth knowing if you audit with a restricted account.

Step 3: Run it

The audit runs over your existing SSH session. Results stream in as they complete, so you watch each check pass, warn or fail rather than waiting on a final report.

Step 4: Read the report

You get a summary containing:

  • A hardening score out of 100
  • A severity breakdown: how many HIGH, MEDIUM and LOW findings
  • Pass, warning, failed and skipped counts
  • A findings table with the header value it actually saw, or the note that only the config could be read

Step 5: Fix what failed

Select the failed findings and send them to the AI Terminal. Each proposed fix command appears with an explanation of what it changes. Nothing runs until you review and approve it, which matters here because add_header in Nginx replaces the inherited set for that block rather than adding to it, and a fix applied in the wrong place silently drops the headers you already had.

Step 6: Re-run and compare

Run the same audit again after reloading and watch the score move. This audit is fast enough to run as the last step of a deployment, which is the point at which a changed proxy config quietly stops forwarding a header.

Conclusion

Security headers are the cheapest hardening on a web server and the most often skipped, because nothing breaks when they are missing. The site works, the padlock appears, and the only evidence is a report nobody runs.

One failure on this page is an active misconfiguration: a wildcard CORS policy with credentials. The rest are absences, and each one is a single line of config. The CtrlOps Security Audit runs all 8 in about 10 seconds per server, against the real vhost rather than the default one.

The neighbouring checklists cover the other web server layers: TLS configuration, content exposure, configuration hardening, identification and patching and logging and monitoring.


Frequently asked questions

At minimum: Strict-Transport-Security, X-Content-Type-Options: nosniff, frame protection through X-Frame-Options or CSP frame-ancestors, and Referrer-Policy. Add Content-Security-Policy and Permissions-Policy once you have time to tune them, and verify that session cookies carry HttpOnly, Secure and SameSite.

It tells the browser to use the Content-Type the server declared rather than inspecting the bytes and guessing. Without it, a file uploaded with a harmless extension but HTML or JavaScript contents can be sniffed into executable content and run in your origin. With it, a file served as text/plain stays text.

Use add_header in the server or http block, with the always keyword so the header is sent on error responses too: add_header X-Content-Type-Options "nosniff" always;. Then sudo nginx -t && sudo systemctl reload nginx. Be careful about placement: add_header in a location block replaces the inherited set for that location rather than adding to it.

Enable the headers module with sudo a2enmod headers, then use Header always set in the VirtualHost or global config: Header always set X-Content-Type-Options "nosniff". Reload with sudo systemctl reload apache2. On RHEL family, mod_headers is usually loaded already from conf.modules.d.

Because unlike the other headers here, the thing it prevents is an attack on the connection rather than on the page, and the window it closes is the first request of every session. It is also the only header on this page with a numeric baseline: 15552000 seconds, six months. Below that the audit warns instead, and names the value it found.

It can, and the failure mode is quiet: a blocked script means a feature stops working for visitors while your server logs look normal. Deploy with Content-Security-Policy-Report-Only first, watch real traffic including your login and payment flows, then switch to enforcing. Expect inline styles to be the last thing you can remove.

That the audit could not get a live HTTP response from the host, so it graded the configuration text instead, and the finding is suffixed to say so. It is weaker evidence: a header present in the config can still be dropped by a location block that overrides it, or by a proxy in front. Treat it as "configured" rather than "confirmed sent".

Because a wildcard alone is a legitimate configuration for a public read-only API, and the audit will not fail something that is correct for a common case. A wildcard combined with Access-Control-Allow-Credentials: true is never correct: browsers reject the combination, which means whoever configured it wanted authenticated cross-origin access and will likely reach for origin reflection next, which browsers do accept.

Because it reads the cookies on one probed response, normally the home page, and most applications only issue a session cookie after login. The skip message says exactly that and asks you to exercise a login route. It is deliberately not a pass, because a pass would imply the audit saw the cookie and approved of it.

Not measurably. They add a few hundred bytes to each response and no processing. HTTPS is a ranking signal and HSTS keeps visitors on it, so the indirect effect is mildly positive, but the reason to send them is that they stop specific attacks rather than anything to do with ranking.

When your headers are set or rewritten at the edge. CtrlOps reads the origin response over SSH, so headers added by Cloudflare, CloudFront or Fastly are invisible to it, and headers your origin sends may be stripped before a visitor sees them. Audit the CDN in its own dashboard, and use this audit to confirm what the origin sends underneath.

Audit your fleet

Run all these checks on every server, in one click

CtrlOps runs this audit over your existing SSH connection - no agents, no scripts to manage. $7/user/month after a 1 month free trial - no credit card required.

Windows

✓ Start instantly·✓ No credit card·✓ No sneaky autorenewals