Hosting
Every Anvil site component deploys the same way on AWS: static assets in S3, a CloudFront distribution in front, and your framework's server running on a Lambda behind it. Because the shape is shared, so is everything on this page — server protection, security headers, WAFs and origin protection work identically for every framework. Each framework's own page covers only what's specific to it.
- SvelteKitSite — Deploy SvelteKit to AWS in one component: S3, CloudFront, Lambda, HTTPS and DNS. Security headers on, form actions working, WAF ready.
Server protection
The server Lambda is reached through a Lambda Function URL — a real HTTPS
endpoint. protection controls who can call it. While it's public, anyone who learns
the URL can skip CloudFront and everything on it: the WAF, rate limits, caching and geo
restrictions.
| Mode | Server URL | Forms, POSTs, webhooks |
|---|---|---|
none | Public | Work |
oac | Locked to CloudFront (IAM) | Rejected — read-only sites only |
edge-oac | Locked to CloudFront (IAM) | Work — a Lambda@Edge function adds the required header; nothing changes in your app |
The default is none. When a WAF is attached (waf) or originProtection is on, the default becomes edge-oac, so the server
can't be used to go around them, and the deploy prints a message saying so. An explicit protection always wins.
This follows AWS's documented approach: Restrict access to a Lambda function URL origin (CloudFront Developer Guide) and Protecting a Lambda function URL with CloudFront and Lambda@Edge (AWS Compute Blog).
Good to know with edge-oac
- Calling the Function URL directly returns 403 from AWS before your code runs — no invocation, no cost.
- Request bodies over 1 MB are rejected with a 413 (a Lambda@Edge limit). Upload large files directly to S3 with a presigned URL.
- Adds about $0.60 per million server requests and a few milliseconds. Static assets aren't affected.
- Query parameter names are percent-encoded at the edge — AWS rejects raw special characters in
names on a locked Function URL — so
?/savereaches your server as?%2Fsave. Parsed parameters are unchanged; only code reading the raw query string sees the difference. - The browser's
Authorizationheader is replaced by CloudFront's signature. Send bearer tokens to your own server in another header (e.g.x-auth-token). Cookie sessions are unaffected. - Destroying the stack or switching away takes a few extra minutes while AWS removes the edge function from every location.
- Switching mode on a live site can cause a few minutes of 403s while CloudFront updates.
- Webhooks and third-party callbacks work, but if you'd rather keep them off the site entirely,
an
HttpApiis a separate endpoint unaffected by site protection.
With none
- Don't trust forwarded headers (
X-Forwarded-For,CloudFront-Viewer-*) for security decisions — they can be forged on direct requests. - Direct traffic is billed and can exhaust your account's Lambda concurrency.
- Security scanners flag public Function URLs.
Security headers
Every site sends browser security headers on every response — pages, API responses and static assets. They're on by default, added by CloudFront, and change nothing visible for visitors: they switch on protections the browser already has.
| Header | Protects against |
|---|---|
Strict-Transport-Security (1 year) | Downgrade to plain HTTP on hostile networks — after one visit, browsers always use HTTPS |
X-Frame-Options: SAMEORIGIN | Clickjacking — another site framing yours and tricking users into clicks |
X-Content-Type-Options: nosniff | Browsers guessing a file is a script and running it |
Referrer-Policy: strict-origin-when-cross-origin | Full URLs (tokens, IDs in query strings) leaking to other sites |
Permissions-Policy: camera=(), microphone=(), geolocation=(), usb=(), browsing-topics=() | Injected scripts or embedded third parties using camera, microphone, location or USB |
Cross-Origin-Opener-Policy: same-origin-allow-popups | Other sites getting a handle on your window (tab-nabbing, cross-window leaks) — sign-in and payment popups keep working |
Why they're on by default: missing security headers are one of the most common findings in pentests and SOC 2 audits, and they cost nothing at the edge. Hosting platforms like Vercel and Netlify set similar defaults; infrastructure tools usually leave it to you. It's defence in depth — it limits damage when something else goes wrong, not a replacement for secure app code.
new anvil.aws.SvelteKitSite('web', {
path: 'web',
// omitted → on, with the defaults above
securityHeaders: {
frameOptions: 'deny', // 'sameorigin' (default) | 'deny' | 'none'
hsts: { includeSubDomains: true, preload: true }, // opt-in, hard to undo
permissionsPolicy: 'camera=(self), microphone=(self)', // allow features you use
crossOriginOpenerPolicy: 'same-origin', // stricter; breaks OAuth/payment popups
},
// securityHeaders: { enabled: false }, // turn them off
});anvil.aws.SvelteKitSite("web",
path="web",
security_headers=anvil.aws.SiteSecurityHeadersArgs(
frame_options="deny",
hsts=anvil.aws.SiteHstsArgs(include_sub_domains=True, preload=True),
permissions_policy="camera=(self), microphone=(self)",
),
)anvilaws.NewSvelteKitSite(ctx.PulumiCtx(), "web", &anvilaws.SvelteKitSiteArgs{
Path: pulumi.String("web"),
SecurityHeaders: &anvilaws.SiteSecurityHeadersArgs{
FrameOptions: anvilaws.SiteFrameOptionsDeny,
Hsts: &anvilaws.SiteHstsArgs{IncludeSubDomains: pulumi.Bool(true), Preload: pulumi.Bool(true)},
PermissionsPolicy: pulumi.String("camera=(self), microphone=(self)"),
},
})- Headers your app sets itself always win — an app with its own policy is never overruled.
- Using the camera, microphone, location or USB? Allow them with
permissionsPolicy, e.g.'camera=(self), microphone=(self)'— your value replaces the whole default. Otherwise the browser refuses the feature without asking the user. - Payments work by default.
paymentisn't blocked, so Apple Pay and Google Pay buttons (e.g. Stripe) keep working. - Keep the default
crossOriginOpenerPolicyunless you're sure:'same-origin'breaks "Sign in with Google/Apple" and payment popups that report back to the page. - Values are checked before deploy — e.g. a
permissionsPolicywith a line break is rejected, so a value can't smuggle in other headers. - Audits: web scanners and pentests (OWASP ZAP, Burp, securityheaders.com) check these headers and report missing ones as findings. AWS Security Hub doesn't check response headers.
- Content-Security-Policy is bring-your-own. Anvil doesn't send one — a useful CSP depends on what your app loads and needs per-request nonces, so configure it in your framework (see your framework's page). Start in report-only mode to find what it would block.
- Sites embedded on other domains (widgets, partner pages) need
frameOptions: 'none'; otherwise the browser refuses with "Refused to display … in a frame because it set 'X-Frame-Options' to 'sameorigin'". - Not in local dev: the headers come from CloudFront, so they only apply to the deployed site. Test embeds against a deployed stage.
- HSTS is sticky for a year.
hsts.includeSubDomainsandhsts.preloadextend it to every subdomain and are hard to undo — off by default; enable them deliberately.
WAF
Create a Waf (default scope cloudfront) and attach it with waf: { arn }. One WAF can protect several sites. Protection defaults to edge-oac while it's attached, so the WAF can't be bypassed through the Function URL.
A new WAF starts in count mode — check its logs for false positives, then set mode: 'block'. A WebACL attached outside Anvil (console or another stack) is replaced
on the next deploy.
const waf = new anvil.aws.Waf('edge'); // count mode until you set mode: 'block'
const site = new anvil.aws.SvelteKitSite('web', {
path: 'web',
waf: { arn: waf.arn }, // protection defaults to "edge-oac"
});waf = anvil.aws.Waf("edge") # count mode until you set mode="block"
site = anvil.aws.SvelteKitSite("web",
path="web",
waf=anvil.aws.SiteWafArgs(arn=waf.arn), # protection defaults to "edge-oac"
)waf, err := anvilaws.NewWaf(ctx.PulumiCtx(), "edge", nil) // count mode until Mode: WafModeBlock
site, err := anvilaws.NewSvelteKitSite(ctx.PulumiCtx(), "web", &anvilaws.SvelteKitSiteArgs{
Path: pulumi.String("web"),
Waf: &anvilaws.SiteWafArgs{Arn: waf.Arn}, // protection defaults to "edge-oac"
})Origin protection
If your site sits behind a CDN or proxy — Cloudflare, Fastly, Akamai, nginx — set originProtection: true. CloudFront then only accepts requests carrying an x-origin-secret header, so the proxy becomes the only way in. Protection defaults to edge-oac, locking the hop after it too.
const site = new anvil.aws.SvelteKitSite('web', {
path: 'web',
domain: 'example.com',
originProtection: true, // protection defaults to "edge-oac"
});
ctx.export('originSecret', site.originSecret); // paste into your proxy's header rulesite = anvil.aws.SvelteKitSite("web",
path="web",
domain="example.com",
origin_protection=True, # protection defaults to "edge-oac"
)
ctx.export("originSecret", site.origin_secret) # paste into your proxy's header rulesite, err := anvilaws.NewSvelteKitSite(ctx.PulumiCtx(), "web", &anvilaws.SvelteKitSiteArgs{
Path: pulumi.String("web"),
Domain: pulumi.String("example.com"),
OriginProtection: pulumi.Bool(true), // protection defaults to "edge-oac"
})
ctx.Export("originSecret", site.OriginSecret) // paste into your proxy's header rule- Configure the proxy before pointing DNS at it. Until it sends the header, the
site returns 403. Order: deploy → add the header rule with the
originSecretoutput → switch DNS. - Proxy setup is one request-header rule. Cloudflare: Rules → Transform Rules → Modify Request
Header → Set static (free plan). Fastly: Headers (Request, Set). Akamai: Modify
Outgoing Request Header. nginx:
proxy_set_header x-origin-secret …; - Treat the secret like a password. It's stable across deploys and never reaches your app — the edge strips it.
- Your own health checks and uptime monitors must also go through the proxy.
- Proxies like Cloudflare include their own WAF, so an AWS WAF is optional behind one.