Research

GitHub allows script from exactly one hostname.

They started with a wildcard and worked their way here.

Three posts across four years, from a permissive first policy in 2013 to a deny-by-default one today. They published what worked, and then published what it still couldn't stop.

2013
First policy shipped
1
Host allowed to run script
'none'
default-src today
The Evidence

One hostname

This is what github.com sends. The long allowlists are trimmed; the two lines that matter are not:

github.com — HTTP response header
content-security-policy: default-src 'none';
  base-uri 'self';
  script-src github.githubassets.com;
  style-src 'unsafe-inline' github.githubassets.com;
  form-action 'self' github.com gist.github.com …;
  frame-ancestors 'none';
  connect-src 'self' uploads.github.com api.github.com …

The whole of script-src is one hostname. Not 'self'. Not a nonce. No 'unsafe-inline', no 'unsafe-eval', no wildcard, no second source. On one of the largest applications on the web, exactly one origin is permitted to execute JavaScript, and it is not the origin serving the page.

Above it, default-src 'none' means every directive GitHub has not explicitly listed denies by default. That is the strongest baseline CSP offers, and it is the precise opposite of where they started.

Check it yourself. One command, no account, no special user agent:

Verify it yourself
curl -sI https://github.com/ | grep -i content-security-policy
The Beginning

Where they started

In April 2013, GitHub shipped their first policy. It looked like this:

github.com — the 2013 policy
default-src *;
script-src 'self' assets-cdn.github.com jobs.github.com
  ssl.google-analytics.com secure.gaug.es;
style-src 'self' assets-cdn.github.com 'unsafe-inline';
object-src 'self' assets-cdn.github.com;

default-src *. Everything permitted unless a more specific directive said otherwise, four sources allowed to run script including their own origin, and 'unsafe-inline' sitting in the styles. By the standard of the header at the top of this page it is barely a policy at all.

Here is GitHub's own assessment of it, written three years later:

“The policy was relatively simple, but substantially reduced the risk of XSS on GitHub.com.”

That is the most encouraging sentence in this research, and it is worth being blunt about why. A mediocre first policy was worth having. It was worth having immediately, it was worth having for three years while they improved it, and the improving is what got them to one hostname. The alternative — waiting until the policy is right before deploying it — produces nothing at all, for as long as it takes.

The 2013 work itself was mechanical rather than clever: the header went out through a Rails before-filter, inline configuration <script> tags became HTML data-* attributes, and inline event handlers were replaced by declarative data attributes read by a shared driver. The same shape of work Twitter and Dropbox describe, arrived at independently.

The Decision

Why they removed their own origin

The single most instructive change in GitHub's policy is one most teams never consider: in 2016 they took 'self' out of script-src, leaving only the asset CDN.

Allowing your own origin to serve script feels obviously safe. It is your domain. Their reasoning for dropping it anyway:

“There may be a forgotten JSONP endpoint that doesn't sanitize the callback function name. Or, another endpoint that serves user-influenced content with a content-type that could be sniffed by browsers as JavaScript.”

An application the size of GitHub has endpoints nobody remembers, and any one of them that reflects attacker-controlled text into a response a browser is willing to treat as JavaScript becomes a script source the moment 'self' is in the directive. GitHub had several. Their answer was to serve every piece of raw, user-supplied content from a separate domain entirely, which then let them delete 'self' from the policy rather than trying to audit their way to safety.

The rest of the 2016 tightening followed the same instinct — narrow the thing rather than police it:

img-src went from * to a named list

Every third-party image source removed, which matters more than it sounds — an image request is a perfectly good way to send data somewhere.

form-action restricted, and it broke OAuth

Their OAuth flow redirected to third-party sites, which the directive refused. The fix was to stop using a 302 and hand the browser a meta refresh instead, decoupling form submission from navigation — later optimised to a JavaScript redirect with the meta refresh as fallback.

A bug bounty submission got Flash running anyway

Browsers of the era checked only the first request in a redirect chain against the policy, so a redirect to a host that was not on the object-src list still executed. A browser bug, chained with a plugin, defeating a correctly written directive.

The Honest Part

They published the limits too

In January 2017 GitHub published a second post. Most of it is a catalogue of what their Content Security Policy could not stop.

They had commissioned Cure53 to answer a deliberately uncomfortable question: assuming an attacker gets content injection on GitHub.com, what can they still do? The findings were published rather than filed.

Dangling markup could exfiltrate CSRF tokens

An unclosed tag swallows the rest of the page into an attribute, and the resulting request carries the token off to an attacker's host. No script execution required, so no script-src to enforce.

Content injection short of execution still works

An attacker who cannot run JavaScript can still inject a form or manipulate existing markup, which is enough for a convincing phishing surface on a domain the user trusts.

Some tags had no defence at all

Cure53 pointed them at <option>, <xmp> and <plaintext> as ways to capture page content. <plaintext> in Chrome they could not defend against.

Any of that is quotable by someone arguing CSP is not worth deploying, so here is the sentence they wrote to introduce it:

“With a solid foundation provided by CSP, we opened up an internal issue titled ‘Defending against post-CSP exploitation.’”

Post-CSP. The post is about what comes after a policy that works, not about one that failed. Script execution — the attack CSP exists to stop — was off the table, which is why the interesting question had become what was left. They then closed the remaining gaps with per-form CSRF tokens, scoped so narrowly that a token for starring one repository cannot be used to star another, and same-site cookies.

And they kept the policy. Nine years later it is stricter than almost anyone's, which is the conclusion worth drawing from all of this: a team that documents exactly where its control stops, and then keeps the control, is giving you the strongest possible endorsement of it. Anyone telling you their product has no limits is telling you something else.

The Thread

They shared the code

secure_headers, the Ruby library that emits these headers, began at Twitter and is maintained at GitHub today. So did the engineer: Neil Matatall did this work at both companies and has spent a decade talking about it publicly.

His advice to anyone starting is the same conclusion GitHub's own history demonstrates — you will not write a perfect policy up front, so start permissive and ratchet it up over time. He also notes that 'strict-dynamic' makes deployments considerably easier, which is the route Dropbox and X took to the same destination.

Both of those companies published their deployment too. Three of the largest applications on the web, each explaining how they did it, none of them claiming it was quick.

Being Straight

What this page doesn't claim

Four things, before anyone else points them out.

The one-hostname claim is about script-src only

connect-src and child-src are long allowlists, not minimal ones. script-src is the directive that decides whether injected code runs, which is why it is the headline, but be precise about which claim is being made.

style-src carries 'unsafe-inline'

The script side is exceptionally tight and the style side is not. The asymmetry is real and you would find it in the header anyway.

This pattern does not transfer everywhere

Single-trusted-host works because GitHub can serve all of its JavaScript from one CDN. An application that cannot wants nonces and 'strict-dynamic' instead. GitHub's route is not the only correct one.

GitHub is not a Report URI customer

Nothing here is an endorsement. And CSP is not sufficient on its own — their 2017 post is the proof of that, and this page agrees with them rather than arguing.

The Point

What this means for you

Read the three posts in order and the method is unmistakable. Ship something imperfect. Watch what it reports. Tighten one directive. Repeat, for years.

Every turn of that ratchet has the same prerequisite: knowing what your pages actually load. GitHub could only remove 'self' from script-src because they knew which endpoints served user-influenced content and where their scripts really came from. Guess wrong at that step and you take down your own application in production.

Report-only mode is what makes the ratchet safe. Tighten the directive, deploy it alongside the enforcing policy, and real browsers tell you exactly what the stricter version would have broken — before it breaks anything.

That is the loop we run. You set the headers, the reports come to us filtered and grouped rather than raw, and you get to see what the next turn would cost before you make it. See how CSP reporting works →

The policy stays yours, enforced by every browser that visits, working whether or not you have a relationship with us. Nothing routes through us and nothing runs on your pages.

FAQ

Frequently asked questions

The whole of script-src is one hostname: github.githubassets.com. No 'self', no nonce, no 'unsafe-inline', no 'unsafe-eval', no wildcard. The policy opens default-src 'none', so every directive they have not listed denies by default. The page carries the command to check that yourself.

Because allowing 'self' turns any endpoint on your own domain into a potential script source. In their words, "there may be a forgotten JSONP endpoint that doesn't sanitize the callback function name", or an endpoint serving user-influenced content that a browser sniffs as JavaScript. GitHub had several such endpoints, so they served all raw content from a separate domain and dropped 'self' entirely.

It is the strongest thing on this page. In 2017 GitHub commissioned Cure53 to attack a hypothetical content injection on GitHub.com, published what got through - dangling markup exfiltrating CSRF tokens, content injection short of script execution - and then closed those gaps with per-form CSRF tokens and same-site cookies. They kept the policy. A team that documents exactly where its control stops and keeps the control is telling you the control works; a vendor claiming no limits is telling you something else.

No. GitHub took the single-trusted-host route because they can serve every script from one CDN. An application that cannot do that wants nonces and 'strict-dynamic' instead, which is the route Dropbox and X took. Both arrive at the same place: nothing executes unless you put it there deliberately.

No, and the asymmetry is worth knowing. style-src carries 'unsafe-inline', and connect-src and child-src are long allowlists rather than minimal ones. The one-hostname claim is about script-src specifically, which is the directive that decides whether injected code runs.

Their first policy shipped in April 2013 with default-src * in it, and GitHub still credited it with substantially reducing XSS risk. The tightening that removed 'self' from script-src was published in 2016, and the post about what remained came in 2017. Three years of public, incremental work - which is the realistic shape of a CSP deployment, and the reason to ship an imperfect policy now rather than a perfect one later.

Start permissive. Tighten from evidence.

One header, and real browser data on what your pages run — so every directive you narrow is a decision rather than a gamble.