Allowing a single inline script under Content Security Policy with a sha256 hash

Permit one inline script without unsafe-inline: compute the hash on the server, add it to the header and lock it with a test.

Author: TS SystemLabs engineering team · Published: · 8 min read

Content Security Policy (CSP) is an HTTP header that tells the browser which sources are allowed to provide scripts, styles, images and frames for a page. Set up properly, it greatly reduces the impact of cross-site scripting (XSS) bugs: even if an attacker manages to inject a <script> fragment into the page, the browser refuses to run it. The trouble is that almost no real-world site consists purely of external files. An analytics snippet, a cookie consent default or a small configuration line often has to be written straight into the page as an inline script.

At that point many teams take the shortcut and add 'unsafe-inline' to the policy. That switches off the most valuable part of the protection CSP offers against XSS. In this article we describe a narrower path: compute the SHA-256 hash of the inline script's content and allow exactly that content and nothing else. The examples come from this site's own code, where we need to run the Google Consent Mode v2 default inline at the very top of the page.

Why an inline script at all?

With Consent Mode v2, the default state of the advertising and analytics permissions should be declared before any Google tag loads. Google's Consent Mode documentation asks for the default command to run before any tag on the page, and the most reliable way to guarantee that is an inline snippet at the top of <head>. You could do it from an external file, but then the ordering depends on that file loading. If an external file arrives late because of network latency or a caching problem, the default arrives late too. Embedding a few lines of script whose content never changes is therefore a sensible choice, and the fact that the content is fixed is exactly what makes the hash approach work.

How the hash method works

Since CSP Level 2, the script-src directive accepts source expressions of the form 'sha256-…', 'sha384-…' or 'sha512-…'. The browser takes the text of every inline <script> element on the page, hashes it with the chosen algorithm, encodes the result in Base64 and compares it with the list in the policy. A matching script runs; a non-matching one is blocked and a violation is logged to the console.

Two details matter here:

  • The hash is computed over the entire text between the <script> and </script> tags. A leading space, a trailing newline or a different quote character changes the hash.
  • The text is converted to bytes using the page's character encoding (UTF-8 in practice) before hashing. Even a comment containing non-ASCII characters counts.

Calculating the hash once by hand and pasting it into the header is therefore fragile. Someone reformats the script, adds a space, and the script silently starts being blocked. The more robust approach is to produce the script text and its hash from the same source, in code.

Generating the script and its hash from one source

On the Python side we keep the script as a constant and compute its hash when the module loads. The template prints the constant verbatim, and the code that builds the CSP takes the hash from the same module. If the text changes, the hash changes with it automatically.

import base64
import hashlib

CONSENT_DEFAULT_JS = (
    "window.dataLayer=window.dataLayer||[];"
    "function gtag(){dataLayer.push(arguments);}"
    "gtag('consent','default',{ad_storage:'denied',ad_user_data:'denied',"
    "ad_personalization:'denied',analytics_storage:'denied',wait_for_update:500});"
)

def csp_hash(text: str) -> str:
    digest = hashlib.sha256(text.encode("utf-8")).digest()
    return "'sha256-" + base64.b64encode(digest).decode("ascii") + "'"

CONSENT_DEFAULT_HASH = csp_hash(CONSENT_DEFAULT_JS)

Keeping the script as a single line without surrounding whitespace is deliberate: in the template we leave no space between the opening <script> tag and the text, or between the text and the closing tag. Splitting it into pieces in the source for readability is fine; Python joins adjacent string literals into one string at compile time.

The template engine's escaping trap

Template engines such as Jinja apply automatic escaping when they print variables into HTML. That is a very valuable default against XSS, but in our case it breaks the script. The single quotes in the text above become &#39;. Because the browser does not decode HTML entities inside <script>, the JavaScript fails with a syntax error, and on top of that the hashed text is no longer the text Python hashed.

So we print the constant without escaping. We do this only for a constant that is entirely under our control and carries no user input; printing any value that comes from a user without escaping would create exactly the hole CSP is meant to close.

<head>
  <meta charset="utf-8">
  <script>{{ consent_default_js|safe }}</script>
  <script src="/static/js/google-etiket.js"></script>
  ...
</head>

Adding the hash to the header

We usually build the CSP header in one place, in an after_request hook. Because a policy can contain several directives, the hash has to be appended to an existing script-src directive, or a new directive has to be created if there is none. A simplified version looks like this:

def add_to_script_src(csp: str, *sources: str) -> str:
    directives = [p.strip() for p in csp.split(";") if p.strip()]
    for i, directive in enumerate(directives):
        parts = directive.split()
        if parts[0] == "script-src":
            parts += [s for s in sources if s not in parts]
            directives[i] = " ".join(parts)
            return "; ".join(directives)
    directives.append(" ".join(("script-src", "'self'") + sources))
    return "; ".join(directives)

@app.after_request
def security_headers(response):
    csp = "default-src 'self'; object-src 'none'; base-uri 'self'"
    response.headers["Content-Security-Policy"] = add_to_script_src(csp, CONSENT_DEFAULT_HASH)
    return response

One point to watch: when there is no script-src, the browser falls back to default-src. Once you add a script-src directive, default-src no longer applies to scripts, so the new directive must explicitly list 'self' and every external script source you rely on.

An honest note: the policy currently live on this site does not yet restrict script sources; it only blocks framing, <object> usage and <base> hijacking. The function that appends the hash is ready to kick in as soon as the policy contains a script-src. That way the inline default will not break on the day we tighten script sources.

When the hash and 'unsafe-inline' appear together

For compatibility with older browsers, some teams put both a hash and 'unsafe-inline' in the policy. Browsers that support CSP Level 2 or later ignore 'unsafe-inline' when they see a hash or nonce in the list; very old browsers do not understand the hash and run the script because of 'unsafe-inline'. This is a known backward-compatibility technique, but be clear about what it means: in modern browsers the protection comes only from the hash, and in old browsers there is no protection at all. Decide with your audience in mind.

Nonce or hash?

The other way to permit an inline script is a nonce: you generate a random value on every request and write it both into the header and into a <script nonce="…"> attribute. A nonce suits scripts whose content changes from request to request, but it makes caching the page harder, because each response has to carry a different value. For scripts with fixed content, the hash approach stays simpler: the response can be cached and the hash only changes when the code does.

Our rule of thumb: fixed content gets a hash, request-dependent content gets a nonce. Using both in the same policy is also possible.

Locking it in with a test

Checking the hash by eye in the browser is not enough; the next edit can break everything. So we write a test that fetches the page with the test client, recomputes the hash of every inline script it contains and checks that each one is present in the header:

import base64
import hashlib
import re

def test_inline_scripts_are_in_csp(client):
    response = client.get("/")
    html = response.get_data(as_text=True)
    csp = response.headers.get("Content-Security-Policy", "")
    scripts = re.findall(r"<script>(.*?)</script>", html, re.S)
    assert scripts, "no inline script found"
    for text in scripts:
        digest = base64.b64encode(hashlib.sha256(text.encode("utf-8")).digest()).decode()
        assert f"'sha256-{digest}'" in csp

The regular expression only matches <script> tags without attributes, so external scripts with a src attribute and type="application/ld+json" data blocks are out of scope. JSON-LD blocks are data blocks rather than executable scripts, so script-src does not block them; we still recommend confirming that in the browser under your own policy.

Before going live: Report-Only

Switching a strict policy on directly can break a forgotten script in production. That is what the Content-Security-Policy-Report-Only header is for: the browser does not enforce the policy, it only reports violations. Running in this mode for a few days and reviewing the violations in the browser console and at your reporting endpoint is the safest step before enforcing the real policy. If you want to verify a hash from the command line, this command prints the hash of the script text stored in a file:

printf '%s' "$(cat script.js)" | openssl dgst -sha256 -binary | openssl base64

Using printf '%s' matters here: echo appends a newline and changes the hash. Command substitution $(…), on the other hand, strips trailing newlines from the file; that gives the right result for text that is printed into the template without a trailing newline. Whichever route you take, make sure you hash content that is byte-for-byte identical to what the template prints.

Conclusion

You do not have to enable 'unsafe-inline' to allow an inline script. Keeping a fixed-content script as a single constant in code, computing its hash in the same place, printing it unescaped in the template without mixing in any other variable, and locking all of this with a test gives you a setup that is both secure and easy to maintain. It also lets a default with legal weight, such as cookie consent, run at the very top of the page without delay. We cover the consent side in detail in our Consent Mode v2 article.

Building something similar?

Describe what you need in a few sentences; if there is a good fit, we will work out the details together.

Write to us