Docs
API7 GatewayManage traffic and APIsTraffic ManagementConfigure CORS

Configure CORS

Configure wildcard and credentialed cross-origin access with deterministic preflight checks.

Applies to:API7 Gateway 3.10.x

Roles:Platform engineer

Goal

Create two independent CORS routes on one HTTPS upstream. /cors/public allows any origin without credentials. /cors/credentialed allows one named origin with credentials and explicitly lists its methods and request headers.

Before you begin

Set the variables used in this guide:

export API7_ADMIN_URL="https://localhost:7443"
export API7_GATEWAY_GROUP_ID="default"   # only the group the quickstart creates; otherwise copy the gateway group's UUID from Gateway Groups in the Dashboard
read -rp "Gateway group name for ADC (if used): " API7_GATEWAY_GROUP_NAME && export API7_GATEWAY_GROUP_NAME
export GATEWAY_URL="http://127.0.0.1:9080"
read -rsp "Dashboard token: " API7_ADMIN_KEY && export API7_ADMIN_KEY && echo
  • Confirm that the control plane and selected gateway group are ready:

    curl -ksS --connect-timeout 3 --max-time 10 -o /dev/null -w '%{http_code}\n' "${API7_ADMIN_URL}/api/gateway_groups" -H "X-API-KEY: ${API7_ADMIN_KEY}"
    curl -ksS --connect-timeout 3 --max-time 10 "${API7_ADMIN_URL}/api/gateway_groups/${API7_GATEWAY_GROUP_ID}/instances" -H "X-API-KEY: ${API7_ADMIN_KEY}" | grep -o '"status": *"Healthy"' | wc -l

    Expected: 200, then 1 or more.

  • The gateway can resolve and reach httpbin.org:443. This is a public demonstration service, so its availability and behavior are outside API7 Gateway's control.

  • curl, grep, and tr are available.

  • For ADC, confirm label-selector support and reserve the working files:

    adc --version
    adc sync --help | grep -- '--label-selector'
    for file in cors-adc.yaml cors-cleanup.yaml; do test ! -e "${file}" || { echo "Stop: ${file} already exists"; exit 1; }; done

    ADC resolves --gateway-group by display name; the compatibility Admin API uses the group's UUID. Enter the display name of the group identified by API7_GATEWAY_GROUP_ID.

This guide creates one service and two routes. Check every fixed Admin API identifier before using PUT:

curl -ksS --connect-timeout 3 --max-time 10 -w '\nservice: %{http_code}\n' "${API7_ADMIN_URL}/apisix/admin/services/cors-guide-service-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
for id in cors-public-id cors-credentialed-id; do
  curl -ksS --connect-timeout 3 --max-time 10 -w "\nroute ${id}: %{http_code}\n" "${API7_ADMIN_URL}/apisix/admin/routes/${id}?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
done

Expected: every resource is absent. A 200 response containing a resource means the ID is in use; stop or change every occurrence. Correct authentication, group, and request errors until absence is unambiguous because PUT replaces a resource at the same ID. The Admin API URL IDs deliberately differ from the name fields below. ADC also treats a display name and backend ID as separate fields; do not infer one from the other. For ADC, continue only if its diff shows creates for exactly the labeled resources in this guide.

Steps

Complete steps 1 and 2 for the Admin API path, or skip them and complete step 3 for ADC. Do not apply both paths.

Step 1: Create the upstream service

curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/services/cors-guide-service-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
  -H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" \
  -d '{
    "name": "cors-guide-service",
    "labels": {"guide": "cors"},
    "upstream": {
      "name": "default", "type": "roundrobin", "scheme": "https", "pass_host": "node",
      "nodes": [{"host": "httpbin.org", "port": 443, "weight": 100}]
    }
  }'

What changed: the service points to the HTTPS demonstration upstream. No public route uses it yet.

Step 2: Create public and credentialed CORS routes

curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/routes/cors-public-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
  -H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" \
  -d '{
    "name":"cors-public","labels":{"guide":"cors"},"paths":["/cors/public"],"methods":["GET","POST","OPTIONS"],"service_id":"cors-guide-service-id",
    "plugins":{"proxy-rewrite":{"uri":"/anything/cors-public"},"cors":{"allow_origins":"*","allow_methods":"GET,POST,OPTIONS","allow_headers":"*","max_age":600}}
  }'

curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/routes/cors-credentialed-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
  -H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" \
  -d '{
    "name":"cors-credentialed","labels":{"guide":"cors"},"paths":["/cors/credentialed"],"methods":["GET","POST","OPTIONS"],"service_id":"cors-guide-service-id",
    "plugins":{"proxy-rewrite":{"uri":"/anything/cors-credentialed"},"cors":{"allow_origins":"https://app.example","allow_methods":"GET,POST,OPTIONS","allow_headers":"Authorization,Content-Type","expose_headers":"X-Request-Id","max_age":600,"allow_credential":true}}
  }'

What changed: both examples can remain active because their public paths differ. The credentialed route explicitly configures every relevant allow-list field. When allow_credential is true, * is invalid in the other CORS attributes. API7 Gateway also accepts ** as a force-all value, but using it with credentials can expose cookies or authorization data to untrusted origins, so this procedure does not use it.

Step 3: Apply the complete desired state with ADC

Skip this alternative if you completed steps 1 and 2. Save the service and both routes in one file so each sync describes the complete selected state.

cors-adc.yaml
services:
  - name: cors-guide-service
    labels: {guide: cors}
    upstream:
      name: default
      scheme: https
      pass_host: node
      nodes:
        - {host: httpbin.org, port: 443, weight: 100}
    routes:
      - name: cors-public
        labels: {guide: cors}
        uris: [/cors/public]
        methods: [GET, POST, OPTIONS]
        plugins:
          proxy-rewrite: {uri: /anything/cors-public}
          cors:
            allow_origins: '*'
            allow_methods: GET,POST,OPTIONS
            allow_headers: '*'
            max_age: 600
      - name: cors-credentialed
        labels: {guide: cors}
        uris: [/cors/credentialed]
        methods: [GET, POST, OPTIONS]
        plugins:
          proxy-rewrite: {uri: /anything/cors-credentialed}
          cors:
            allow_origins: https://app.example
            allow_methods: GET,POST,OPTIONS
            allow_headers: Authorization,Content-Type
            expose_headers: X-Request-Id
            max_age: 600
            allow_credential: true

Lint, validate, inspect, and then apply the reviewed diff:

adc lint -f cors-adc.yaml
adc validate -f cors-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=cors
adc diff -f cors-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=cors
adc sync -f cors-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=cors

Expected: lint and validation succeed; the diff creates only the labeled service and its two labeled routes; sync affects only that reviewed set.

Validate

Send a preflight request to the public route:

PUBLIC_PREFLIGHT=$(curl -sS --connect-timeout 3 --max-time 15 -D - -o /dev/null "${GATEWAY_URL}/cors/public" -X OPTIONS \
  -H 'Origin: https://any.example' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: X-Demo' | tr -d '\r')
printf '%s\n' "${PUBLIC_PREFLIGHT}" | grep -qE '^HTTP/[0-9.]+ 200'
printf '%s\n' "${PUBLIC_PREFLIGHT}" | grep -qi '^Access-Control-Allow-Origin: \*$'
if printf '%s\n' "${PUBLIC_PREFLIGHT}" | grep -qi '^Access-Control-Allow-Credentials:'; then echo 'Unexpected credential permission'; exit 1; else echo 'Credentials not allowed'; fi

Expected: all checks succeed and the final command prints Credentials not allowed.

Check the allowed credentialed preflight and an actual cross-origin response:

CREDENTIAL_PREFLIGHT=$(curl -sS --connect-timeout 3 --max-time 15 -D - -o /dev/null "${GATEWAY_URL}/cors/credentialed" -X OPTIONS \
  -H 'Origin: https://app.example' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: Authorization,Content-Type' | tr -d '\r')
printf '%s\n' "${CREDENTIAL_PREFLIGHT}" | grep -qE '^HTTP/[0-9.]+ 200'
printf '%s\n' "${CREDENTIAL_PREFLIGHT}" | grep -qi '^Access-Control-Allow-Origin: https://app\.example$'
printf '%s\n' "${CREDENTIAL_PREFLIGHT}" | grep -qi '^Access-Control-Allow-Credentials: true$'
printf '%s\n' "${CREDENTIAL_PREFLIGHT}" | grep -qi '^Access-Control-Allow-Methods: GET,POST,OPTIONS$'
printf '%s\n' "${CREDENTIAL_PREFLIGHT}" | grep -qi '^Access-Control-Allow-Headers: Authorization,Content-Type$'
printf '%s\n' "${CREDENTIAL_PREFLIGHT}" | grep -qi '^Access-Control-Max-Age: 600$'

CREDENTIAL_RESPONSE=$(curl -sS --connect-timeout 3 --max-time 15 -D - -o /dev/null "${GATEWAY_URL}/cors/credentialed" -H 'Origin: https://app.example' | tr -d '\r')
printf '%s\n' "${CREDENTIAL_RESPONSE}" | grep -qi '^Access-Control-Allow-Origin: https://app\.example$'
printf '%s\n' "${CREDENTIAL_RESPONSE}" | grep -qi '^Access-Control-Allow-Credentials: true$'
printf '%s\n' "${CREDENTIAL_RESPONSE}" | grep -qi '^Access-Control-Expose-Headers: X-Request-Id$'

Expected: every assertion succeeds.

Finally, send a preflight request from an origin that is not allowed:

DENIED_PREFLIGHT=$(curl -sS --connect-timeout 3 --max-time 15 -D - -o /dev/null "${GATEWAY_URL}/cors/credentialed" -X OPTIONS \
  -H 'Origin: https://untrusted.example' -H 'Access-Control-Request-Method: POST' | tr -d '\r')
printf '%s\n' "${DENIED_PREFLIGHT}" | grep -qE '^HTTP/[0-9.]+ 200'
if printf '%s\n' "${DENIED_PREFLIGHT}" | grep -qi '^Access-Control-Allow-Origin:'; then echo 'Unexpected origin permission'; exit 1; else echo 'Origin not allowed'; fi

Expected: the status check succeeds and the final command prints Origin not allowed. The plugin handles OPTIONS with a 200 response, but the missing permission header makes this preflight fail in a browser. curl shows the headers; it does not enforce browser CORS policy.

Roll back / Clean up

For the Admin API path, delete routes before their service:

for id in cors-credentialed-id cors-public-id; do
  curl -ksS --connect-timeout 3 --max-time 15 -X DELETE "${API7_ADMIN_URL}/apisix/admin/routes/${id}?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
done
curl -ksS --connect-timeout 3 --max-time 15 -X DELETE "${API7_ADMIN_URL}/apisix/admin/services/cors-guide-service-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"

For ADC, save an empty selected state and review the deletion diff before syncing it:

cors-cleanup.yaml
services: []
adc lint -f cors-cleanup.yaml
adc diff -f cors-cleanup.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=cors
adc sync -f cors-cleanup.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=cors
rm -f cors-adc.yaml cors-cleanup.yaml

Expected: the reviewed diff and sync delete only this guide's labeled service and routes.

Troubleshoot

  • The credentialed configuration fails schema validation: confirm that no CORS attribute uses or inherits *; set origins, methods, and request headers explicitly.
  • A preflight returns 404: confirm that OPTIONS is in the route's methods and that the public path matches exactly.
  • A preflight returns 200 without permission headers: verify the request's Origin against allow_origins. That response denies browser access even though its HTTP status is successful.
  • The upstream request fails: verify DNS and outbound HTTPS access to httpbin.org:443. Preflight is handled at the gateway, while the actual request needs the upstream.
  • ADC proposes unrelated changes: stop and verify the gateway-group display name, guide=cors selector, labels, and complete desired-state file.