Security in Astro
Astro is static-first: pages are pre-rendered to plain HTML at build time, so a finished site has no server runtime, no database, and almost nothing to attack.
Static by Default
No server, no database, no plugins running at request time — just HTML on a CDN.
Escaped by Default
Expressions in .astro templates are HTML-escaped automatically, blocking most XSS.
Secrets Stay Server-Side
Only PUBLIC_ variables reach the browser. Everything else never leaves the server.
A Minimal Attack Surface
When you build for static output, 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 Astro site built to static HTML 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 runtime to execute on
- • Vulnerable CMS plugins — none installed
- • Admin-panel brute forcing — no login to attack
Handled by your CDN host
- • Automatic HTTPS / TLS certificates
- • DDoS protection at the edge
- • Distributed, cached delivery (no single server to take down)
- • Instant rollbacks to a previous build
Using on-demand rendering (SSR)? If you add a server adapter for API routes, actions, or server-rendered pages, the sections below become essential — you reintroduce a runtime, so input validation and headers matter again.
XSS Protection & set:html
Astro escapes template expressions for you. The only common way to open an XSS hole is to opt out.
Any value you render with { } is HTML-escaped, so a string like <script> is shown as text instead of executed. The escape hatch is the set:html directive — it injects raw HTML, so only ever pass it content you trust or have sanitized.
---
const userBio = await getUserBio(); // untrusted input
---
{/* ✅ Safe: automatically HTML-escaped */}
<p>{userBio}</p>
{/* ❌ Dangerous: set:html renders raw HTML, bypassing escaping */}
<p set:html={userBio} />When you genuinely need to render user-supplied HTML (a rich-text bio, Markdown from an untrusted source), sanitize it first with a library like sanitize-html or isomorphic-dompurify.
---
import sanitizeHtml from "sanitize-html";
const clean = sanitizeHtml(userBio);
---
{/* ✅ Safe: rendered raw, but stripped of scripts and event handlers first */}
<p set:html={clean} />Environment Variables & Secrets
Astro uses the PUBLIC_ prefix to draw a hard line between what ships to the browser and what stays on the server.
Only variables prefixed with PUBLIC_ are inlined into the client bundle. Anything else is available on the server and at build time, but is never shipped to visitors. Keep your .env file out of git.
# .env (add to .gitignore, never commit secrets)
PUBLIC_ANALYTICS_ID=G-XXXXXXX # shipped to the browser
STRIPE_SECRET_KEY=sk_live_xxxxx # server-only, never sent to the clientFor type-safe variables with validation, use astro:env. Declaring a variable as access: "secret" guarantees it can never be imported into client code — the build fails if you try.
// astro.config.mjs
import { defineConfig, envField } from "astro/config";
export default defineConfig({
env: {
schema: {
STRIPE_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
PUBLIC_ANALYTICS_ID: envField.string({ context: "client", access: "public" }),
},
},
});Never put an API secret behind a PUBLIC_ prefix. Anything public is fully readable in the browser's bundled JavaScript — treat it as published the moment you deploy.
Built-in CSRF Protection
For on-demand routes, Astro can reject cross-origin form submissions out of the box.
When you handle form POST requests on the server, the security.checkOrigin option verifies that the request's Origin header matches your site before your handler runs — a simple, effective guard against CSRF. It is enabled by default for on-demand rendered pages, and you can set it explicitly:
// astro.config.mjs
import { defineConfig } from "astro/config";
export default defineConfig({
output: "server",
security: {
checkOrigin: true, // reject cross-origin form POST/PUT/PATCH/DELETE requests
},
});Security Headers & CSP
A handful of HTTP headers hardens any site — static or server-rendered.
On a static deploy, set response headers at your host. Netlify and Cloudflare Pages read a public/_headers file; Vercel uses vercel.json.
# public/_headers (Netlify / Cloudflare Pages)
/*
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=()For on-demand routes, set headers in middleware so they apply to every response:
// src/middleware.ts
import { defineMiddleware } from "astro:middleware";
export const onRequest = defineMiddleware(async (context, next) => {
const response = await next();
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set("X-Frame-Options", "DENY");
return response;
});Content Security Policy
Astro has built-in CSP support that automatically generates hashes for your inline scripts and styles — enable it in astro.config.mjs (via the csp option; check whether it is experimental in your Astro version). A CSP is your strongest defense-in-depth layer against XSS, since it blocks unauthorized scripts even if one slips through.
Validate Every Input
Wherever you accept data — forms, API routes, content — validate it with a schema before you trust it.
Astro Actions validate input with Zod before your handler ever runs, so the data you receive is already typed and checked. The same Zod-powered schemas back Content Collections, giving your Markdown and data files validation at build time too.
// src/actions/index.ts
import { defineAction } from "astro:actions";
import { z } from "astro:schema";
export const server = {
contact: defineAction({
accept: "form",
input: z.object({
email: z.string().email(),
message: z.string().min(1).max(2000),
}),
handler: async ({ email, message }) => {
// input is already validated and typed before it reaches here
await sendEmail(email, message);
return { ok: true };
},
}),
};Keep Dependencies Current
The biggest remaining risk for a static site is the npm packages it is built from.
Astro and its integrations are your supply chain. Audit them regularly, keep a committed lockfile so builds are reproducible, and be deliberate about which third-party integrations you add — each one runs with full access during the build.
npm audit # report known vulnerabilities
npm audit fix # apply safe, compatible fixes
npx @astrojs/upgrade # bump Astro + official integrations together