Configuring CORS
Cross-Origin Resource Sharing (CORS) controls which web applications on different domains can access your API. Zuplo handles CORS at the gateway level, automatically responding to preflight requests and adding the appropriate headers to responses.
Built-in Policies
Every route has a corsPolicy property in its x-zuplo-route configuration.
Zuplo provides two built-in policies:
none
Disables CORS for the route. All CORS headers are stripped from responses, and
preflight OPTIONS requests return a 404 response. This is the default when
no corsPolicy is set.
config/routes.oas.json
anything-goes
Allows any origin, method, and header. This is useful for development or internal APIs but is not recommended for production. It sets:
Access-Control-Allow-Origin: The requesting origin (reflected back)Access-Control-Allow-Methods: The route's configured methodsAccess-Control-Allow-Headers:*Access-Control-Expose-Headers:*Access-Control-Allow-Credentials:trueAccess-Control-Max-Age:600
config/routes.oas.json
Custom CORS Policies
For production use, create custom CORS policies with fine-grained control over which origins, methods, and headers are allowed.
Custom CORS policies are defined in the policies.json file alongside regular
policies, under the corsPolicies array:
config/policies.json
Then reference the policy by name on each route:
config/routes.oas.json
You can also select the CORS policy from the route designer dropdown in the Zuplo Portal.
Policy Properties
| Property | Type | Required | Description |
|---|---|---|---|
name | string | Yes | A unique name used to reference this policy on routes. |
allowedOrigins | string[] or string | Yes | Origins permitted to make cross-origin requests. Supports wildcards (see Origin Matching). |
allowedMethods | string[] or string | No | HTTP methods allowed for cross-origin requests (e.g., GET, POST). |
allowedHeaders | string[] or string | No | Request headers the client can send. Use * to allow any header. |
exposeHeaders | string[] or string | No | Response headers the browser can access from JavaScript. |
maxAge | number | No | Time in seconds the browser caches preflight results. |
allowCredentials | boolean | No | Whether to include credentials (cookies, authorization headers) in cross-origin requests. |
All list properties (allowedOrigins, allowedMethods, allowedHeaders,
exposeHeaders) accept either a JSON array of strings or a single
comma-separated string:
Code
Do not include a trailing / on origin values. For example,
https://example.com is valid but https://example.com/ does not work.
Origin Matching
The allowedOrigins property supports several matching patterns:
Exact Match
Specify the full origin including the protocol:
Code
Origin matching is case-insensitive, so https://APP.EXAMPLE.COM matches
https://app.example.com.
Wildcard (*)
Allow any origin:
Code
Subdomain Wildcards
Use *. to match a single subdomain level:
Code
This matches https://app.example.com and https://api.example.com, but does
not match:
https://example.com(no subdomain)https://v2.api.example.com(multi-level subdomain)
Wildcards with Ports
Subdomain wildcards work with ports:
Code
This matches http://app.localhost:3000 but not http://localhost:3000.
Multiple Patterns
Combine exact origins and wildcard patterns:
Code
Using Environment Variables
Use environment variables to configure different origins per environment:
config/policies.json
Set the environment variable as a comma-separated string:
Code
Environment variables work for allowedOrigins, allowedMethods,
allowedHeaders, and exposeHeaders.
How CORS Works in Zuplo
Preflight Requests
When a browser makes a cross-origin request that requires preflight, it sends an
OPTIONS request with Origin and Access-Control-Request-Method headers.
Zuplo handles these automatically:
- Zuplo matches the
OPTIONSrequest path and the requested method to a configured route. - If the route has a CORS policy, Zuplo checks whether the request origin
matches the policy's
allowedOrigins. - If the origin matches, Zuplo responds with a
200 OKand the appropriate CORS headers. - If the origin does not match or the route has no CORS policy, Zuplo responds
with a
404 Not Found.
Preflight handling runs before any policies or handlers on the route.
Simple Requests
For simple cross-origin requests (e.g., GET with standard headers), there is
no preflight. Zuplo adds CORS headers to the response based on the route's
policy. If the origin does not match, no CORS headers are added and the browser
blocks the response.
Header Precedence
Zuplo strips any existing CORS headers from upstream responses before applying the configured policy headers. This prevents conflicts and ensures the gateway is the single source of truth for CORS configuration.
Troubleshooting
No CORS headers in response
- Verify the route has a
corsPolicyset (notnone). - Check that the request includes an
Originheader. Browsers add this automatically for cross-origin requests, but tools likecurldo not. - Confirm the
Originvalue matches one of theallowedOriginspatterns exactly (including the protocol likehttps://).
Preflight returns 404
- Ensure the
corsPolicyon the matching route is not set tonone. - Verify the
Access-Control-Request-Methodheader in the preflight request matches a method configured on the route. - Check that the request path matches an existing route.
Preflight returns 400
- The preflight request must include both the
OriginandAccess-Control-Request-Methodheaders. A400response means one or both are missing.
Wildcard subdomain not matching
- The
*.pattern only matches a single subdomain level.https://*.example.comdoes not matchhttps://v2.api.example.com. - The
*.pattern does not match the base domain.https://*.example.comdoes not matchhttps://example.com. Add the base domain separately if needed.
Credentials not working
- Set
allowCredentialstotruein the CORS policy. - When using credentials,
allowedOriginscannot rely on a literal*being sent as theAccess-Control-Allow-Originheader value. Zuplo reflects the actual requesting origin instead, which is compatible with credentials.
Backend CORS headers conflicting with Zuplo
If your backend service sends its own Access-Control-* headers, they can
conflict with the headers Zuplo sets from the CORS policy. Zuplo strips existing
CORS headers from upstream responses before applying the configured policy, but
if you have custom outbound policies that interact with the response, backend
CORS headers may leak through.
To prevent conflicts, use the Remove Response Headers outbound policy to explicitly strip CORS headers from your backend response:
config/policies.json
Alternatively, disable CORS on your backend entirely and let Zuplo be the single source of truth for CORS configuration.
Browser shows "CORS error" but the real issue is a 401 or 403
When an API request fails with a 401 Unauthorized or 403 Forbidden response,
the browser often reports it as a CORS error. This happens because the error
response may not include the required Access-Control-Allow-Origin header, so
the browser blocks access to the response entirely and surfaces a generic CORS
message.
This is especially common when an inbound policy (such as API key
authentication) rejects the request before the handler runs. The preflight
OPTIONS request succeeds because it runs before any policies, but the actual
GET or POST request gets rejected by the authentication policy.
To diagnose this:
- Check the request in the browser's Network tab. Look at the actual HTTP
status code -- if it is
401or403, the problem is authentication, not CORS. - Test the same request with
curland include anOriginheader to see the full response:Code - Fix the underlying authentication issue. Once the request returns a successful response, the CORS headers are included and the browser error goes away.
CORS headers lost in custom outbound policies
When a custom outbound policy creates a new Response object, CORS headers that
Zuplo added can be lost if the new response does not carry them forward. Zuplo
applies CORS headers after the handler runs but before outbound policies
execute, so any outbound policy that replaces the response must preserve the
existing headers.
Always pass the original response headers when constructing a new Response:
modules/my-outbound-policy.ts
If you need to modify headers, copy them into a new Headers object first:
Code
Avoid constructing a Response with no headers argument or with an empty
Headers object, as this drops all CORS headers and causes the browser to block
the response.
CORS on localhost during development
When developing locally, the browser enforces CORS even for localhost. Common
issues include:
- Port mismatch:
http://localhost:3000andhttp://localhost:5173are different origins. Add each port you use toallowedOrigins. - Protocol mismatch:
http://localhost:3000andhttps://localhost:3000are different origins. Make sure the protocol matches. - Missing localhost: If you use a custom CORS policy without
localhostinallowedOrigins, browser requests from your local development server are blocked.
For development, either add your local origins to a custom CORS policy:
Code
Or use environment variables to keep production and development origins separate:
config/policies.json
Then set different values per environment:
- Production:
ALLOWED_ORIGINS=https://app.example.com - Development:
ALLOWED_ORIGINS=https://app.example.com, http://localhost:3000
Use the anything-goes built-in policy for quick local testing when you do not
need to validate CORS behavior. Switch to a custom policy before deploying to
production.
For more details on CORS, see the MDN documentation: Cross-Origin Resource Sharing (CORS).