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.
The shape of a grade
Section titled “The shape of a grade”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-Policysyntax, 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-tonames 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.
A letter first, and a score beside it
Section titled “A letter first, and a score beside it”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.
The caps
Section titled “The caps”| 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 policy rules
Section titled “The policy rules”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.
The header rules
Section titled “The header rules”| 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.
The HSTS ramp
Section titled “The HSTS ramp”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 preload list
Section titled “The preload list”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.
Next actions
Section titled “Next actions”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.
What a scan cannot see
Section titled “What a scan cannot see”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.
Versioning
Section titled “Versioning”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.