4.6 Security Policy Configuration
Right, let’s talk about locking this thing down. You’ve built a site, it looks fantastic, and now you’re thinking about putting it on the internet—that digital neighborhood where everyone’s a critic and some are just plain malicious. Hugo, being the sensible static site generator it is, gives you a few levers to pull to manage security headers via the hugo.toml file. This isn’t about server hardening (that’s on you and your hosting provider); this is about telling the user’s browser how to behave when it’s viewing your site. It’s a set of instructions, a policy, and getting it right is the difference between a robust site and a digital welcome mat for trouble.
The Security Headers: Your First Line of Defense
Think of security headers as the bouncers for your website. They don’t live on your server; they’re instructions sent to the visitor’s browser, telling it what it’s allowed to do. The most powerful of these bouncers is the Content Security Policy (CSP). A CSP is a glorious, frustrating, and absolutely essential whitelist that says, “Hey browser, only execute JavaScript or load styles from these specific places I trust. Ignore everything else.” This single header can neuter entire classes of attacks, like Cross-Site Scripting (XSS), because even if an attacker manages to inject a malicious script into your content, the browser will refuse to run it if it’s not on the approved list.
Configuring this in Hugo is straightforward because it’s just another header. You define it under the [security] section in your hugo.toml (or config.toml). Here’s a reasonably strict starting point for a simple site:
[security]
[[security.headers]]
# The bouncer for scripts and styles
for = '/*'
[security.headers.values]
X-Content-Type-Options = 'nosniff'
X-Frame-Options = 'DENY'
Referrer-Policy = 'strict-origin-when-cross-origin'
# This is the big one. The CSP.
Content-Security-Policy = '''
default-src 'self';
script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https://images.unsplash.com;
'''
Let’s break down that CSP directive because it’s the one that will cause you the most pain and give you the most gain:
default-src 'self';: The default rule. Everything must come from your own domain ('self'). A great baseline.script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net;: This allows scripts from your domain, inline scripts (a necessary evil for many analytics and embed codes, hence'unsafe-inline'), and from the jsDelivr CDN.style-src 'self' 'unsafe-inline';: Same deal for CSS.img-src 'self' data: https://images.unsplash.com;: Images can be from your site, embedded data URIs (data:), and Unsplash.
The Peril of unsafe-inline and How to Fix It
You noticed I used 'unsafe-inline', right? I winced typing it. It’s a gaping hole in your policy, but it’s often a practical necessity when you’re using third-party snippets (like Google Analytics’ old code) or Hugo’s built-in template functions that output inline <script> tags. The browser doesn’t care that you meant to put that inline script there; it just sees “inline script” and unless you say it’s okay, it gets blocked.
The modern, secure way to avoid this is to use a nonce (a number used once). You generate a random nonce for each page load and add it to your inline script tags and your CSP header. The browser checks that they match. Hugo can help with this! You can use the CSP nonce field in your hugo.toml to let Hugo inject the nonce for its internal scripts.
[security]
enableInlineShortcodes = true
# This tells Hugo to generate a nonce for its internal scripts
[security.csp]
nonce = "'nonce-{{ .CSPNonce }}'"
Then, in your template, you might have:
<script nonce="{{ .CSPNonce }}">
// Your custom inline script here. This will now be allowed because the nonce matches the one in the header.
console.log("This is allowed!");
</script>
Your CSP header would then change to be far more secure:
Content-Security-Policy = '''
default-src 'self';
script-src 'self' https://cdn.jsdelivr.net 'nonce-{{ .CSPNonce }}';
style-src 'self' 'unsafe-inline'; # We'll tackle styles next
img-src 'self' data: https://images.unsplash.com;
'''
See? No more 'unsafe-inline' for scripts. Just a cryptographically secure nonce that changes on every page load. Beautiful.
Environment-Specific Policies: Don’t Break Your Dev Flow
Here’s the kicker: a super strict CSP will absolutely break your site in development. Hugo’s live reload? That’s an inline script. Your local styles? Blocked. It’s a nightmare. This is where Hugo’s environments save the day. You can define a lax policy for development and a strict one for production.
First, set your environment. When you run hugo server, it defaults to development. When you build with hugo (or your CI/CD runs it), it uses production. You can check this in your config with {{ hugo.Environment }}.
Now, let’s use a conditional config. You can’t use logic in TOML itself, so we use the magic of Hugo’s config directory structure. Create a config directory in your project root. Inside it, create two files: production/config.toml and development/config.toml. Hugo will merge these with your main hugo.toml file, with environment-specific files taking precedence.
config/production/config.toml:
[security]
[[security.headers]]
for = '/*'
[security.headers.values]
Content-Security-Policy = '''
default-src 'self';
script-src 'self' https://cdn.jsdelivr.net 'nonce-{{ .CSPNonce }}';
style-src 'self';
img-src 'self' data: https://images.unsplash.com;
'''
config/development/config.toml:
[security]
[[security.headers]]
for = '/*'
[security.headers.values]
# A much looser policy for dev that allows Hugo's live reload to work
Content-Security-Policy = '''
default-src 'self';
script-src 'self' 'unsafe-inline'; # Allow all inline scripts in dev
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
'''
This is the professional way to do it. Develop in peace, deploy with strength. The key takeaway is this: your CSP will be a living document. You’ll add a new domain, see something break in the browser’s console, and update the policy. It’s a chore, but it’s the best chore you can do for your site’s security.