---
status: in-progress
---

# A WAF in front of the open-next dashboards

Status: in progress. Steps 0 to 2 of §5 are built. [`packages/waf`](../../packages/waf/README.md)
declares the count-mode web ACL and its log group (#4464) in dev and prod stacks (#4482), and the
customer, bank, admin and ui-sandbox distributions attach it (#4465, #5047). Step 3, the prod
count-mode review, and step 4, the switch to block, remain.

On 2026-10-02, none of the 11 production CloudFront distributions had a web ACL attached. This proposes one AWS WAF
web ACL shared by the dashboards, run in count mode first, and evaluates Cloudflare as the other way
to get a WAF, a bot filter and rate limiting in front of them. The recommendation is in §5.

How to read the evidence. Every external fact carries the URL it was read from, and every page was
read on 2026-10-02. "Measured" means a number pulled from the production account on that date.
"Inferred" is my conclusion from the cited pages, not a sentence on them. "Not verified" means I
could not find it, and §6 lists all of those in one place.

## 1. Today

### 1.1 What fronts each distribution

Measured on 2026-10-02 in the production account (372806568664), CloudWatch `AWS/CloudFront`, sum
over the trailing 30 days. The web ACL column is the `WebACLId` of each distribution, empty on all.

| Distribution | Requests  | GB out | Name resolves through                                                                | Defined in                                  |
| ------------ | --------- | ------ | ------------------------------------------------------------------------------------ | ------------------------------------------- |
| admin        | 1,016,276 | 2.33   | Cloudflare DNS, not proxied (`proxy: false`) since #4246; proxied when measured      | `packages/admin-dashboard/sst.config.ts`    |
| bank         | 1,018,915 | 4.19   | Cloudflare DNS, not proxied (`proxy: false`), one CNAME per bank written at run time | `packages/bank-dashboard/sst.config.ts`     |
| customer     | 659,144   | 3.88   | Route53, delegated zone for `app.cfx.to`                                             | `packages/customer-dashboard/sst.config.ts` |
| network-map  | 3,400,448 | 65.88  | Not found in this repo (`cfx-marketing` per `docs/architecture.md`)                  | Another repo                                |
| movemoney    | 96,798    | 0.81   | Not found in this repo; `movemoney.app` is a zone in the shared Cloudflare account   | Another repo (`cfx-movemoney`)              |
| marketing    | 107,454   | 2.16   | Not found in this repo                                                               | Another repo                                |
| ui-sandbox   | 3,164     | 0.03   | Route53                                                                              | `packages/ui-sandbox/sst.config.ts`         |
| static       | 18,560    | 0.50   | Cloudflare and Route53 share custody (comment in `packages/cloudfront/pulumi`)       | `packages/cloudfront/pulumi`                |
| brand-config | 17,757    | 0.09   | Not found in this repo                                                               | Not located                                 |

The table names nine distributions with traffic. A second movemoney distribution served 0 requests.
The eleventh distribution is not identified. The totals are 6,338,516 requests and 79.86 GB.

No dashboard is behind Cloudflare's proxy any more. Pull request 4152 deleted admin's Cloudflare
Access application, leaving the Cognito operator session as the only gate ([AUTH.md](../AUTH.md)), and
pull request 4246 turned admin's record grey (`proxy: false`) so CloudFront sees the real viewer and
sets `CloudFront-Viewer-Time-Zone`. Customer left Cloudflare earlier, and its config says "Cloudflare
is no longer in the DNS path for any stage". Admin's proxied record still shapes the analysis below,
which was written on 2026-10-02. It applies again only if a dashboard goes back behind Cloudflare.

### 1.2 The existing WAF coverage does not reach CloudFront

Firewall Manager already runs a WAF policy in production, but not for CloudFront.

- `packages/aws-network/waf-policies-prod.tf` and `waf-policies-nonprod.tf` list
  `resource_type_list = ["AWS::ElasticLoadBalancingV2::LoadBalancer", "AWS::ApiGateway::Stage"]`.
  Neither names `AWS::CloudFront::Distribution`. I read these in a local clone at commit `d92f7ed`
  (2026-09-01).
- `docs/infra/networking/overview.md` says the WAF policy attaches to "ALBs, API Gateway
  stages, CloudFront distributions". That is wrong for CloudFront, and the doc should be corrected in
  place.
- The prod policy runs five managed rule groups with `overrideActionType = "NONE"`, so each rule
  uses its own action: `KnownBadInputsRuleSet`, `CommonRuleSet` (excluding `NoUserAgent_HEADER`),
  `SQLiRuleSet`, `AmazonIpReputationList` and `AnonymousIpList` (excluding `HostingProviderIPList`),
  with logs sent to Firehose then S3 (`network/terraform.tfvars:20-26`).
- A stage opts out through the tag `waf-disable=true`, set from `waf: false` in `LambdaRestApi`.
  [apigw-403-change-not-live.md](../runbooks/apigw-403-change-not-live.md) records that Firewall
  Manager does not detach an ACL from a stage it already covered.

### 1.3 The deploy role cannot create a web ACL

The SST deploy role has `cloudfront:*` (`aft-account-customizations`
`workloads-prod/terraform/iam.tf`, lines 155, 370 and 567) and no `wafv2` action anywhere in that
repo (searched a local clone at commit `e99c22f`, 2026-07-23, and a search of `main` on 2026-10-02 found none either). I did
not test the role. Attaching an existing web ACL to a distribution goes through the CloudFront API,
not `wafv2:AssociateWebACL` ([WAF IAM
docs](https://docs.aws.amazon.com/waf/latest/developerguide/security_iam_service-with-iam.html):
"For Amazon CloudFront distributions, instead of this action, use the CloudFront action
UpdateDistribution"). Creating the ACL needs `wafv2` permissions on the creating role. The IAM change
in `aft-account-customizations` is a prerequisite to any of the AWS options except Firewall Manager
(§2.5).

### 1.4 Is the data-transfer bill a reason to act?

The claim that we pay heavily for bytes through AWS is not supported by what was measured.

- Prod account, Cost Explorer, September 2026: total $1,509.13. The largest lines are RDS instances
  ($745 and $100), RDS Proxy ($92), VPC endpoint hours ($86), Lambda provisioned concurrency ($67)
  and Fargate ($64). The data-transfer usage lines are `USW2-DataProcessing-Bytes` $7.41,
  `USW2-DataTransfer-Out-Bytes` $2.37, `TransitGateway-Bytes` $1.23, `VendedLog-Bytes` $1.07 and
  $0.38, and `US-DataTransfer-Out-OBytes` $0.03.
- CloudFront moved 79.86 GB out in 30 days. CloudFront pay-as-you-go pricing includes an always-free
  tier of 1 TB of transfer out and 10,000,000 requests a month, then $0.085 per GB for the first 10 TB
  to the US ([pricing](https://aws.amazon.com/cloudfront/pricing/pay-as-you-go/)). At that rate 79.86 GB
  costs $0 inside the allowance, or $6.79 if the allowance did not apply to this account. The 6.34M
  requests are also under the 10M free requests; without the allowance they would cost about $6.34
  at $0.01 per 10,000 HTTPS requests. I did not check whether the always-free tier applies to
  a member account of our organisation.
- Not measured: the network account and every other account, and any bytes that leave AWS by a route
  other than CloudFront (API Gateway, ALBs, NAT).

## 2. AWS WAF on the CloudFront distributions

### 2.1 Design

One global web ACL, created in `us-east-1` ([WAF
docs](https://docs.aws.amazon.com/waf/latest/developerguide/web-acl-associating-aws-resource.html):
"You can associate a protection pack (web ACL) with one or more CloudFront distributions", and the
global ACL "will have a hard-coded Region of US East (N. Virginia) Region"). AWS now calls a web ACL a
"protection pack" in these pages.

- Rules, six in total: the five managed groups the regional policy already runs (§1.2), and one
  rate-based rule. Each managed group counts as one rule ([WAF
  pricing](https://aws.amazon.com/waf/pricing/): "$1.00 per month (prorated hourly) for each rule group
  or each managed rule group"). The five groups use 1,175 WCU in total (700, 200, 200, 25 and 50 from
  the [baseline](https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-baseline.html),
  [use-case](https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-use-case.html)
  and [IP reputation](https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-ip-rep.html)
  pages), under the 1,500 WCU default and the 5,000 limit ([limits](https://docs.aws.amazon.com/waf/latest/developerguide/limits.html)).
  A rate-based rule costs 2 WCU plus 30 for each custom aggregation key.
- Mode: count first. A rule in count mode is "a non-terminating action" ([rule
  actions](https://docs.aws.amazon.com/waf/latest/developerguide/waf-rule-action.html)), and the
  the networking overview already runs non-production in count. Two weeks of logs from customer,
  bank and admin go through a false-positive review before any rule blocks. Next.js server actions and
  uploads are the likely false-positive sources. I did not fetch the rule lists of the groups, so
  which of their rules matter here is inferred.
- Logs: to a log group, S3 bucket or Firehose stream whose name starts with `aws-waf-logs-`, in
  `us-east-1` for a CloudFront ACL ([logging](https://docs.aws.amazon.com/waf/latest/developerguide/logging.html)).
  The Firehose stream behind the regional policy is not in `us-east-1`, so a CloudFront ACL needs its own destination (inferred from the policy name, which says Oregon).
- Attaching in SST: `sst.aws.Nextjs` has no `webAcl` argument, but its `transform.cdn` reaches the
  `Cdn` component, which takes `webAclArn` ("The ARN of a WAF WebACL to associate with the CloudFront
  distribution", [Cdn](https://sst.dev/docs/component/aws/cdn/), [Nextjs](https://sst.dev/docs/component/aws/nextjs/)).
  The form is `transform: { cdn: (args) => { args.webAclArn = ... } }`. This follows from the docs
  and the SST source on `dev`; I did not run it or check the SST version this repo pins. The ACL
  itself lives in a separate stack and the dashboards read its ARN, so the dashboards do not own a
  resource that several apps share.
- Distributions outside this repo (network-map, movemoney, marketing, brand-config) and `static` in
  `packages/cloudfront` set `WebACLId` in their own stacks. Their owners decide when.

### 2.2 Cost

AWS WAF unit prices, read from [aws.amazon.com/waf/pricing](https://aws.amazon.com/waf/pricing/) on
2026-10-02: $5.00 per web ACL per month, $1.00 per rule per month, $0.60 per million requests. They
give the following. One shared ACL with six rules costs $11 a month plus $0.60 per million
requests:

| Attached to                         | Requests (30 days) | Monthly cost |
| ----------------------------------- | ------------------ | ------------ |
| customer and bank                   | 1,678,059          | $12.01       |
| customer, bank and admin            | 2,694,335          | $12.62       |
| the six app distributions           | 6,299,035          | $14.78       |
| all nine distributions with traffic | 6,338,516          | $14.80       |

The six app distributions are admin, bank, customer, network-map, movemoney and marketing. The
figures exclude logging, CAPTCHA and Fraud Control. AWS WAF includes 500 MB of log ingestion per
million requests, which at 6.3M requests is about 3 GB.

Add-ons, from the same page: Bot Control (common level) is $10 a month per ACL and $1.00 per million
requests after the first 10 million, so at 6.3M requests it adds $10 plus the $1 for its rule slot,
making $25.80 for the whole ACL. The targeted level is $10 a month and $10.00 per million after the
first million, which at 6.3M requests would be about $53 a month in requests alone; it is not proposed.
CAPTCHA is $0.40 per 1,000 attempts. Past 1,500 WCU the request price rises ($0.20 per million for
each further 500 WCU).

### 2.3 What WAF sees behind Cloudflare

The managed IP groups, the IP-set rules and a rate-based rule all use the connecting address by
default. Behind a proxy that is the proxy's address: the IP reputation page says "the web request
origin will contain the address of the last proxy, and not the originating address of the client". For
the admin distribution, `AmazonIpReputationList` and `AnonymousIpList` would judge Cloudflare's
addresses. A rate-based rule can aggregate on a header instead ([rate-based
settings](https://docs.aws.amazon.com/waf/latest/developerguide/waf-rule-statement-type-rate-based-high-level-settings.html):
"AWS WAF retrieves the first IP address in the specified header", with `X-Forwarded-For` as the
common choice and "any header" allowed). That header is only safe to trust once CloudFront accepts
traffic from Cloudflare alone (§3.3). No dashboard has been proxied since #4246 (§1.1), so none of this
applies today.

### 2.4 Flat-rate CloudFront plans

CloudFront has per-distribution flat-rate plans that bundle a web ACL, DDoS protection and bot
controls ([pricing](https://aws.amazon.com/cloudfront/pricing/) and the [plan
guide](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/flat-rate-pricing-plan.html)).

| Plan     | Per distribution | Requests | Data transfer | WAF rules |
| -------- | ---------------- | -------- | ------------- | --------- |
| Free     | $0               | 1M       | 100 GB        | 5         |
| Pro      | $15              | 10M      | 50 TB         | 25        |
| Business | $200             | 125M     | 50 TB         | 50        |
| Premium  | $1,000           | 500M     | 50 TB         | 75        |

At our volumes, six distributions fit the Free plan and admin, bank and network-map would need Pro
(admin and bank are about 1.6% and 1.9% over the 1M request limit), for $45 a month. That is more
than the shared ACL, and these constraints from the plan guide rule it out for now:

- A web ACL attached to a plan distribution "can only be used for that distribution", so nine
  distributions mean nine ACLs to keep in step, and rule groups cannot be shared.
- A distribution that Firewall Manager manages is not eligible for a plan.
- The programmatic API for plans was announced on 2026-09-03
  ([announcement](https://aws.amazon.com/about-aws/whats-new/2026/09/cloudfront-flat-rate-pricing-plans-api/)).
  The Terraform resource (`aws_pricingplanmanager_subscription`) was an open, unmerged pull request
  when I looked, and I found no Pulumi or SST support. Not verified.
- The targeted bot level and account-takeover protection need pay-as-you-go.

### 2.5 Firewall Manager for CloudFront

A Firewall Manager WAF policy can protect `AWS::CloudFront::Distribution` ([Policy
API](https://docs.aws.amazon.com/fms/2018-01-01/APIReference/API_Policy.html)), with Global as the
policy Region in the console ([create a
policy](https://docs.aws.amazon.com/waf/latest/developerguide/create-policy.html)). Firewall Manager
creates and attaches the ACL through its own service-linked role
([`AWSServiceRoleForFMS`](https://docs.aws.amazon.com/waf/latest/developerguide/fms-using-service-linked-roles.html)),
so the SST deploy role needs no `wafv2` permission. That removes the §1.3 prerequisite.

It costs more and has an open problem. A policy is $100 a month per Region
([pricing](https://aws.amazon.com/firewall-manager/pricing/)), plus the ACLs and rules it creates and
AWS Config charges, against $11 for a shared ACL. On the next apply, an SST or Pulumi distribution
that sets no `WebACLId` would likely send an empty value and remove the ACL that Firewall Manager
attached, and auto-remediation would put it back. That is inferred from the Terraform provider
schema and the SST source; no AWS or SST page covers it. It would need a test before anyone relied on
it. The advantage is that it matches how the API Gateway stages are protected today and gets one
central log pipeline. I do not propose it, because the IAM change is smaller and cheaper.

## 3. Cloudflare evaluation

Cloudflare here means the Cloudflare CDN and security service, not CloudFront.

### 3.1 Criteria compared

Each row is a question that bears on the choice. The columns give the AWS WAF position, the Cloudflare
position, and what the evidence supports. "Not assessed" means this proposal did not look at it.

| Criterion                          | AWS WAF on CloudFront                                   | Cloudflare in front                                                                                                                                                                                                                                     | Evidence and gaps                                                                                                                                                                                                                                             |
| ---------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Bot detection                      | Bot Control, $10 a month at our volume (§2.2)           | Free: Bot Fight Mode, which cannot be tuned. Pro: Super Bot Fight Mode, which acts only on traffic rated "definitely automated". Business adds "likely automated". JA3/JA4 fingerprints and bot scores need Bot Management, an Enterprise add-on (§3.2) | No head-to-head measurement of either found, so this proposal ranks neither                                                                                                                                                                                   |
| Cost control during a bot incident | Not assessed                                            | Used during an earlier bot incident on movemoney.app                                                                                                                                                                                                    | Not confirmed. The `movemoney` distribution served 96,798 requests and 0.81 GB in 30 days, so CloudFront was not where the cost arose. It could be SMS, a vendor API or compute. The plan of the `movemoney.app` zone and what the incident hit are not known |
| Bandwidth cost                     | 79.86 GB a month costs $0 to $6.79 on CloudFront (§3.4) | A proxy could reduce bytes served from CloudFront, so it saves at most that amount                                                                                                                                                                      | Not material at our volume (§3.4)                                                                                                                                                                                                                             |
| Earlier operating history          | Current setup                                           | A proxy-in-front setup existed when the dashboards deployed to Vercel                                                                                                                                                                                   | No trace in this repo. `docs/architecture.md` says Vercel is no longer part of the deployment path. Worth collecting what that setup taught                                                                                                                   |
| Challenge for forms (Turnstile)    | Not assessed                                            | Free plan: 20 widgets, unlimited challenges. Works on a site that is not proxied through Cloudflare, so it does not depend on this decision                                                                                                             | A server must call Siteverify for it to mean anything ([plans](https://developers.cloudflare.com/turnstile/plans/), [get started](https://developers.cloudflare.com/turnstile/get-started/))                                                                  |
| Admin access control               | Not assessed                                            | Zero Trust is free up to 50 users and $7 per user per month after that ([plans](https://www.cloudflare.com/plans/)). Admin has had no Access app since #4152                                                                                            | Retired by #4152 (§1.1). Not verified whether Access requires the hostname to be proxied; Cloudflare's docs require only that the domain is an active zone in the account                                                                                     |
| Operational complexity             | One cache, one place that holds the client address      | Two caches, two places that hold the client address, the alias and certificate on CloudFront, a DNS owner split, and a second outage source                                                                                                             | Detail in §3.3                                                                                                                                                                                                                                                |

### 3.2 What each plan includes

Prices from [cloudflare.com/plans](https://www.cloudflare.com/plans/), per zone: Free $0, Pro $20 a
month billed annually or $25 monthly, Business $200 annually or $250 monthly, Enterprise by contract.

| Feature                 | Free                          | Pro                               | Business                           | Enterprise                        |
| ----------------------- | ----------------------------- | --------------------------------- | ---------------------------------- | --------------------------------- |
| Managed WAF rules       | Free Managed Ruleset only     | Cloudflare Managed and OWASP Core | Same as Pro                        | Same as Pro                       |
| Custom rules            | 5                             | 20                                | 100                                | 1,000                             |
| Rate limiting rules     | 1                             | 2                                 | 5                                  | 100                               |
| Rate limit counted by   | IP (path, verified bot match) | IP                                | IP, IP with NAT                    | Many, with Advanced Rate Limiting |
| Rate limit period       | 10 s                          | up to 1 min                       | up to 10 min                       | up to 65,535 s                    |
| Bot protection          | Bot Fight Mode                | Super Bot Fight Mode              | Super Bot Fight Mode, more classes | Bot Management (add-on)           |
| Turnstile               | Free tier                     | Free tier                         | Free tier                          | Contact sales                     |
| DDoS (layers 3 to 7)    | Unmetered                     | Unmetered                         | Unmetered                          | Unmetered                         |
| Security events kept    | 31 days                       | 31 days                           | 31 days                            | 31 days (analytics 90)            |
| Logpush                 | Usage-based                   | Usage-based                       | Usage-based                        | Through account team              |
| Proxy a wildcard record | Yes                           | Yes                               | Yes                                | Yes                               |

Sources: [WAF plan table](https://developers.cloudflare.com/waf/), [custom
rules](https://developers.cloudflare.com/waf/custom-rules/), [rate limiting
rules](https://developers.cloudflare.com/waf/rate-limiting-rules/), [bots by
plan](https://developers.cloudflare.com/bots/plans/), [DDoS](https://developers.cloudflare.com/ddos-protection/about/),
[security events](https://developers.cloudflare.com/waf/analytics/security-events/),
[Logpush pricing](https://developers.cloudflare.com/logs/logpush/pricing/) (25 GB included, then $0.03
per GB to R2 and $0.10 per GB elsewhere) and [wildcard
records](https://developers.cloudflare.com/dns/manage-dns-records/reference/wildcard-dns-records/).

Points that change a decision:

- A rate limit on Free matches only path and verified bot. On Pro and Business it counts by IP only.
  Counting by header, cookie or API key needs Enterprise.
- Free and Pro rate limits act for a fixed period and cannot throttle; only Enterprise can throttle
  with the block action.
- Super Bot Fight Mode "may challenge API or mobile app traffic" ([bots
  docs](https://developers.cloudflare.com/bots/get-started/super-bot-fight-mode/)). A challenge page
  cannot be answered by a `fetch()` call ([challenge
  pages](https://developers.cloudflare.com/cloudflare-challenges/challenge-types/challenge-pages/)),
  which is how Next.js navigation and server actions talk to the server. Cloudflare's own advice is
  Turnstile pre-clearance. Inferred: a challenge rule applied to dashboard routes mid-session would
  break them.
- Rate limiting has no price on its docs page, and I could not verify one.
- The compliance reports (SOC 2, ISO 27001, PCI DSS) download from the dashboard for Super
  Administrators ([compliance docs](https://developers.cloudflare.com/fundamentals/reference/policies-compliances/compliance-docs/)).
  Whether any plan is excluded is not verified.
- Cloudflare says all plans can proxy a wildcard record. The edge certificate is the catch:
  Universal SSL covers the root and first-level subdomains only ([limitations](https://developers.cloudflare.com/ssl/edge-certificates/universal-ssl/limitations/)),
  so `bank.cfx.to` and `*.cfx.to` would be covered and `*.dev.cfx.to` would not.

The plan of the `cfx.to` zone was not checked.

### 3.3 What Cloudflare in front of a CloudFront dashboard looks like

Admin ran this way until #4246, and `admin-dashboard/sst.config.ts` still holds most of the pattern: a
Cloudflare record (now grey) to the CloudFront distribution, with the proxied hostname as an alias on the
distribution and an ACM certificate (`us-east-1`) that covers it.

- Alias, Host and TLS. On Free, Pro and Business the Host header Cloudflare sends is the proxied
  hostname: overriding Host, SNI or the DNS target is Enterprise only ([origin
  rules](https://developers.cloudflare.com/rules/origin-rules/)). CloudFront picks the distribution
  from the Host header and returns 403 when no alias matches ([403
  docs](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/http-403-permission-denied.html)),
  so the alias and certificate are required. The TLS mode should be Full (strict): Cloudflare then
  validates the certificate, which must be unexpired, from a public CA and match the hostname
  ([SSL modes](https://developers.cloudflare.com/ssl/origin-configuration/ssl-modes/)). Flexible would
  send plain HTTP to CloudFront. A Cloudflare Origin CA certificate cannot serve as the CloudFront
  certificate, since CloudFront accepts the CAs Mozilla trusts (inferred for the Origin CA).
- Cloudflare's SNI default towards the origin is not documented anywhere I read. CloudFront rejects a
  request whose SNI differs from the Host header with a 421 ([domain
  fronting](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/CNAMEs.html)), so this
  needs a test before a rollout.
- Locking the origin. Today anyone can reach `d….cloudfront.net` or the alias directly and skip
  Cloudflare. Cloudflare's own ratings ([protect your
  origin](https://developers.cloudflare.com/fundamentals/security/protect-your-origin-server/)): an IP
  allowlist is "moderately secure", and Authenticated Origin Pulls with your own certificate is
  "very secure". A shared-certificate pull "only guarantees that a request is coming from the
  Cloudflare network", which any Cloudflare customer can satisfy. Three ways to do it on CloudFront,
  none documented end to end by either vendor:
  - A secret request header set by a Cloudflare transform rule and checked by a WAF byte-match rule
    (2 WCU). This needs an AWS web ACL on the distribution, so Cloudflare does not remove AWS WAF
    from the design.
  - A WAF IP set holding Cloudflare's published ranges ([ips-v4](https://www.cloudflare.com/ips-v4/),
    [ips-v6](https://www.cloudflare.com/ips-v6/)). It lets other Cloudflare tenants through.
  - CloudFront viewer mutual TLS, which exists and has no extra charge on pay-as-you-go
    ([docs](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/mtls-authentication.html)).
    Whether it accepts Cloudflare's origin-pull certificate is not verified.
- Client address. Every address the CloudFront side sees is a Cloudflare edge address:
  `CloudFront-Viewer-Address`, CloudFront Functions `viewer.ip`, WAF's default source and Lambda's
  `requestContext.http.sourceIp`. OpenNext copies `sourceIp` into the request `ip` (read from
  `opennextjs-aws` on `main`). Cloudflare sends the client in `CF-Connecting-IP` and appends to any
  `X-Forwarded-For` the client supplied ([headers](https://developers.cloudflare.com/fundamentals/reference/http-headers/)),
  so the leftmost entry is client-controlled. The origin request policy must forward
  `CF-Connecting-IP`, and the header is trustworthy only after the lockdown above.
- Caching. Cloudflare caches by file extension and does not cache HTML or JSON by default
  ([default behavior](https://developers.cloudflare.com/cache/concepts/default-cache-behavior/)), so
  `/_next/static` assets would be cached twice and pages once. A CloudFront invalidation does not
  clear Cloudflare (inferred). Next.js ISR sends `s-maxage=N, stale-while-revalidate`, and in
  Cloudflare `s-maxage` disables stale-while-revalidate ([cache
  control](https://developers.cloudflare.com/cache/concepts/cache-control/)). The safe setting is to
  bypass Cloudflare's cache for everything except static assets. I found no Cloudflare page that
  recommends this, so it is my inference.
- Timeouts. The proxy read timeout is 125 seconds and returns a 524 on Free, Pro and Business
  ([limits](https://developers.cloudflare.com/fundamentals/reference/connection-limits/)).
- No documented prohibition on proxying a CloudFront origin. Cloudflare's warnings against stacking
  describe Cloudflare placed behind another vendor ([multi-vendor
  guide](https://developers.cloudflare.com/reference-architecture/architectures/multi-vendor/)), the
  opposite direction. The two-networks overhead point still applies.

Replacing CloudFront with Cloudflare in front of the Lambda function URLs and S3 is a larger change
and I do not propose it. A function URL with `AWS_IAM` auth needs requests signed with SigV4
([Lambda docs](https://docs.aws.amazon.com/lambda/latest/dg/urls-invocation.html)), which a
Cloudflare rule cannot do, and a Worker would have to. With auth type `NONE` the URL is public and
the lockdown becomes ours to build. AWS says a Lambda URL origin expects its own domain in the Host
header, and Cloudflare cannot rewrite Host below Enterprise, so the Host would be wrong
(a deduction from those two statements; I found no page stating it for function URLs).
`sst.aws.Nextjs` also builds the CloudFront, OAC and cache wiring this would replace.

Failure modes. Once proxied, a dashboard is down when Cloudflare is. Cloudflare published
post-mortems for 2025-11-18 ([bot feature file](https://blog.cloudflare.com/18-november-2025-outage/):
"significant failures to deliver core network traffic", and the dashboard login could not load
Turnstile), 2025-12-05 ([about 28% of HTTP traffic for 25
minutes](https://blog.cloudflare.com/5-december-2025-outage/)), and others, including a dashboard and
API outage on 2025-09-12. The fail-open is to turn the record to DNS-only, but proxied records have
a fixed 300-second TTL ([TTL](https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl/)),
the Cloudflare dashboard and API can be down in the same incident, and any origin lockdown rejects
direct traffic, so the lockdown must be relaxed in the same step. Whoever owns the Cloudflare zone
gets paged for these, and the application team gets the false positives.

### 3.4 Bandwidth

I could not find a plan-level promise of unmetered CDN bandwidth on the plans, network-services or
CDN pages. Cloudflare's CDN page says "Bandwidth costs are bundled and competitively priced"
([CDN](https://www.cloudflare.com/application-services/products/cdn/)), and a Cloudflare staff reply
on the community forum (2021-11-10) says "There are no explicit limits on self-service plans". The
unmetered claim Cloudflare does make in writing is for DDoS mitigation.

The Service-Specific Terms (last updated 2026-09-28) restrict serving "video or a disproportionate
percentage of pictures, audio files, or other large files" through the CDN on Free, Pro and Business
without a paid product ([terms](https://www.cloudflare.com/service-specific-terms-application-services/)).
This clause moved out of the Self-Serve Agreement, where it was section 2.8. Whether dashboard
JavaScript and images count as "large files" is not defined. Network-map is the distribution where it
could matter: 65.88 GB of the 79.86 GB.

What a proxy would save. Cloudflare would answer from its cache, so fewer bytes would leave
CloudFront, but only for cached static assets, and CloudFront would still pay for every miss. The most
it could remove from a $0 to $6.79 line is that line. Bandwidth does not decide this. Network-map's
3.4M requests and 66 GB a month are unexplained.

### 3.5 Operations

- As code. The repo already pins `@pulumi/cloudflare` 6.17.0 for SST (`admin-dashboard/sst.config.ts`).
  The latest release is 6.21.0 (npm, 2026-09-18), and the Terraform provider is 5.26.0 (GitHub,
  2026-09-26). Rules are `cloudflare.Ruleset` (phases `http_request_firewall_custom`,
  `http_request_firewall_managed`, `http_ratelimit`, `http_request_cache_settings`), bots are
  `cloudflare.BotManagement`, Turnstile is `cloudflare.TurnstileWidget` and DNS is
  `cloudflare.DnsRecord`. Origin pulls are `cloudflare.AuthenticatedOriginPulls`. That 6.x tracks the
  Terraform 5.x resources is not verified.
- Who owns it. [rules/pulumi.md](../../rules/pulumi.md) already puts Cloudflare DNS, Access apps and
  tunnels in the Pulumi stack of the package that serves the name. A zone-wide ruleset belongs to the
  zone, not to one package, so one stack would own `cfx.to` rulesets, the way one stack owns the
  tunnel config.
- DNS. `app.cfx.to` was moved to a Route53-delegated zone on purpose. `bank` and its per-bank records
  are Cloudflare DNS written at run time by `BankDashboardDnsService`, unproxied. Proxying either
  means reversing the first move and changing the service that writes the second.

## 4. Comparison

Monthly cost at the measured volumes. Setup effort is my estimate in working days, for the four
dashboards this repo owns (admin, bank, customer, ui-sandbox) and not for the distributions in other
repos.

|                         | AWS WAF on CloudFront (shared ACL)                                                            | Cloudflare Pro in front                                                                                     | Both                                                 |
| ----------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| Monthly cost            | $14.80 (all nine), or $12.62 for customer, bank and admin. $25.80 with Bot Control            | $20 to $25 per zone. Two zones (`cfx.to`, `movemoney.app`) is $40 to $50. Business is $200 to $250 per zone | AWS cost plus the Cloudflare plan                    |
| Setup effort            | 3 to 4 days plus a two-week count-mode soak, after the IAM change                             | 6 to 8 days: DNS moves, certificates, lockdown, cache rules, client address                                 | 8 to 12 days                                         |
| Protection              | Five managed groups, one rate rule by IP or forwarded header, optional bot control            | Managed and OWASP rules, 20 custom rules, 2 rate rules by IP, bots limited to "definitely automated"        | Both layers, with the client-address problem of §2.3 |
| Bots                    | Bot Control common, $10 a month                                                               | Super Bot Fight Mode, challenge cannot answer `fetch()`                                                     | Either                                               |
| Rate limiting           | By IP or any header                                                                           | IP only below Enterprise                                                                                    | Both                                                 |
| Compliance coverage     | Same account as the evidence we already collect; Firewall Manager logs for regional resources | SOC 2, ISO 27001 and PCI DSS reports for Super Administrators                                               | Both                                                 |
| Lock-in                 | Low; managed groups are named by AWS                                                          | Higher for DNS and cache rules; we already depend on Cloudflare for Access, WARP and tunnels                | Both                                                 |
| Operational burden      | One system. The deploy role needs `wafv2` permissions first                                   | Second cache, second DNS owner, lockdown to maintain, second outage source                                  | The most                                             |
| Needs an AWS ACL anyway | Yes                                                                                           | Only if the origin lockdown uses a header or IP rule                                                        | Yes                                                  |

## 5. Recommendation

Attach one shared AWS WAF web ACL to the dashboards this repo owns, in count mode, and keep
Cloudflare where it already is.

- It costs $12.62 to $14.80 a month at measured volumes, and it reuses the five rule groups the
  Firewall Manager policy runs on API Gateway.
- It needs no change to DNS, certificates or caching, and no second place to set rules.
- No dashboard is proxied (admin went grey in #4246), so WAF sees client addresses with no header
  trust problem.
- It leaves the Cloudflare question open without blocking on it. Turnstile (free, standalone) can be
  added to any sign-up or login form independently.

Admin gets the same ACL. While admin was proxied, its IP-based groups would have judged Cloudflare's
addresses. Since #4246 admin is grey, so they see client addresses, as on customer and bank.

What this gives up. Cloudflare's network-wide bot intelligence and managed challenge; there is no
edge rate limit ahead of CloudFront. I did not find evidence that these matter at 6.3M requests a month across the nine
distributions, and the movemoney.app bot incident could not be verified.
Cloudflare for `movemoney.app` can be decided separately.

Sequence:

| Step | Work                                                                                                                                              | Done when                                                                   |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| 0    | `packages/aft/account-customizations`: allow web ACL create and read in `us-east-1` for the SST role. Correct `docs/infra/networking/overview.md` | A dev deploy can create an ACL and attach it                                |
| 1    | A `waf` stack: global ACL, five groups in count, one rate rule, log destination in `us-east-1`                                                    | ACL exists in dev                                                           |
| 2    | `transform.cdn` on customer, bank, admin and ui-sandbox attaches the ACL ARN                                                                      | `WebACLId` is non-empty on the dev distributions                            |
| 3    | Two weeks of count-mode logs in prod, false-positive review per rule                                                                              | Each rule that would have blocked a legitimate request is excluded or fixed |
| 4    | Switch rules to block, one dashboard at a time, customer last                                                                                     | Block count and 4xx rate watched for a week per dashboard                   |
| 5    | Other repos set `WebACLId` on their own distributions                                                                                             | Owners decide                                                               |

## 6. Not verified

- Whether the SST deploy role can create a web ACL (never tested), and the full `wafv2` and CloudFront permission set association needs.
- A CloudFront-specific AWS WAF price. The pricing page shows one price table.
- Firewall Manager behaviour against an IaC-managed distribution (§2.5), and whether a Global policy bills as one Region.
- SST `transform.cdn` with `webAclArn` on the version this repo pins.
- Terraform, Pulumi or SST support for CloudFront flat-rate plans.
- Any plan-level unmetered bandwidth statement from Cloudflare, and whether our traffic would count as "large files".
- Cloudflare's default SNI to the origin, and whether CloudFront viewer mutual TLS accepts Cloudflare's origin-pull certificate.
- Whether Access requires a proxied hostname.
- The Cloudflare rate limiting price, and compliance report availability by plan.
- Whether the AWS WAF forwarded-IP setting accepts `CF-Connecting-IP`. The docs say any header.
- Whether Next.js RSC `Vary` behaves under a Cloudflare "Cache Everything" rule.
- The identity of the eleventh distribution and the repos that define network-map, marketing, movemoney and brand-config.
- The always-free CloudFront tier for this account, and bytes outside CloudFront and outside the prod account.
