No unsafe-inline. No unsafe-eval. Nonces on scripts and styles.
login.gov is the single sign-on service for US federal services, and it is built in the open. So you can read its Content Security Policy as code, and every change ever made to it, rather than inferring it from a header.
This is the full enforced policy on secure.login.gov. Nothing is trimmed, because there is nothing to trim:
content-security-policy: default-src 'self'; child-src 'self'; form-action 'self'; block-all-mixed-content; font-src 'self' data: https://secure.login.gov; img-src 'self' data: login.gov …; media-src 'self'; object-src 'none'; base-uri 'self'; style-src 'self' https://secure.login.gov 'nonce-640d05ab659d…'; script-src 'self' https://secure.login.gov https://www.google.com/recaptcha/ … 'nonce-640d05ab659d…'; connect-src 'self' www.google-analytics.com
Read it twice and notice what is absent. There is no 'unsafe-inline' and no 'unsafe-eval', anywhere, in any directive. Across this whole body of research that is the only production policy of which that is true — and set against our crawl of the top million sites, where 46.8% of policies carry unsafe-inline, it puts login.gov in a very small minority.
There is a nonce on both script-src and style-src. Most deployments get the script side under control and quietly give up on styles, because inline styles are scattered through templates and frameworks and nobody wants to chase them. Doing both is a decision somebody had to see through.
And then there is form-action 'self', which deserves its own sentence on an identity provider. It constrains where a form on that page is allowed to post to. It is the directive standing between an injected login form quietly shipping someone's credentials to another origin and the browser simply refusing to send them.
Check it yourself:
curl -sI https://secure.login.gov/ | grep -i content-security-policy
Every other page in this research quotes a header and, where the team wrote one, a blog post. This one can point at the code.
login.gov is developed in the open at 18F/identity-idp, and the policy lives at config/initializers/content_security_policy.rb. The repository is not an archive: it was last pushed to the day before this research was done.
It is worth sitting with what that means. For almost every organisation, a security control is something you infer from the outside — you read the header, you form a view, and the reasoning behind it is private. Here the control is a file. You can read what each directive is built from, see that the asset host comes from configuration rather than being pasted in, and follow the commit history to find out when each decision was taken and what it replaced.
A policy you can read the history of is a policy somebody has been maintaining. A policy you can only read the header of might have been set once, years ago, by someone who has since left.
unsafe-eval is one line of codeThe most quotable thing in that file is a single line:
script_src = [:self, IdentityConfig.store.asset_host.presence].compact
script_src << :unsafe_eval if !Rails.env.production?
The dangerous keyword is added for local development and excluded from production by the code itself. Not by a policy document, not by a checklist, not by someone spotting it in review: shipping it is structurally impossible without editing that line and defending the edit.
The same file does the same thing for a webpack development server port and for mailer previews — each added conditionally, none of them ever in production. That is also the answer to the objection that a strict policy makes development miserable. It does, if the production policy is the one you develop against. Here the developer affordances exist, they are written down, and they cannot escape into production.
Twelve commits have touched that policy file. The earliest, from January 2022, is titled “Replace SecureHeaders CSP tooling with built in Rails tooling”.
Which means that before 2022, the login page for US federal services was running its Content Security Policy on secure_headers — the Ruby library that began at Twitter in 2013 and is maintained at GitHub today.
One library, written to solve one company's problem, ending up in another company's hands and then underneath a government identity provider. It is a small thing, but it is the clearest illustration in this research that this is one shared body of practice rather than a series of unrelated deployments.
The first one matters more than the rest.
It is the exception rather than the standard, and pretending otherwise would be the sort of claim this research exists to avoid. The table below is what the rest of the estate actually sends.
script-src is not a pure nonce policyAlongside 'self' and the nonce it allows reCAPTCHA and Google Analytics hosts. Clean is not the same as minimal.
No report-uri and no report-to. When that policy blocks something, nobody is told. We have an obvious interest in pointing that out, so take it as the observation it is rather than a criticism of a team that has clearly thought about this harder than most.
Nothing here should be read as US government endorsement of us or of anything else. The page exists because the policy is public, not because anybody is a customer.
Here is the rest of the estate. Every row was read from a live response with a real browser user agent, following redirects:
| Federal site | Content Security Policy |
|---|---|
secure.login.gov | Enforced, strict, nonce-based |
www.cisa.gov | Report-only, reporting to its own endpoint |
www.whitehouse.gov | upgrade-insecure-requests and frame-ancestors only |
www.ssa.gov | report-uri and upgrade-insecure-requests only |
www.irs.gov | None |
www.va.gov | None |
www.healthcare.gov | None |
cloud.gov | None |
www.usa.gov | None |
www.nasa.gov | None |
Two rows repay a second look. The federal cybersecurity agency runs its policy in report-only with reporting switched on, which is exactly the position GOV.UK spent years in before enforcing. And www.ssa.gov sends an enforced header containing a reporting endpoint and upgrade-insecure-requests — and nothing else. There are no directives in it to violate, so the endpoint has nothing to receive. It is a good illustration of how easily a policy ends up present but not doing anything.
The transferable lesson is not “be a government”. It is where the policy lives.
login.gov's Content Security Policy is a file in the application, so a change to it is a commit: reviewed, attributed, dated, revertible, and visible to everybody who works on the service. Typed into a CDN console instead, the same change is invisible — no diff, no reviewer, and no way to answer “when did this get weaker, and who did it?” six months later.
Then the second half, which login.gov has deliberately not done. Their policy sends no reports, so when it blocks something, nobody hears about it. That is a defensible choice for a service with one carefully controlled login page and a team that reads its own source. It is a much harder choice for a site with marketing tags, embedded video, a payment provider and four teams shipping to it.
An enforcing policy with no reporting still protects your users. It just never tells you what it stopped, whether the thing it stopped was an attack or your own new checkout script, or that someone weakened it last Tuesday.
That is the half we run. You keep the policy in your own code, exactly as they do, and the reports come back filtered and grouped so you can see what it is actually doing. See how CSP reporting works →
Keep the header in your own application, and get the reports back filtered enough to read.