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
OPTIONSrequests 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: Originis 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.