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 -lExpected:
200, then1or 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, andtrare 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; }; doneADC resolves
--gateway-groupby display name; the compatibility Admin API uses the group's UUID. Enter the display name of the group identified byAPI7_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}"
doneExpected: 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.
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: trueLint, 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=corsExpected: 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'; fiExpected: 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'; fiExpected: 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:
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.yamlExpected: 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 thatOPTIONSis in the route's methods and that the public path matches exactly. - A preflight returns
200without permission headers: verify the request'sOriginagainstallow_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=corsselector, labels, and complete desired-state file.