CORS on AWS ECS Fargate usually goes wrong for one boring reason: people configure it in the wrong layer.

I’ve seen teams add CORS headers in app code, then put an ALB, CloudFront, Nginx, or API Gateway in front of it and accidentally strip or duplicate headers. Then the browser says “CORS failed” and everybody starts guessing.

Here’s the practical way to think about it:

  • Browser enforces CORS
  • Your backend must return the right headers
  • Every proxy in front of your app must preserve them
  • Preflight OPTIONS requests must succeed
  • You cannot “fix CORS” from frontend code

If your app runs on ECS Fargate, CORS is not an ECS feature. ECS just runs containers. The actual CORS behavior comes from whatever is serving traffic:

  • your app container
  • Nginx or Envoy sidecar
  • Application Load Balancer
  • API Gateway in front of ECS
  • CloudFront in front of ALB

That distinction matters.

The CORS headers you actually care about

A basic CORS response for a browser request might look like this:

Access-Control-Allow-Origin: https://app.example.com
Vary: Origin

For preflight requests:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600
Vary: Origin

If you use cookies or HTTP auth across origins:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

And this is the rule people break all the time:

  • If you send Access-Control-Allow-Credentials: true
  • you cannot use Access-Control-Allow-Origin: *

Browsers reject that combination.

For a real-world example, api.github.com sends:

access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset

That’s a good reminder that Access-Control-Expose-Headers matters if frontend code needs to read non-simple response headers like ETag or rate-limit metadata.

Typical ECS Fargate architecture

A common setup looks like this:

Browser
  -> ALB
    -> ECS Fargate service
      -> app container

Sometimes it becomes:

Browser
  -> CloudFront
    -> ALB
      -> ECS Fargate
        -> Nginx
          -> app

Or:

Browser
  -> API Gateway
    -> VPC Link / ALB
      -> ECS Fargate

Every extra hop is one more place to break CORS.

My preference: handle CORS in the application unless you have a strong reason not to. That keeps policy close to the API behavior. If you terminate CORS at Nginx or API Gateway, do it intentionally and make sure the app isn’t also injecting conflicting headers.

Option 1: Handle CORS in your Node.js app on Fargate

Here’s an Express example that works well for ECS.

import express from "express";

const app = express();

const allowedOrigins = new Set([
  "https://app.example.com",
  "https://admin.example.com",
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;

  if (origin && allowedOrigins.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Access-Control-Expose-Headers", "ETag, Link, X-Request-Id");
    res.setHeader("Vary", "Origin");
  }

  if (req.method === "OPTIONS") {
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With");
    res.setHeader("Access-Control-Max-Age", "600");
    return res.status(204).end();
  }

  next();
});

app.get("/api/health", (req, res) => {
  res.json({ ok: true });
});

app.listen(3000, () => {
  console.log("listening on 3000");
});

A few things I like here:

  • origins are explicitly allowlisted
  • preflight is handled early
  • Vary: Origin is set so caches don’t serve the wrong origin-specific response
  • exposed headers are deliberate

If your frontend uses fetch(..., { credentials: "include" }), this pattern is mandatory. Do not switch to wildcard origin.

Dockerfile for Fargate

FROM node:22-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

EXPOSE 3000
CMD ["node", "server.js"]
```text

Once deployed to Fargate behind an ALB, the ALB usually just forwards requests. That part is easy. The hard part is making sure health checks and routing don’t swallow `OPTIONS` accidentally.

## Option 2: Handle CORS in Nginx inside the container

If your ECS task uses Nginx as a reverse proxy, you can do CORS there. I only do this when I need one policy for multiple upstream apps.

server { listen 80; server_name _;

location /api/ {
    set $cors_origin "";

    if ($http_origin ~* "^https://(app|admin)\.example\.com$") {
        set $cors_origin $http_origin;
    }

    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Credentials "true" always;
        add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With" always;
        add_header Access-Control-Max-Age "600" always;
        add_header Vary "Origin" always;
        return 204;
    }

    add_header Access-Control-Allow-Origin $cors_origin always;
    add_header Access-Control-Allow-Credentials "true" always;
    add_header Access-Control-Expose-Headers "ETag, Link, X-Request-Id" always;
    add_header Vary "Origin" always;

    proxy_pass http://127.0.0.1:3000;
}

}


Two gotchas:

1. **Use `always`** so headers are added even on error responses.
2. Don’t blindly reflect any `Origin` unless you really mean “allow any site.” Reflection without validation is just a sloppy wildcard.

## ALB and ECS: what ALB does not do for you

Application Load Balancer is great for routing. It is not your CORS policy engine.

ALB can forward `OPTIONS` just fine, but it won’t magically generate good preflight responses for your app. If your target returns 404, 405, or 500 for `OPTIONS`, the browser sees a CORS failure.

Make sure:

- listener rules route `OPTIONS` to the same target group as normal API requests
- your app or proxy responds to `OPTIONS`
- target group health checks don’t confuse you into thinking all methods work just because `GET /health` works

A classic failure mode:

- `GET /api/users` works in Postman
- browser sends preflight `OPTIONS /api/users`
- app returns `405 Method Not Allowed`
- browser blocks actual request
- backend team says “the API is up”

That’s not a browser problem. That’s your API refusing preflight.

## API Gateway in front of ECS Fargate

If you put API Gateway in front of ECS, decide whether **API Gateway** or **your app** owns CORS.

Don’t do both unless the configs are identical.

For HTTP API, AWS can manage CORS configuration. That’s convenient for simple cases. But if your backend needs dynamic origin logic, credentials, or custom exposed headers, I’d rather keep it in app code.

A minimal Terraform example for API Gateway HTTP API CORS looks like this:

resource “aws_apigatewayv2_api” “http_api” { name = “ecs-api” protocol_type = “HTTP”

cors_configuration { allow_origins = [ “https://app.example.com” ] allow_methods = [“GET”, “POST”, “PUT”, “DELETE”, “OPTIONS”] allow_headers = [“content-type”, “authorization”] expose_headers = [“etag”, “link”, “x-request-id”] allow_credentials = true max_age = 600 } }


This is fine if the API only serves one or two trusted browser origins.

## A stricter pattern for production

For production ECS services, I usually want environment-driven origin control.

const allowedOrigins = new Set( (process.env.CORS_ALLOWED_ORIGINS || “”) .split(",") .map(s => s.trim()) .filter(Boolean) );

function applyCors(req, res) { const origin = req.headers.origin; if (!origin) return false; if (!allowedOrigins.has(origin)) return false;

res.setHeader(“Access-Control-Allow-Origin”, origin); res.setHeader(“Access-Control-Allow-Credentials”, “true”); res.setHeader(“Access-Control-Allow-Methods”, “GET, POST, PUT, PATCH, DELETE, OPTIONS”); res.setHeader(“Access-Control-Allow-Headers”, “Content-Type, Authorization”); res.setHeader(“Access-Control-Expose-Headers”, “ETag, Link, X-Request-Id”); res.setHeader(“Access-Control-Max-Age”, “600”); res.setHeader(“Vary”, “Origin”); return true; }


Then in your ECS task definition:

{ “name”: “api”, “image”: “123456789012.dkr.ecr.us-east-1.amazonaws.com/api:latest”, “environment”: [ { “name”: “CORS_ALLOWED_ORIGINS”, “value”: “https://app.example.com,https://admin.example.com” } ] }


That makes promotion across dev, staging, and prod much less painful.

## How to test CORS on Fargate

Use `curl` to simulate browser behavior. Don’t start with the browser console. It hides too much.

### Simple request

curl -i https://api.example.com/api/health
-H “Origin: https://app.example.com


You want to see:

Access-Control-Allow-Origin: https://app.example.com Vary: Origin


### Preflight request

curl -i -X OPTIONS https://api.example.com/api/users
-H “Origin: https://app.example.com
-H “Access-Control-Request-Method: POST”
-H “Access-Control-Request-Headers: content-type,authorization”


You want something like:

HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Allow-Credentials: true Access-Control-Max-Age: 600 Vary: Origin


If that fails, inspect each layer:

- CloudFront response headers policy
- API Gateway CORS config
- ALB listener and target group routing
- Nginx config
- app middleware order

## Common mistakes I keep seeing

### 1. Wildcard with credentials

Broken:

Access-Control-Allow-Origin: * Access-Control-Allow-Credentials: true


Pick one:
- public API with `*`
- credentialed API with explicit origins

### 2. Missing `Vary: Origin`

If responses are cached and origin-specific, you need:

Vary: Origin


Without it, one tenant’s origin can poison another tenant’s cached response.

### 3. Forgetting exposed headers

If frontend code reads `ETag` or `Link`, expose them:

Access-Control-Expose-Headers: ETag, Link


GitHub exposes a long list for exactly this reason.

### 4. Handling only 200 responses

Browsers still apply CORS rules to 401, 403, and 500 responses. If your app only adds headers on success, debugging auth failures becomes miserable.

### 5. Preflight blocked by auth middleware

Your auth layer should usually skip authentication for `OPTIONS`. Preflight is permission negotiation, not the real API action.

## Security angle

CORS is not an auth control. It stops browsers from reading cross-origin responses. It does not stop direct server-to-server requests, curl, bots, or malicious scripts running on allowed origins.

Treat CORS as a browser access policy, not a firewall.

If you’re also hardening your ECS app with headers like `Content-Security-Policy`, that’s a separate concern. For broader header strategy, see the official AWS docs for your edge layer and, if you need CSP-specific guidance, [https://csp-guide.com](https://csp-guide.com).

For AWS docs, the ones worth keeping open are the official pages for:

- ECS task definitions
- Application Load Balancer listeners and target groups
- API Gateway CORS configuration
- CloudFront response headers policies

Use the AWS docs for the infrastructure behavior, and keep the actual CORS policy as close to your application as possible.

That’s the setup that breaks the least on ECS Fargate.