Content Security Policy (CSP) is a critical layer in modern web security, acting as a primary defense against Cross-Site Scripting (XSS) attacks. But as applications evolve, a naive CSP implementation can quickly become a development bottleneck. If you’ve ever found yourself stuck in the “SHA loop”—constantly updating CSP hashes for every minor script change—this guide is for you.
This article provides a detailed, step-by-step walkthrough for implementing a dynamic, nonce-based CSP. We’ll leverage the power of NGINX with Lua, Docker, and Kubernetes to build a security posture that is both robust and agile.
For modern web applications that rely on inline scripts for analytics, UI initialization, or third-party integrations (like Intercom, Google Tag Manager, etc.), a static CSP often forces a difficult choice. The most common “solution” is to include a sha256 hash of the script in your script-src directive.
A SHA hash is a cryptographic fingerprint of the script’s exact content. If even a single byte changes, the hash becomes invalid.
This leads to a significant operational problem:
This creates a brittle, reactive cycle where security policy actively hinders development velocity.
A nonce (Number used ONCE) is a unique, random, and unpredictable string generated by the server for every single HTTP request. Instead of validating a script’s content (the hash), the browser validates that the script was given a “password” (nonce) that matches the one in the CSP header for that specific page load.
This approach completely decouples your security policy from the script’s content, allowing for rapid development and third-party script updates without sacrificing security.
We will build a system where:
We externalize the static part of our CSP into a Kubernetes ConfigMap. This makes it easy to manage without touching our application image. Notice the __NONCE__ placeholder.
configmap.yaml
YAML
apiVersion: v1
kind: ConfigMap
metadata:
name: my-app-csp-rules
data:
CONTENT_SECURITY_POLICY: "default-src 'self'; script-src 'self' 'nonce-__NONCE__
' https://some-trusted.domain; style-src 'self' 'unsafe-inline';"
This YAML defines our base policy. The nonce will be dynamically inserted where __NONCE__ is.
This shell script runs when the container starts. Its job is to bridge the gap between the Kubernetes environment (where the ConfigMap is mounted as an environment variable) and the static NGINX configuration file.
launch-openresty.sh
Bash
#!/bin/sh
# List of variables to be substituted
VAR_LIST='$CONTENT_SECURITY_POLICY,$SERVER_NAME'
# envsubst reads the template file and replaces variables with their
# environment variable counterparts, outputting a final config file.
envsubst "$VAR_LIST" < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf
# Start the server with the final configuration
exec /usr/local/openresty/bin/openresty -g "daemon off;"
This is where the magic happens. We use OpenResty (NGINX + Lua) to handle requests dynamically. Note that we now have a default.conf.template file.
default.conf.template
Nginx
# This block runs ONCE at server startup to prepare our function.
init_by_lua_block {
-- This function generates a cryptographically secure nonce.
function generate_nonce()
local f = io.open("/dev/urandom", "r")
if not f then return ngx.md5(ngx.now() .. math.random()) end
local random_bytes = f:read(16) -- 128 bits of entropy
f:close()
-- Encode and clean for use in an HTML attribute
local encoded = ngx.encode_base64(random_bytes)
encoded = string.gsub(encoded, "[%+/=]", "")
return encoded
end
}
server {
listen 80;
server_name $SERVER_NAME; # Placeholder for startup script
root /usr/local/openresty/nginx/html;
# Other server configs…..
# This location block intercepts requests for our main HTML file.
location = /index.html {
# This block runs PER-REQUEST.
content_by_lua_block {
-- 1. Generate a unique nonce for this request
local nonce = generate_nonce()
-- 2. Fetch the CSP template provided by the startup script
local csp_template = "$CONTENT_SECURITY_POLICY"
-- 3. Inject the nonce into the CSP template
local final_csp_header = string.gsub(csp_template, "__NONCE__", nonce)
-- 4. Set the final CSP header in the HTTP response
ngx.header["Content-Security-Policy"] = final_csp_header
-- 5. Read the index.html file from disk
local file = io.open("/usr/local/openresty/nginx/html/index.html", "r")
if not file then ngx.exit(ngx.HTTP_NOT_FOUND) return end
local body = file:read("*a")
file:close()
-- 6. Inject the same nonce into the HTML body
body = string.gsub(body, "__CSP_NONCE_PLACEHOLDER__", nonce)
-- 7. Serve the modified HTML to the user
ngx.print(body)
}
}
location / {
try_files $uri $uri/ /index.html;
}
}
Your frontend code must include the placeholder that the Lua script looks for.
index.html (snippet)
HTML
<script nonce="__CSP_NONCE_PLACEHOLDER__">
// Your inline script that needs to be trusted
console.log("This script is now trusted by CSP!");
</script>
Once deployed, you can verify the implementation using your browser’s developer tools.
By moving from a static, hash-based CSP to this dynamic, nonce-based architecture, you’ve created a system that is not only more secure by allowing you to remove ‘unsafe-inline’, but also vastly more maintainable and developer-friendly. You have successfully broken the “SHA loop” and enabled your team to ship features faster without compromising on security.