Security in Eleventy
Eleventy is a pure static site generator: every page is rendered to plain HTML at build time, so a finished site has no server runtime, no database, and almost nothing to attack.
Purely Static
Eleventy only ever outputs HTML, CSS, and JS — nothing running at request time. Just files on a CDN.
Escaped by Default
Output in .html templates is HTML-escaped automatically, blocking most XSS.
Secrets Stay at Build Time
process.env is read only during the Node build. Nothing reaches the browser unless you print it.
A Minimal Attack Surface
Because the output is just files, the whole class of server-side attacks simply does not apply.
A traditional CMS exposes a database, an admin login, and a stack of plugins that all run on every request — each one a potential entry point for SQL injection, remote code execution, or privilege escalation. An Eleventy site has none of that at runtime. Visitors receive pre-built files from a CDN, so there is no live application server to compromise.
Removed by static output
- • SQL injection — no database queries
- • Server RCE — no production runtime to execute on
- • Vulnerable CMS plugins — none installed
- • Admin-panel brute forcing — the Decap login is delegated off-site
Handled by Netlify
- • Automatic HTTPS / TLS certificates
- • DDoS protection at the edge
- • Distributed, cached delivery (no single server to take down)
- • Instant rollbacks to a previous deploy
Your build is the runtime. The one place code actually executes is the build — on your machine or in CI. Treat that environment as production: keep build secrets in CI, review every plugin, and never run a build from an untrusted source.
XSS Protection & the safe filter
Nunjucks escapes template output for you. The only common way to open an XSS hole is to opt out.
Any value you render with {{ }} in a Nunjucks template is HTML-escaped, so a string like <script> is shown as text instead of executed. The escape hatch is the safe filter — it outputs raw HTML, so only ever pass it content you trust or have sanitized.
{# userBio comes from a CMS or user-generated content, untrusted #}
{# ✅ Safe: Nunjucks HTML-escapes automatically #}
<p>{{ userBio }}</p>
{# ❌ Dangerous: `safe` outputs raw HTML, bypassing escaping #}
<p>{{ userBio | safe }}</p>When you genuinely need to render user-supplied HTML (a rich-text bio, Markdown from an untrusted source), sanitize it first. Add a filter in your config backed by a library like sanitize-html, then pipe through it before safe.
// .eleventy.js
const sanitizeHtml = require("sanitize-html");
module.exports = function (eleventyConfig) {
eleventyConfig.addFilter("sanitize", (html) => sanitizeHtml(html));
};Now <p>{{ userBio | sanitize | safe }}</p> renders the HTML raw, but stripped of scripts and event handlers first.
Environment Variables & Secrets
Eleventy runs in Node at build time, so process.env is available where you build — never in the browser.
You can read process.env in your config, in global data files, and in JavaScript templates — all of which run only during the build. None of it ships to visitors unless you explicitly print it into a page. The starter already relies on this pattern: it reads ELEVENTY_ENV (set to PROD on Netlify builds) to toggle minification. Use dotenv for local values and keep your .env file out of git.
# .env (add to .gitignore, never commit secrets)
API_TOKEN=secret_xxxxx # used at build time to fetch data
ANALYTICS_ID=G-XXXXXXX # safe to print into the pageA global data file in src/_data/ — alongside the kit's own client.js — can use a secret to fetch from an API while only the returned data — never the token — ends up in your pages:
// src/_data/products.js , runs at build time only
module.exports = async function () {
const res = await fetch("https://api.example.com/products", {
headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
});
// Only this returned data is rendered, the token never reaches the page
return res.json();
};Never print a secret into a template — even in a comment or a data- attribute. Anything you output ends up in the public HTML. Build-time secrets must stay in build-time code (config, _data, filters), never in the rendered page.
Forms & Dynamic Features Live Off-Site
With no server of your own, there is nothing to receive a form POST — so whole classes of attack are never yours to defend.
Because a finished Eleventy site is just files, dynamic work like contact forms, payments, or search is delegated to specialized providers — Netlify Forms, Formspree, Stripe, and the like. They handle spam filtering, validation, and CSRF on their own hardened infrastructure, keeping sensitive operations off your public site entirely.
<!-- A Netlify form, submissions are processed by Netlify, not by you -->
<form name="contact" method="POST" data-netlify="true">
<input type="hidden" name="form-name" value="contact" />
<label>Email <input type="email" name="email" required /></label>
<label>Message <textarea name="message" required></textarea></label>
<button type="submit">Send</button>
</form>Pick a reputable provider and let it do the hard part. By keeping submissions, payment details, and authentication on a maintained third-party platform, your site keeps a strong security posture without ever exposing a server.
The Decap CMS Admin
The one login in this kit lives at /admin/ — but the authentication never runs on your site.
The starter bundles Decap CMS so a client can edit blog posts. It looks like an admin panel, but it is just static HTML and JavaScript: it has no server, no database, and no password store of its own. Logins are delegated to DecapBridge, and approved edits are committed straight to your Git repository — which then triggers a fresh Netlify build. There is nothing on your public site to brute-force.
Why it stays safe
- • Auth handled by DecapBridge, not your site
- • Edits land as Git commits, gated by repo access
- • No CMS database or runtime to exploit
- • Content is re-validated on the next build
Your responsibilities
- • Limit who has write access to the repo
- • Treat editor accounts like deploy keys
- • Sanitize CMS-authored HTML before
safe - • Run
npm run remove-decapif unused
Because content authored in Decap is “trusted-ish” user input, the same escaping rules apply: render Markdown bodies normally, and if you ever pipe CMS HTML through safe, sanitize it first. Not building a blog? The kit's remove-decap script strips the admin entirely, shrinking the attack surface to nothing but static files.
Security Headers & CSP
A handful of HTTP headers hardens any static site — and you set them at your host.
Since Eleventy ships only files, response headers are configured where you deploy. This starter deploys to Netlify, which reads a _headers file from your output folder (drop it in src/ so it copies into public/, next to the kit's existing _redirects).
# src/_headers (copied into public/, served by Netlify)
/*
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: DENY
Permissions-Policy: geolocation=(), microphone=(), camera=()Prefer config over a copied file? The starter already ships a netlify.toml at the repo root — add a headers block to it:
# netlify.toml
[[headers]]
for = "/*"
[headers.values]
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
X-Frame-Options = "DENY"Content Security Policy
Eleventy does not generate CSP hashes for you, so the cleanest path is to avoid inline scripts and styles — then you can ship a strict Content-Security-Policy header (alongside the others above) without unsafe-inline. If you must use inline code, generate a hash or nonce and add it to the policy. A CSP is your strongest defense-in-depth layer against XSS, blocking unauthorized scripts even if one slips through.
Validate Data at Build Time
Wherever you pull in outside data — APIs, src/_data files, front matter — validate it before you trust it.
Eleventy assembles data from many sources at build time. Validating anything from an external source means a bad upstream response fails the build instead of shipping broken or unsafe markup. A schema library like zod works well inside a data file:
// src/_data/posts.js , validate external data before it's rendered
const { z } = require("zod");
const PostSchema = z.array(
z.object({
title: z.string().min(1),
slug: z.string().regex(/^[a-z0-9-]+$/),
body: z.string(),
})
);
module.exports = async function () {
const res = await fetch("https://cms.example.com/posts");
// Throws and fails the build if the shape is wrong
return PostSchema.parse(await res.json());
};Keep Dependencies Current
The biggest remaining risk for a static site is the npm packages it is built from.
Eleventy and its plugins are your supply chain — each one runs with full access during the build. Audit them regularly, keep a committed lockfile so builds are reproducible, and be deliberate about which plugins you add.
npm audit # report known vulnerabilities
npm audit fix # apply safe, compatible fixes
npm outdated # see which packages have newer versions
npm install @11ty/eleventy@latest # update Eleventy itself