Content Security Policy headers are one of those things every web team knows they should have but often skips — the syntax is verbose, the directives are easy to misconfigure, and a wrong policy breaks the app silently in production. Writing a small generator in Go removes the human error and makes CSP a first-class part of your build.
Why CSP matters and where it fails
CSP is an HTTP response header that tells the browser which resources — scripts, styles, images, fonts — are allowed to load and from where. A well-crafted policy defeats cross-site scripting (XSS) attacks even when an attacker has already injected content into your HTML, because the browser simply refuses to execute disallowed scripts.
The problem is maintenance. Teams start with a permissive policy (default-src 'self'), then add exceptions for every CDN and vendor widget until the header looks like an allow-list for the entire internet. At that point the policy is longer than it is useful.
Building a generator means you describe intent — "scripts from our CDN only, no inline styles, report violations to this endpoint" — and get a validated, reproducible header string every time.
Designing the CSP struct
A CSP is a collection of directives. Each directive has a name (script-src, style-src, img-src, …) and one or more source values. In Go, the natural representation is a struct with typed fields:
package csp
import (
"fmt"
"strings"
)
type Policy struct {
DefaultSrc []string
ScriptSrc []string
StyleSrc []string
ImgSrc []string
FontSrc []string
ConnectSrc []string
FrameSrc []string
ObjectSrc []string
ReportURI string
UpgradeInsecureRequests bool
}
func (p Policy) Build() string {
var parts []string
add := func(name string, values []string) {
if len(values) > 0 {
parts = append(parts, fmt.Sprintf("%s %s", name, strings.Join(values, " ")))
}
}
add("default-src", p.DefaultSrc)
add("script-src", p.ScriptSrc)
add("style-src", p.StyleSrc)
add("img-src", p.ImgSrc)
add("font-src", p.FontSrc)
add("connect-src", p.ConnectSrc)
add("frame-src", p.FrameSrc)
add("object-src", p.ObjectSrc)
if p.UpgradeInsecureRequests {
parts = append(parts, "upgrade-insecure-requests")
}
if p.ReportURI != "" {
parts = append(parts, "report-uri "+p.ReportURI)
}
return strings.Join(parts, "; ")
}
This gives you a Build() method that emits a valid header value. Fields you leave nil simply do not appear in the output — no empty directives to confuse the browser.
Wiring it into a Go HTTP middleware
The generator is only useful if the header is actually sent. Here is a middleware for the standard net/http library that takes a Policy and attaches it to every response:
package csp
import "net/http"
func Middleware(p Policy) func(http.Handler) http.Handler {
header := p.Build()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", header)
next.ServeHTTP(w, r)
})
}
}
Usage in main.go:
policy := csp.Policy{
DefaultSrc: []string{"'self'"},
ScriptSrc: []string{"'self'", "https://cdn.example.com"},
StyleSrc: []string{"'self'", "'unsafe-inline'"},
ImgSrc: []string{"'self'", "data:", "https://images.example.com"},
ObjectSrc: []string{"'none'"},
ReportURI: "https://csp-report.example.com/collect",
UpgradeInsecureRequests: true,
}
mux := http.NewServeMux()
mux.HandleFunc("/", homeHandler)
http.ListenAndServe(":8080", csp.Middleware(policy)(mux))
The header value is computed once at startup, not on every request. At scale, that matters.
Handling nonces for inline scripts
'unsafe-inline' in script-src wipes out most of CSP's XSS protection. The correct alternative is nonces: a per-request cryptographically random value that must appear both in the header and on each <script> tag. The browser rejects any inline script that does not carry the matching nonce.
Nonces require the header to be generated per-request rather than once at startup:
package csp
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
)
type contextKey struct{}
func generateNonce() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(b), nil
}
func NonceMiddleware(p Policy) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nonce, err := generateNonce()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
adjusted := p
adjusted.ScriptSrc = append(
append([]string{}, p.ScriptSrc...),
fmt.Sprintf("'nonce-%s'", nonce),
)
w.Header().Set("Content-Security-Policy", adjusted.Build())
ctx := context.WithValue(r.Context(), contextKey{}, nonce)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// NonceFromCtx retrieves the nonce for use in HTML templates.
func NonceFromCtx(ctx context.Context) string {
v, _ := ctx.Value(contextKey{}).(string)
return v
}
In your HTML template, the nonce goes on each inline script tag:
<script nonce="{{ .Nonce }}">
// initialization code that must stay inline
</script>
With this in place you can drop 'unsafe-inline' from script-src entirely, which is the single most impactful change you can make to an existing CSP.
Testing the output
Before shipping, verify the generated header against known valid strings:
package csp_test
import (
"strings"
"testing"
)
func TestPolicyBuild(t *testing.T) {
p := Policy{
DefaultSrc: []string{"'self'"},
ObjectSrc: []string{"'none'"},
}
got := p.Build()
if !strings.Contains(got, "default-src 'self'") {
t.Errorf("missing default-src: %q", got)
}
if !strings.Contains(got, "object-src 'none'") {
t.Errorf("missing object-src: %q", got)
}
}
func TestEmptyFieldsOmitted(t *testing.T) {
p := Policy{DefaultSrc: []string{"'self'"}}
got := p.Build()
if strings.Contains(got, "script-src") {
t.Errorf("expected script-src to be omitted: %q", got)
}
}
Run with go test ./.... You can also paste the generated header into Google's CSP Evaluator to catch gaps — it flags missing object-src 'none', which would otherwise allow plugin injection even in modern browsers.
For a broader view of HTTP security headers and hardening steps that complement CSP, our free security hardening checklists cover CSP alongside HSTS, CORS, authentication controls, and more.
The takeaway
Writing CSP by hand does not scale. A typed Go struct enforces that every directive is deliberate, lets you unit-test the output, and makes policy reviews part of code review rather than an afterthought buried in Nginx config.
The nonce approach is non-trivial to wire up but pays off immediately: you remove 'unsafe-inline' from script-src, which is the most common reason CSP fails to prevent XSS in practice.
If you maintain multiple services, the next logical step is externalizing the policy definition to a JSON or YAML file loaded at startup — one source of truth, all services consistent, policy diffs visible in git.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)