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/json
  • authorization
  • x-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:

  • Authorization
  • x-api-key
  • x-amz-date
  • x-amz-security-token
  • x-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:

  • OPTIONS is not forwarded
  • the Origin header is not forwarded
  • response headers are overwritten
  • caching ignores origin differences
  • only POST is allowed in the behavior

Fix

If CloudFront sits in front of AppSync:

  1. Allow OPTIONS in the cache behavior.
  2. Forward the Origin, Access-Control-Request-Method, and Access-Control-Request-Headers headers when needed.
  3. Make sure response headers policy doesn’t conflict with what AppSync returns.
  4. Add Vary: Origin if origins differ.
  5. Verify that OPTIONS /graphql reaches 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:

  1. The OPTIONS response
  2. The actual POST response
  3. 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:

  1. What is the exact browser origin?

    • http://localhost:3000 is different from https://app.example.com
  2. Is there a preflight?

    • Usually yes for GraphQL POSTs with auth headers
  3. Does OPTIONS /graphql return:

    • Access-Control-Allow-Origin
    • Access-Control-Allow-Methods: POST, OPTIONS
    • Access-Control-Allow-Headers matching the real request
  4. If using credentials, is the origin explicit instead of *?

  5. If using CloudFront or a proxy, are OPTIONS and Origin handled correctly?

  6. Does caching vary by origin where needed?

    • Vary: Origin
  7. Does the frontend need any exposed response headers?

    • If yes, add Access-Control-Expose-Headers

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.