AWS AppSync looks simple from the browser: send a GraphQL POST, get JSON back, move on. Then CORS shows up and burns half a day.
I’ve seen the same pattern over and over: the GraphQL API works in Postman, works in the AWS console, maybe even works from a local script, but the browser throws a CORS error that tells you almost nothing useful. AppSync is especially good at this because the problem is often not “CORS in AppSync” by itself. It’s usually some combination of custom domains, auth mode, preflight behavior, CloudFront, cookies, or headers your frontend is trying to send.
Here are the common mistakes I see with CORS for AWS AppSync, and the fixes that actually work.
Mistake #1: Assuming AppSync CORS is “broken” when the real issue is preflight
A lot of AppSync requests trigger a preflight OPTIONS request before the real POST /graphql. If that preflight fails, the browser blocks the actual request.
Typical frontend code:
await fetch("https://api.example.com/graphql", {
method: "POST",
headers: {
"content-type": "application/json",
"authorization": token,
"x-api-key": apiKey
},
body: JSON.stringify({
query: `
query GetUser {
getUser(id: "123") { id name }
}
`
})
});
That request is not “simple” because of:
application/jsonauthorizationx-api-key
So the browser sends something like:
OPTIONS /graphql HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type,x-api-key
If the response doesn’t allow those headers and method, you get blocked.
Fix
Make sure the endpoint answering OPTIONS includes the right headers:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST,OPTIONS
Access-Control-Allow-Headers: authorization,content-type,x-api-key
If you’re using the default AppSync endpoint, AWS generally handles the basics. Problems usually start when you put AppSync behind:
- CloudFront
- a reverse proxy
- a custom domain setup
- WAF rules or edge logic that interfere with
OPTIONS
If you’re debugging this, don’t guess. Check the actual response headers with browser devtools or a header testing tool like HeaderTest.
Mistake #2: Returning * for everything, then trying to use credentials
People love Access-Control-Allow-Origin: * because it makes quick tests pass. Then later they add cookies or credentialed requests and everything breaks.
The browser refuses credentialed cross-origin requests when the server responds with:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
That combination is invalid.
Fix
If you need cookies or any credentialed browser request, return the exact origin:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
That Vary: Origin matters if you have caching in front of AppSync. Without it, one origin’s response can get cached and served to another.
If you’re not using cookies and you’re authenticating with bearer tokens or API keys in headers, you probably don’t need Allow-Credentials at all. Don’t turn it on unless you actually need it.
Mistake #3: Forgetting that AppSync auth headers must be allowed in preflight
AppSync commonly uses:
Authorizationx-api-keyx-amz-datex-amz-security-tokenx-amz-user-agent
If your frontend uses IAM-signed requests, Cognito tokens, or API keys, preflight needs to allow those headers.
I’ve seen teams allow only content-type and then wonder why browser requests fail while server-side requests work.
Fix
Match what your browser really sends. For example:
Access-Control-Allow-Headers: authorization,content-type,x-api-key,x-amz-date,x-amz-security-token,x-amz-user-agent
Access-Control-Allow-Methods: POST,OPTIONS
Access-Control-Allow-Origin: https://app.example.com
Don’t blindly paste giant header lists forever. Start from real traffic and keep it tight enough to stay understandable.
Mistake #4: Breaking CORS with a custom domain or CloudFront behavior
This is where AppSync gets annoying. The default AppSync endpoint may behave fine, but once you stick CloudFront or another proxy in front, CORS can fail because:
OPTIONSis not forwarded- the
Originheader is not forwarded - response headers are overwritten
- caching ignores origin differences
- only
POSTis allowed in the behavior
Fix
If CloudFront sits in front of AppSync:
- Allow
OPTIONSin the cache behavior. - Forward the
Origin,Access-Control-Request-Method, andAccess-Control-Request-Headersheaders when needed. - Make sure response headers policy doesn’t conflict with what AppSync returns.
- Add
Vary: Originif origins differ. - Verify that
OPTIONS /graphqlreaches something that can answer correctly.
A lot of “AppSync CORS issues” are really CloudFront config issues.
Mistake #5: Not exposing response headers your frontend needs
CORS doesn’t just control whether the request is allowed. It also controls which response headers JavaScript can read.
By default, your frontend cannot read most custom headers unless they’re listed in Access-Control-Expose-Headers.
A real-world example from api.github.com:
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, Warning
That’s a good example of doing this deliberately. GitHub knows clients may need rate limit and pagination headers, so they expose them.
Fix
If your frontend needs headers like request IDs, rate-limit values, pagination links, or ETags, expose them:
Access-Control-Expose-Headers: ETag, Link, X-Request-Id, X-RateLimit-Remaining
For AppSync specifically, this often matters less for raw GraphQL data and more when you’ve added extra layers that inject useful headers.
Mistake #6: Testing with curl and assuming the browser will behave the same
curl is great, but it doesn’t enforce CORS. Postman doesn’t either. They’ll happily send requests the browser blocks.
I still use curl to inspect headers, but never as proof that browser CORS is correct.
Example:
curl -i -X OPTIONS "https://api.example.com/graphql" \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type,x-api-key"
That’s useful because it simulates preflight. But the final check has to happen in a browser.
Fix
Test three things:
- The
OPTIONSresponse - The actual
POSTresponse - The browser console/network panel
If one of those is skipped, you’ll miss the real problem.
Mistake #7: Using multiple auth modes without checking all frontend paths
AppSync often starts simple and grows weird:
- public API key access for one route
- Cognito auth for signed-in users
- IAM auth for admin tooling
Each mode can change which headers are sent, which means CORS can fail only for some users or some screens.
That’s why one page works and another page throws a CORS error even though both hit the same GraphQL endpoint.
Fix
Inventory the actual browser request headers per auth mode.
For example:
API key flow
content-type: application/json
x-api-key: abc123
Cognito flow
content-type: application/json
authorization: eyJ...
IAM-signed flow
content-type: application/json
authorization: AWS4-HMAC-SHA256 ...
x-amz-date: 20260701T120000Z
x-amz-security-token: ...
x-amz-user-agent: aws-amplify/...
Your CORS config has to support the headers used by every browser-based flow you actually ship.
Mistake #8: Treating CORS like access control
CORS is a browser rule, not a real authorization boundary.
If your AppSync API is publicly reachable, CORS does not stop non-browser clients from calling it. It just tells browsers whether frontend JavaScript from another origin can read the response.
I still see teams say “we’ll lock it down with CORS.” No, you won’t.
Fix
Use real auth:
- Cognito
- IAM
- API keys where appropriate, ideally for low-risk/public use only
- field-level auth in your schema and resolvers
If you’re working on broader browser security too, CORS is only one piece. Things like CSP, HSTS, and frame protections matter as well; csp-guide.com is useful for the CSP side.
A sane CORS checklist for AppSync
When I troubleshoot AppSync CORS, I check this in order:
-
What is the exact browser origin?
http://localhost:3000is different fromhttps://app.example.com
-
Is there a preflight?
- Usually yes for GraphQL POSTs with auth headers
-
Does
OPTIONS /graphqlreturn:Access-Control-Allow-OriginAccess-Control-Allow-Methods: POST, OPTIONSAccess-Control-Allow-Headersmatching the real request
-
If using credentials, is the origin explicit instead of
*? -
If using CloudFront or a proxy, are
OPTIONSandOriginhandled correctly? -
Does caching vary by origin where needed?
Vary: Origin
-
Does the frontend need any exposed response headers?
- If yes, add
Access-Control-Expose-Headers
- If yes, add
A practical baseline
For a token-based SPA talking to AppSync without cookies, this is a reasonable baseline:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST,OPTIONS
Access-Control-Allow-Headers: authorization,content-type,x-api-key,x-amz-date,x-amz-security-token,x-amz-user-agent
Vary: Origin
And if you need readable custom response headers:
Access-Control-Expose-Headers: ETag, Link, X-Request-Id
That won’t solve every AppSync setup, but it covers the mistakes I see most often.
The big lesson: when AppSync CORS breaks, stop staring at the GraphQL query. Look at the OPTIONS request, the proxy layer, and the exact headers your browser is sending. That’s usually where the bug is hiding.