Introduction
This is the 13th installment of "AWS CDK 100 Drill Exercises." See here for an overview of the series.
Last time (#012), we covered publishing a site using S3 static website hosting alone. That setup had two clear limitations: no HTTPS support, and the bucket itself sitting open to Principal * (albeit IP-restricted).
This time we rebuild the same static site behind Amazon CloudFront, with a completely private S3 bucket. The bucket can never be read directly from the outside again, traffic is served as HTTPS over TLS 1.3, and you can optionally add defense-in-depth with WAFv2.
What you'll learn in this article
- How to serve from CloudFront without ever making the S3 bucket public, using Origin Access Control (OAC)
- Applying security headers like CSP and HSTS via a
ResponseHeadersPolicy, and hiding theServerheader - A design that rewrites
403/404toindex.htmlfor SPA client-side routing, while showing genuine5xxfailures with their real status codes intact - How to handle the constraint that CLOUDFRONT-scoped WAFv2 is pinned to us-east-1 using
crossRegionReferences - A two-stage IP allow-list design: "before managed rule evaluation" and "after managed rule evaluation"
📁 Code repository: GitHub
Architecture Overview
| Trait | Benefit |
|---|---|
| Private bucket + OAC | Unlike S3 website hosting, the bucket itself is never publicly reachable. Only CloudFront's OAC ID can read it |
| Cross-region WAF stack | The Web ACL and its related resources live in a stack pinned to us-east-1, with only the ARN passed to the main stack via crossRegionReferences: true
|
| Error mapping that doesn't hide real failures |
403/404 go to the SPA's index.html; 500-504 keep their original status codes and show a dedicated error page |
| Single-flag toggles |
enableWaf and geoRestrictionCountries can each be turned on/off with one optional parameter |
Data Flow
Viewer
│ HTTPS (min TLS 1.3), optional geo-restriction
▼
CloudFront Distribution
├─ WAFv2 Web ACL (optional, us-east-1): managed rule groups + IP allow-list → default action: block
├─ ResponseHeadersPolicy: applies CSP + security headers to every response
├─ Default behavior "/*"
│ └─ S3 origin (OAC) ───────────────────► WebsiteBucket (private)
│
└─ Error handling
├─ 403 / 404 → /index.html, HTTP 200, TTL 5 min
└─ 500-504 → /error.html, original status code preserved, TTL 1 min
Implementation Highlights
1. Private bucket + OAC — the polar opposite of "who can read this"
In the previous S3 website hosting version, the bucket was (conditionally) open to Principal: *. This time it's the exact opposite: the bucket itself is completely private.
const websiteBucket = createAccountRegionalBucket({
scope: this, id: 'WebsiteBucket',
// no websiteIndexDocument or similar settings at all
});
const distribution = new cloudfront.Distribution(this, 'WebsiteDistribution', {
defaultBehavior: {
origin: cloudfrontOrigins.S3BucketOrigin.withOriginAccessControl(websiteBucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
},
});
S3BucketOrigin.withOriginAccessControl() handles both creating the OAC and adding a bucket policy scoped to the distribution's ARN in a single line. Nobody can read the bucket on its own — the only relationship that grants read access is a resource-level one with "this specific CloudFront distribution."
2. Bulk security headers via ResponseHeadersPolicy
CloudFront's ResponseHeadersPolicy can inject response headers regardless of how the origin is implemented. On top of CSP, HSTS, and X-Frame-Options, we overwrite the Server header with an empty string to hide server information.
securityHeadersBehavior: {
contentSecurityPolicy: {
contentSecurityPolicy: "default-src 'self'; script-src 'self'; ...",
override: true,
},
strictTransportSecurity: {
accessControlMaxAge: cdk.Duration.days(365),
includeSubdomains: true,
preload: true,
override: true,
},
// ...
},
customHeadersBehavior: {
customHeaders: [{ header: 'server', value: '', override: true }],
},
Even though this setup only serves static HTML, the CSP explicitly blocking anything beyond self-hosted scripts/styles — and not allowing unsafe-inline — sets a useful design precedent for whenever a JS framework gets layered on top later.
3. Gentle with the SPA, honest about real failures
CloudFront's custom error responses rewrite both 403 and 404 to index.html + 200. This ensures that when a path that doesn't exist as an S3 object (an SPA client-side route) is accessed directly, control is correctly handed off to the app's own routing.
Genuine origin/edge failures like 500-504, on the other hand, keep their original status code and only swap in error.html for the body.
errorResponses: [
{ httpStatus: 403, responseHttpStatus: 200, responsePagePath: '/index.html', ttl: cdk.Duration.minutes(5) },
{ httpStatus: 404, responseHttpStatus: 200, responsePagePath: '/index.html', ttl: cdk.Duration.minutes(5) },
// 5xx are genuine failures, not SPA routing concerns, so keep the status code as-is
// and only replace the body, so origin error details aren't leaked
...[500, 502, 503, 504].map((httpStatus) => ({
httpStatus, responseHttpStatus: httpStatus, responsePagePath: '/error.html', ttl: cdk.Duration.minutes(1),
})),
],
You'll often see implementations that just turn everything into a 200 to paper over errors, but doing that buries genuine failures under a 200 from a monitoring perspective. Here, 403/404 are treated as SPA-specific, while 5xx stays honest — the two roles are kept separate.
4. CLOUDFRONT-scoped WAFv2 is pinned to us-east-1 — wire it with crossRegionReferences
When you specify scope: 'CLOUDFRONT' for a WAFv2 Web ACL, that Web ACL can only be created in us-east-1, regardless of which region the distribution itself lives in. Try creating it elsewhere and you get rejected with The scope is not valid.
In this reference implementation, all WAF-related resources are factored out into a separate stack (CloudfrontWafStack) that always deploys to us-east-1. Only its ARN is passed to the main stack, using crossRegionReferences: true set on both stacks.
const wafStack = new CloudfrontWafStack(this, pascalCase(`${props.project}Waf`), {
env: { account: props.params.accountId, region: 'us-east-1' },
crossRegionReferences: true,
enableWaf: props.params.enableWaf,
allowedIpsAfterRules: props.allowedIps,
});
const mainStack = new CloudfrontS3StaticWebsiteStack(this, pascalCase(`${props.project}Main`), {
webAclArn: wafStack.webAclArn,
crossRegionReferences: true,
});
mainStack.addStackDependency(wafStack);
Even when enableWaf: false (or unset), CloudfrontWafStack itself is still deployed — it just doesn't create a Web ACL, and webAclArn becomes an empty string, so the distribution deploys without a webAclId. By designing this as "don't create the Web ACL" rather than "remove the whole stack when WAF isn't needed," toggling enableWaf on and off later never breaks the cross-region reference wiring itself.
5. Allow-listing before or after the managed rules
The Web ACL's defaultAction is block. That means nothing gets through unless there's an explicit Allow rule. This stack has a two-stage IP allow list.
-
AllowSpecificIPsBeforeRules(priority 1, created only when configured): bypasses evaluation of the AWS managed rule groups (Common/KnownBadInputs/AdminProtection/IpReputation/AnonymousIp) entirely -
AllowSpecificIPsAfterRules(priority 100, always created): evaluated after the managed rule groups. When no IPs are specified, it defaults to allowing the entire IPv4/IPv6 address space (the managed rules still apply)
addresses: props.allowedIpsAfterRules
? props.allowedIpsAfterRules.map(ip => `${ip}/32`)
: ['0.0.0.0/1', '128.0.0.0/1'], // WAF rejects /0, so the full IPv4 range is split into two /1s
In the default wiring, the deploy operator's IP is passed as allowedIpsAfterRules. That means even allow-listed IPs first get inspected for known attack patterns by rules like Core/KnownBadInputs before being let through. Use allowedIpsBeforeRules only for the exceptional case where you want to bypass managed-rule false positives during active testing.
6. WAF logs go directly to S3, with sensitive headers masked
To deliver WAF logs directly to S3, the bucket name must start with aws-waf-logs- (otherwise creating the CfnLoggingConfiguration itself is rejected). On top of that, authorization and cookie headers are masked before being logged.
redactedFields: [
{ singleHeader: { Name: 'authorization' } },
{ singleHeader: { Name: 'cookie' } },
],
Deploy & Verify
export PROJECT=your-project
export ENV=dev
npm run bootstrap # first time only
# Also bootstrap us-east-1 once — the WAF stack deploys there and crossRegionReferences
# needs that region bootstrapped as well:
# npx cdk bootstrap aws://<account-id>/us-east-1 --profile $PROJECT-$ENV
npm run stage:deploy:all
curl https://<output WebsiteDistributionDomainName>/
The reference
devconfig ships withenableWaf: trueandgeoRestrictionCountries: ['JP'], so a straight deploy gives you a WAF-protected, Japan-only distribution. Adjustparameters/dev-params.tsto change either.
Access from an IP not in the WAF's allow list, and WAF returns a 403 before the request ever reaches CloudFront's own error page.
Cost Estimate
💰 Rough monthly estimate (Tokyo region, low traffic)
| Service | Usage | Rough monthly cost |
|---|---|---|
| CloudFront | Low request volume, minimal data transfer | ~$1-2 |
| AWS WAF | Web ACL ($5) + 6 rules ($1 each), low request volume | ~$11 |
| S3 (2 buckets) | A few MB of content + access/WAF logs | Under $0.10 |
Total (WAF enabled): ~$12-14/month
Total (enableWaf: false): ~$1-2/month
WAF is billed per Web ACL, per rule, and per evaluated request, making it the dominant cost factor at low traffic volumes. For pure demo use where IP/geo restriction isn't needed, enableWaf: false significantly lowers the cost.
Summary
What we learned from this pattern:
- Origin Access Control lets you serve from CloudFront without ever making the S3 bucket public. The bucket alone is unreadable by anyone — the polar opposite of last time's setup
-
ResponseHeadersPolicylets you apply security headers in bulk, regardless of the origin's implementation. Even for a static site, designing the CSP to explicitly deny anything beyond self-hosted resources holds up well for future expansion -
Don't conflate error responses for SPA routing rewrites with visibility into genuine failures.
403/404become200;5xxkeeps its status code — both are "show a friendly page," but they mean very different things -
CLOUDFRONT-scoped WAFv2 is pinned to us-east-1. This constraint applies regardless of which region the distribution itself lives in, and it never goes away. Separating the feature on/off toggle from the region-constraint wiring via
crossRegionReferences: truemakes future changes more resilient -
defaultAction: blockpairs naturally with a design where even allow-listed IPs still get inspected for attack patterns once. Allowing before managed-rule evaluation should stay the exception, not the norm
References
- Restricting access to an Amazon S3 origin with OAC
- Adding a response headers policy
- AWS WAF Developer Guide
- Restricting the geographic distribution of your content
- Cross-region references in CDK
- cdk-nag
Let's keep learning practical AWS CDK patterns through the 100 drill exercises!
If you found this helpful, please ⭐ the repository!
📌 You can see the entire code in my GitHub repository.

Top comments (0)