DEV Community

Cover image for AWS CDK 100 Drill Exercises #013: CloudFront S3 Static Website — Serving a Private Bucket over HTTPS with OAC and Cross-Region WAF

AWS CDK 100 Drill Exercises #013: CloudFront S3 Static Website — Serving a Private Bucket over HTTPS with OAC and Cross-Region WAF

Level 200

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 the Server header
  • A design that rewrites 403/404 to index.html for SPA client-side routing, while showing genuine 5xx failures 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

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
Enter fullscreen mode Exit fullscreen mode

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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 }],
},
Enter fullscreen mode Exit fullscreen mode

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),
  })),
],
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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' } },
],
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
curl https://<output WebsiteDistributionDomainName>/
Enter fullscreen mode Exit fullscreen mode

The reference dev config ships with enableWaf: true and geoRestrictionCountries: ['JP'], so a straight deploy gives you a WAF-protected, Japan-only distribution. Adjust parameters/dev-params.ts to 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:

  1. 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
  2. ResponseHeadersPolicy lets 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
  3. Don't conflate error responses for SPA routing rewrites with visibility into genuine failures. 403/404 become 200; 5xx keeps its status code — both are "show a friendly page," but they mean very different things
  4. 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: true makes future changes more resilient
  5. defaultAction: block pairs 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


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)