Fixing CORS Behind AWS ALB and CloudFront

The API answers a preflight perfectly when you address the load balancer directly, and fails the moment the same request goes through the distribution in front of it. Nothing in the application changed; the request that arrives there is simply not the request the browser sent. This guide works through the three CloudFront settings and the one Application Load Balancer habit that account for nearly every case, and it extends the bisection method in Troubleshooting CORS at the Proxy Layer with the AWS-specific detail.

Failure symptom:

Access to XMLHttpRequest at 'https://api.helio-metrics.app/v2/series' from
origin 'https://portal.helio-metrics.app' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check: No
'Access-Control-Allow-Origin' header is present on the requested resource.

A second variant shows up once more than one origin uses the API, and it points at a different setting:

... has been blocked by CORS policy: The 'Access-Control-Allow-Origin' header
has a value 'https://admin.helio-metrics.app' that is not equal to the
supplied origin.

Root Cause

CloudFront does not forward the whole request to your origin. A cache behaviour decides which methods are admitted at all, an origin request policy decides which headers, cookies and query strings travel upstream, and a cache policy decides which of those become part of the cache key. The defaults are tuned for static assets, so Origin is neither forwarded nor part of the key unless you say so. An application that never receives Origin correctly emits no grant, and an edge that ignores Origin in its key happily serves one origin’s grant to another — which is the second error message above.

The Application Load Balancer behind it contributes a smaller but sharper problem. It forwards headers faithfully, but its fixed-response action can set only a status code, a content type and a body. A listener rule that “handles OPTIONS” with a fixed response therefore returns a preflight answer with no Access-Control-Allow-Methods and no Access-Control-Allow-Origin at all.

The four gates a preflight passes through on AWS A preflight travels from the browser to CloudFront, then to the Application Load Balancer, then to the target group. Two gates sit at CloudFront — the allowed methods and the origin request policy — and two at the load balancer, covering fixed-response interception and forwarding to the target. The preflight travels left to right; each gate can stop it before the next layer ever sees it Browser sends OPTIONS CloudFront cache behaviour Load balancer listener rules Target group your API Gate 1 — OPTIONS must be in the allowed methods list Gate 2 — a request policy must forward Origin upstream Gate 3 — no fixed-response rule may intercept the OPTIONS Gate 4 — the matching rule must forward to the target group Any closed gate produces the same console message about a missing grant even though the target application is configured perfectly Diagnose by gate, not by symptom: the browser cannot tell you which one closed

Prerequisite State

Step-by-Step Fix

Step 1 — Prove the target is right before touching the edge

Send the preflight straight to the load balancer, bypassing CloudFront entirely. Address the load balancer’s own DNS name and override the Host header so the application’s routing still matches:

curl -sS -o /dev/null -D - -X OPTIONS \
  "http://api-lb-1234567890.eu-west-1.elb.amazonaws.com/v2/series" \
  -H 'Host: api.helio-metrics.app' \
  -H 'Origin: https://portal.helio-metrics.app' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: authorization,content-type' \
  | grep -iE '^(HTTP/|access-control|vary):'

If this returns 204 with the full set of Access-Control-* headers, the target is fine and every remaining step belongs to the edge. If it does not, fix the application first — nothing downstream can invent a grant it never received.

With that established, four settings decide the outcome, and their defaults are wrong for an API in three cases out of four:

Setting Default on a new behaviour What a CORS API needs Symptom when left at the default
AllowedMethods GET, HEAD The full set including OPTIONS The preflight is rejected at the edge with 403
OriginRequestPolicyId none A policy forwarding Origin and both Access-Control-Request-* headers The target sees no Origin and emits no grant
CachePolicyId a caching policy with no headers in the key A policy with Origin in the key, or no caching for credentialed paths One origin’s grant is replayed to another
ResponseHeadersPolicyId none Usually none — let the target own the grant Two conflicting grants when both layers emit headers

Step 2 — Forward Origin to the origin

CloudFront strips request headers that no policy asks for. Attach an origin request policy that forwards Origin together with the two preflight declaration headers. AWS ships a managed policy for exactly this, and resolving its id at run time is safer than pasting one:

POLICY_ID=$(aws cloudfront list-origin-request-policies --type managed \
  --query "OriginRequestPolicyList.Items[?OriginRequestPolicy.OriginRequestPolicyConfig.Name=='Managed-CORS-CustomOrigin'].OriginRequestPolicy.Id" \
  --output text)
echo "$POLICY_ID"

Set that id as OriginRequestPolicyId on the cache behaviour that matches your API path pattern. Until this is in place the application sees a request with no Origin at all, so it correctly declines to emit a grant — the failure looks like a broken allowlist but is really a missing input.

Step 3 — Put Origin in the cache key

Forwarding alone is not enough. If Origin is not part of the cache key, the first response cached for one origin is replayed to every other origin, producing the “not equal to the supplied origin” variant of the error. Create a cache policy that includes the header, and keep the TTLs at zero for a dynamic API:

{
  "Name": "api-cors-origin-in-key",
  "DefaultTTL": 0,
  "MaxTTL": 0,
  "MinTTL": 0,
  "ParametersInCacheKeyAndForwardedToOrigin": {
    "EnableAcceptEncodingGzip": true,
    "EnableAcceptEncodingBrotli": true,
    "HeadersConfig": {
      "HeaderBehavior": "whitelist",
      "Headers": { "Quantity": 1, "Items": ["Origin"] }
    },
    "CookiesConfig": { "CookieBehavior": "none" },
    "QueryStringsConfig": { "QueryStringBehavior": "all" }
  }
}
aws cloudfront create-cache-policy --cache-policy-config file://cache-policy.json

Keep Vary: Origin on the application’s responses as well. The cache key governs CloudFront; Vary governs every other cache between the edge and the user, and the reasoning for both is set out in Handling Vary: Origin Header Correctly.

The difference the key makes is visible as soon as a second origin calls the same path:

What the cache key does to a cross-origin grant Four rows pair a cache key with a requesting origin, the edge action, and the grant that gets served. With a path-only key the second origin receives the first origin's grant; with Origin in the key each origin gets its own entry. Cache key at the edge Requesting origin Edge action Grant served path only portal.helio-metrics.app miss, fetches portal path only admin.helio-metrics.app hit, same key portal — wrong path plus Origin admin.helio-metrics.app miss, fetches admin path plus Origin portal.helio-metrics.app hit, own key portal With Origin in the key each origin gets its own cached entry, so no grant is ever replayed to a stranger

Step 4 — Admit OPTIONS on the cache behaviour

A behaviour restricted to GET and HEAD rejects the preflight itself with a 403 and an x-cache: Error from cloudfront header — the request never reaches the load balancer, so no listener rule and no application setting can rescue it. Update the behaviour to the full method set:

"AllowedMethods": {
  "Quantity": 7,
  "Items": ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"],
  "CachedMethods": { "Quantity": 2, "Items": ["GET", "HEAD"] }
}

Leaving CachedMethods at GET and HEAD means preflight responses are not cached at the edge, so a policy change takes effect immediately and each browser still caches its own preflight for the duration you set with Access-Control-Max-Age.

Step 5 — Keep preflight handling off the load balancer

It is tempting to answer OPTIONS at the load balancer and save a hop. The fixed-response action cannot do it: it accepts a status code, a content type and a message body, and nothing else. The browser receives a 204 with no Access-Control-Allow-Methods and rejects the preflight — a failure that looks exactly like a missing application route.

# Correct: the OPTIONS request is forwarded like any other method.
aws elbv2 create-rule \
  --listener-arn "$LISTENER_ARN" \
  --priority 10 \
  --conditions Field=path-pattern,Values='/v2/*' \
  --actions Type=forward,TargetGroupArn="$TARGET_GROUP_ARN"

If a rule matching http-request-method with value OPTIONS already exists and terminates in a fixed response, delete it. Each layer’s real capabilities are worth keeping in view when you decide where the policy lives:

What each AWS layer can actually do for CORS Four capabilities compared across CloudFront, the Application Load Balancer and the target application. The load balancer cannot add response headers and can only return a bare fixed response, which is why preflight handling belongs on the target. Capability CloudFront Load balancer Target app Sees the browser's Origin header only if forwarded always only what arrives Can add arbitrary response headers headers policy no yes Can answer a preflight completely with a function status code only yes Can separate responses per origin cache key not applicable Vary: Origin Only one column can do all four, which is why the grant logic belongs on the target application

Step 6 — Invalidate, then re-test

Cached objects created under the old key survive the configuration change. Clear them before you judge the fix:

aws cloudfront create-invalidation \
  --distribution-id E2ABCDEF1234XY \
  --paths '/v2/*'

An invalidation is asynchronous, so give it a moment and then read the edge’s own verdict on every probe. The x-cache header tells you whether the response you are looking at came from the origin (Miss from cloudfront) or from a cached copy (Hit from cloudfront), and a non-zero age on a response that carries a grant is a warning sign in itself: the grant it carries was computed for whichever origin happened to ask first. Any conclusion drawn from a hit is a conclusion about yesterday’s configuration, which is why the verification below insists on a miss before it accepts the fix.

Verification

Repeat the preflight through the public hostname and read the whole picture — status, grant, Vary, and the edge’s own cache verdict:

curl -sS -o /dev/null -D - -X OPTIONS https://api.helio-metrics.app/v2/series \
  -H 'Origin: https://portal.helio-metrics.app' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: authorization,content-type' \
  | grep -iE '^(HTTP/|access-control|vary|x-cache|age):'

Then send the same request with the second origin and confirm the grant changes with it — identical grants for different origins mean the cache key still ignores Origin.

Security Boundary Note

Do not reach for a wildcard grant in a response headers policy to make the edge stop complaining. It applies to every path the behaviour matches, it cannot be combined with credentials, and it silently overrides the per-route decisions the application was making. Similarly, an Origin-keyed cache is a correctness and a confidentiality control: without it, a response generated for an authenticated origin can be served to a different one, which is the same failure mode that the audit in Fixing Duplicate Access-Control-Allow-Origin Headers uncovers from the other direction. Keep credentialed API paths on a behaviour that does not cache at all, and keep the allowlist in the application.

Common Mistakes

Mistake Technical impact Correct approach
Leaving the default cache behaviour on an API path Origin is neither forwarded nor keyed, so the target emits no grant Attach a CORS origin request policy and a cache policy that includes Origin
Answering OPTIONS with an ALB fixed-response rule The preflight gets a status but none of the Access-Control headers Forward OPTIONS to the target group and answer it in the application
Enabling a CORS response headers policy while the app also sets headers Two grants on one response, which the browser rejects outright Pick one layer; if you keep the policy, stop the application emitting the same headers
Judging the fix before invalidating Objects cached under the old key keep returning the old grant Invalidate the path prefix, then re-run both origin probes

FAQ

Does CloudFront add CORS headers to my responses by itself?

Only when you attach a response headers policy that contains a CORS configuration. By default CloudFront passes through whatever the origin returned, which means a missing grant is almost always a forwarding or caching problem rather than a header-injection problem. If you do attach such a policy, decide deliberately whether it overrides the origin’s own headers, because leaving both sides enabled with override on is how a single grant becomes two conflicting ones.

Why does my preflight return 403 with MethodNotAllowed from CloudFront?

The cache behaviour that matched the path does not permit OPTIONS. CloudFront rejects any method outside the behaviour’s allowed set before it ever contacts the origin, so the load balancer and the application never see the preflight and no configuration behind them can help. Switch the behaviour to the set that includes OPTIONS along with the write methods, and keep the cached-methods list limited to GET and HEAD unless you deliberately want preflight responses cached at the edge.

Should the load balancer or the application own the CORS policy?

The application, in almost every case. An Application Load Balancer cannot attach arbitrary response headers, so a fixed-response action can return a status for OPTIONS but never the Access-Control headers a preflight needs. That leaves the target as the only layer that can answer a preflight completely and the only layer that knows which origins each route trusts. Use listener rules for routing and health, and keep the grant logic in one place behind them.