Skip to content

How scans are graded

The site scanner fetches a page once and grades what came back. A grade is a security opinion, and publishing the rubric is how HeaderHawk owns it: every cap and every finding below names the guidance it rests on, and the page prints the rubric version it was graded with. This page describes version 5.

A result has three parts, and only two are graded.

  • The Content Security Policy. Every policy the page delivers - each enforced header, each report-only header, and each <meta http-equiv> policy in the initial HTML - is analysed with the same rules as the policy analyzer. Enforced and report-only policies are reported separately, and only enforced ones count.
  • The other headers. Transport, framing, content-type sniffing, the referrer policy, Permissions-Policy syntax, cookie attributes, version strings, the deprecated headers, and cross-origin scripts without integrity.
  • Visibility, shown beside the grade and not counted in it: whether any policy reports what it blocks, whether the group report-to names is defined, whether a report-only policy with reporting means a rollout is under way, and which browser report types the response asks for, each with the header that asks.

Reporting earns no credit in the grade. MDN’s Observatory scores a report-only policy as nothing because it blocks nothing, and a reporting vendor softening that would read as grading its own funnel up. The same goes for the other report types: a page that asks for every one of them grades exactly as it would asking for none.

The score starts at 100 and loses points for every graded finding:

Severity Points
High 25
Medium 10
Low 4
Info 0

The letter is the band the score lands in: A+ from 95, A from 85, B from 70, C from 50, D from 25, and F below that.

Then the caps apply. A cap is a single finding that bounds the grade whatever else the page does right, and the headline under the grade names the lowest one the page hit. When a cap binds, the score is held inside the capped band as well, so the two never contradict each other. When the findings take the grade below the cap, the headline says both.

One more rule: a policy’s own findings can cost at most the 25 points that having no policy costs. Without it a permissive policy scored below no policy at all, which put a page with a policy under one with nothing. The caps still separate them - no policy is a D at best, an open script rule a C.

Max When The reason the page gives
D No enforced Content-Security-Policy, report-only alone included. Or the page is served over plain HTTP. “Nothing on this page stops an injected script.” With a report-only policy that reports: “Rollout in progress: reports are coming in, and nothing is enforced yet.” Over HTTP: “The page is served over plain HTTP, so every header here can be rewritten in transit.”
C Script is effectively open: 'unsafe-inline' with no nonce or hash, * or a scheme source such as https:, data: or blob: in the script source list, or no script-src and no default-src at all. The analyzer finding’s own title, with its remediation as the next step.
B 'unsafe-eval'; a script host known to serve JSONP endpoints or AngularJS; a missing object-src or base-uri; no Strict-Transport-Security, or one under six months; neither frame-ancestors nor a valid X-Frame-Options. The directive or header named, and the one line that fixes it.
A No X-Content-Type-Options: nosniff; no Referrer-Policy, or unsafe-url / no-referrer-when-downgrade; no form-action; HSTS under a year; or a policy that allows script by host allowlist rather than by nonce or hash. Which of those it was.
A+ None of the above: a strict policy (a nonce or hash, and either 'strict-dynamic' or no host allowlist), HSTS of at least a year, form-action set, and the A-row headers present. “This is what a strict policy looks like.”

X-Frame-Options on its own satisfies the framing rule, with a note that frame-ancestors supersedes it. The three cross-origin isolation headers (Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy, Cross-Origin-Resource-Policy) are information only in this version: MDN’s Observatory began scoring them on 1 September 2026, and a grade that moved with it would read as arbitrary.

The rules are the analyzer’s, and each one is explained on the policy analyzer as it fires. The ones that cap the grade:

Finding Severity Cap Source
Scripts are not restricted at all High C CSP Level 3, and MDN’s Observatory scoring of a missing policy
'unsafe-inline' with no nonce or hash High C web.dev, strict CSP; MDN’s Observatory
* in the script source list High C MDN’s Observatory
A bare scheme (https:) as a script source High C MDN’s Observatory; web.dev
data: or blob: as a script source High C MDN’s Observatory
'unsafe-eval' High B MDN’s Observatory; web.dev
A script host known to serve JSONP or AngularJS High B Google CSP Evaluator’s bypass lists; Weichselbaum et al., ACM CCS 2016
object-src missing Medium B web.dev, strict CSP
base-uri missing Medium B web.dev, strict CSP
form-action missing Medium A OWASP CSP cheat sheet
The policy is not a strict CSP Info A web.dev, strict CSP

The remaining analyzer findings (a repeated directive, an unquoted keyword, an http:// source, a deprecated directive, an IP address or localhost as a source, and so on) deduct their severity’s points without capping. Two more are information only: no require-trusted-types-for 'script', and connect-src and img-src left unrestricted. A policy that reports without 'report-sample' is noted beside the grade with the other reporting findings.

A script host allowlist with no 'strict-dynamic' is shown as a low finding and deducts nothing: it is the weakness “not a strict CSP” already caps at A, and charging its points as well would count it twice.

The bypass hosts are Google CSP Evaluator’s JSONP and AngularJS lists, vendored at a pinned commit and matched against the directive that governs script, including *. wildcards and paths. The JSONP endpoints on Google’s tag and analytics hosts count only when 'unsafe-eval' is allowed too, because they need it, and none count under a nonce with 'strict-dynamic', which stops a browser trusting hosts at all. 'self' is not a finding.

The policy analyzer shows the policy part of this grade for a pasted header, from the same code.

A <meta> policy carrying frame-ancestors, report-uri or sandbox gets one medium finding, and those directives are removed before analysis, because a browser ignores them there (CSP Level 3, §3.1.2).

When a page sends more than one enforced policy, a finding counts only when every policy raises it: a browser applies each policy independently and a resource must satisfy all of them, so a weakness one policy closes is not a weakness of the page.

Finding Severity Cap Source
Served over plain HTTP High D MDN Observatory, redirection
Strict-Transport-Security missing, unreadable or max-age=0 High B MDN Observatory; RFC 6797
HSTS max-age under six months Medium B MDN Observatory, which counts six months as in force
HSTS max-age under a year Info A hstspreload.org, which asks for a year
Strict-Transport-Security sent more than once Medium - RFC 6797, section 8.1: a browser processes only the first. Low when the values agree
HSTS sent over plain HTTP, where it is ignored Info - RFC 6797, sections 7.2 and 8.1
HSTS in force without includeSubDomains Info - hstspreload.org; RFC 6797, section 6.1.2
preload in a header the list would refuse, or has not got Info - hstspreload.org and its status API
Nothing controls framing Medium B MDN Observatory; OWASP clickjacking defence
X-Frame-Options value browsers do not honour Low - MDN, X-Frame-Options
X-Content-Type-Options: nosniff missing Low A MDN Observatory; OWASP HTTP headers
Referrer-Policy missing Low A MDN Observatory
Referrer-Policy: unsafe-url or no-referrer-when-downgrade Low A MDN Observatory, which scores both as leaking
Referrer-Policy value not recognised Low - Referrer Policy specification
Permissions-Policy entry that cannot be parsed Low - Permissions Policy specification, structured field syntax
A cookie without Secure (HTTPS pages) Medium - MDN Observatory; OWASP session management
A cookie without HttpOnly Low - MDN Observatory
A cookie without SameSite Low - MDN, SameSite
Server or X-Powered-By naming a version Low - OWASP HTTP headers cheat sheet
X-XSS-Protection switched on Low - OWASP HTTP headers cheat sheet, which recommends 0
Public-Key-Pins still sent Low - MDN, HPKP, withdrawn
Expect-CT still sent Info - MDN, Expect-CT, obsolete
Feature-Policy is the old name Info - MDN, Permissions-Policy
Cross-origin script tags without integrity Medium - Subresource Integrity; the polyfill.io incident
The cross-origin isolation headers Info - MDN Observatory, scored there since 1 September 2026 and information only here

Cookie findings name the cookie and never its value; the scanner cuts every Set-Cookie value to its name and attributes before the response is graded or stored.

HSTS is the one header on this page a site cannot take back in an afternoon. A browser holds the max-age it was handed until it expires or it sees the host again over HTTPS, so a year set before every asset and subdomain is ready locks out the visitors who already have it, for a year. A preload entry is worse: removal is a form submission followed by a wait, and hstspreload.org says the change reaches users with a Chrome release, takes months, and carries no guarantee about other browsers.

So the findings recommend one step, with the exact header line to serve, rather than the finished header:

Step max-age Duration
1 300 five minutes
2 604800 a week
3 2592000 a month
4 31536000 a year
5 63072000 two years

A site with no header, an unreadable one or max-age=0 starts at step 1. Any other value under a year is answered with the first step above it, keeping whatever directives are already set. A year or more raises nothing: it clears this grade and the preload list, and two years is a choice, not a fix. None of the advice changes a score: the only HSTS findings that cost anything are the three capped rows above, and a repeated header whose values disagree.

includeSubDomains is recommended once HSTS is in force, and never without the warning that goes with it: it makes every name under the host unreachable for the length of the max-age if any of them does not serve HTTPS, and one fetch of one URL cannot tell you whether they do. The finding says that rather than implying it checked, and the step it recommends is the directive at a short max-age first.

The scanner asks hstspreload.org’s status API about the host that answered, with a two-second timeout. It is information only, and like every finding it is only ever something wrong: a domain the maintainers removed or refused is reported, and so is a header that asks for preload when the domain is not on the list or would be refused. A domain on the list, one with a submission pending, and one that could be preloaded but has not asked to be raise nothing, because preloading is a choice with no quick way back. An outage means nothing is said about the list. It never fails a scan and it never changes a grade.

Eligibility is checked against the submission requirements as published on 2026-09-15: a valid certificate, an HTTP to HTTPS redirect on the same host, every subdomain over HTTPS, and the header on the registrable domain with a max-age of at least 31536000, includeSubDomains and preload. Two of those - the subdomains, and port 80 when the scan began at an https:// URL - cannot be decided from one response, and a finding about a header that asks for preload names them as unchecked rather than counting them either way.

Preloading applies to the registrable domain, so a scan of a subdomain says so. Which name that is comes from the Public Suffix List, private section included: a site on example.github.io controls that name and not github.io.

Up to five findings, ordered by how far fixing each one alone would raise the grade, then by the points it costs. A finding that would change nothing on its own is not an action, but a page whose grade is held by several findings of the same weight sees each of them with the points it costs.

Scripts a page adds at run time, pages behind a login, and a different policy on another path. The scan reads the HTML the server sent for one URL, arriving with no cookies.

Every result carries the rubric version it was graded with, stored with it and printed on the page. Changing a cap, a weight or a rule bumps the version, so a grade that moved can be told apart from a site that did.

Version 4 added the requested report types to Visibility. No letter or score moved with it; the version changed because a rule was added.

Version 5 stopped reporting good news as findings: a year or more of HSTS, a domain on the preload list or pending on it, and a domain that could be preloaded and has not asked to be now raise nothing. It also takes the reporting finding in Visibility from the page as a whole, so a <meta> policy that cannot carry report-uri no longer reads as “no reporting endpoint” on a page whose header reports. No letter or score moved.