From Brittle to Agile: A Modern Approach to CSP with NGINX, Lua, and Kubernetes

From Brittle to Agile: A Modern Approach to CSP with NGINX, Lua, and Kubernetes

Anshum ShankhdharJuly 8, 2025
Share this article From Brittle to Agile: A Modern Approach to CSP with NGINX, Lua, and Kubernetes From Brittle to Agile: A Modern Approach to CSP with NGINX, Lua, and Kubernetes From Brittle to Agile: A Modern Approach to CSP with NGINX, Lua, and Kubernetes

Table of Contents

    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.

    The Problem: The Fragility of SHA-Based CSPs

    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:

    • Third-Party Scripts: Vendors like Intercom update their scripts frequently for bug fixes and feature rollouts. You have no control over these changes.
    • Development Friction: Every time a script changes, the new hash must be calculated and the CSP header updated.
    • Deployment Overhead: This change often requires a full application redeployment to get the new NGINX configuration live.

    This creates a brittle, reactive cycle where security policy actively hinders development velocity.

    The Solution: Dynamic, Nonce-Based Security

    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.

    Our Architecture

    We will build a system where:

    1. Kubernetes ConfigMap: Stores the static CSP rules template.
    2. Docker & Startup Script: Packages our application and uses envsubst to inject the CSP template into our NGINX config at runtime.
    3. NGINX with Lua (OpenResty): Dynamically generates a nonce for each request, injects it into the CSP header and the HTML body, and serves the final result.

    Step-by-Step Implementation Guide

    Step 1: The ConfigMap – A Centralized Policy Template

    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.

    Step 2: The Startup Script – Runtime Configuration

    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;"
    

    Step 3: The NGINX Configuration – The Dynamic Core

    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;
    
        }
    
    }
    

    Step 4: The Frontend – Completing the Handshake

    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>
    

    Verification and Final Thoughts

    Once deployed, you can verify the implementation using your browser’s developer tools.

    1. Network Tab: Inspect the main document request. The Content-Security-Policy response header should contain a unique nonce- value on every refresh.
    2. View Source: The <script> tag in your HTML should have a matching nonce=”…” attribute.

    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.

      Talk to an Expert

      100% confidential and secure
      From Brittle to Agile: A Modern Approach to CSP with NGINX, Lua, and Kubernetes Anshum Shankhdhar

      DevOps Engineer | Professional Firefighter (of servers) Automating everything, breaking production, and pretending I know why it’s failing. Fluent in YAML, Bash, and last-minute debugging. If it ain’t in CI/CD, I don’t trust it.