Security
The Content Security Policy, response headers, CSRF protection, production checks and secrets handling that ship enabled.
Content Security Policy#
Astro generates the CSP (security.csp in astro.config.ts) with a SHA-256 hash for every
script and style it emits, so no 'unsafe-inline' is needed for scripts. The resulting policy
looks like this:
default-src 'self'; img-src 'self' data: blob: https:; font-src 'self' data:;
connect-src 'self'; media-src 'self'; worker-src 'self' blob:; manifest-src 'self';
object-src 'none'; base-uri 'self'; form-action 'self';
script-src 'self' 'wasm-unsafe-eval' 'sha256-…';
style-src-elem 'self' 'sha256-…'; style-src-attr 'unsafe-inline'
Two deliberate relaxations:
'wasm-unsafe-eval'allows Pagefind’s WebAssembly search index.style-src-attr 'unsafe-inline'allows thestyleattributes Shiki uses for syntax highlighting.<style>elements remain hash-protected. For a fully strict policy switchmarkdown.syntaxHighlightto'prism'and remove the attribute source.
On Vercel, Netlify and Node (staticHeaders: true) the policy is sent as a real header for
prerendered pages; on Cloudflare it is injected as a <meta http-equiv> element. Server-rendered
pages always receive the header.
CSP is disabled in astro dev, so verify changes with pnpm build && pnpm preview. The e2e
suite fails if any page logs a CSP violation.
Response headers#
config/security-headers.ts is the single source of truth for the remaining headers:
| Header | Value |
|---|---|
Strict-Transport-Security |
max-age=63072000; includeSubDomains |
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
camera, microphone, geolocation and others disabled |
Cross-Origin-Opener-Policy |
same-origin |
They are applied by src/middleware.ts to every on-demand response, by public/_headers for
static assets on Netlify and Cloudflare, and by integrations/security-headers.ts, which adds
them to the Vercel Build Output configuration. A unit test keeps _headers and the TypeScript map
in sync. Add preload to HSTS only after submitting the domain to the preload list.
CSRF and origins#
Astro’s security.checkOrigin rejects cross-origin POST, PUT, PATCH and DELETE requests
to on-demand routes. Better Auth validates trustedOrigins independently, and cookies are
SameSite=Lax.
Production configuration checks#
assertProductionConfig() (src/lib/env.ts) runs once per server instance from the
middleware when NODE_ENV=production, never at build time. A missing or short
BETTER_AUTH_SECRET makes on-demand requests fail with a clear error instead of running with
a default secret; a file database, missing email provider or missing contact recipient are
logged as warnings and reported by /api/health. Anonymous callers of that route only get
status and time; the configuration details need an admin session or
Authorization: Bearer <HEALTH_TOKEN>, so the route does not describe the deployment to
everyone.
Abuse controls and audit#
- Better Auth rate limits every auth endpoint with database storage; sign-in, sign-up, magic link and password endpoints have stricter built-in limits.
- The contact form is throttled per address and per IP (
src/lib/throttle.ts) and protected by a honeypot. - Sign-in links, verification links and reset tokens are bearer credentials. Without
RESEND_API_KEY,sendEmail()prints messages only outside production; in production it throws and the login page hides the magic-link form and the reset link (see authentication). - Administrative actions, account deletions and data exports are recorded in
audit_log. Message status changes and deletions share a transaction with their entries; everything else is best-effort and a failed write is logged (see the admin area). - Access decisions (
/admin, the admin actions, the account export) read the session from the database instead of the cookie cache, so demotion, bans and “sign out everywhere” apply immediately (see sessions and cookies). The last active administrator cannot be removed. - Admin pages never change data while rendering: marking a message read is a POST action, so link prefetching and previews cannot alter state.
Secrets#
- Secrets are read on the server only (
src/lib/env.ts); the client bundle can only see variables declared withcontext: 'client'inenv.schema, which must start withPUBLIC_. .envfiles are ignored by Git..env.exampledocuments every variable.- CI runs
pnpm audit --audit-level=highand Dependabot opens upgrade pull requests weekly.
Reporting vulnerabilities#
public/.well-known/security.txt and SECURITY.md describe how to report issues. Update the
contact details before launching.