DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

Implementing Content Security Policy (CSP) Generation with Go

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, "; ")
}
Enter fullscreen mode Exit fullscreen mode

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)
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

In your HTML template, the nonce goes on each inline script tag:

<script nonce="{{ .Nonce }}">
  // initialization code that must stay inline
</script>
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)