ZuploZuplo
LoginSign Up
  • Documentation
  • API Reference
Introduction
Getting Started
    Develop using the Portal
      1 - Setup Your Gateway2 - Rate Limiting3 - API Key Auth4 - Deploy5 - Dynamic Rate LimitingMCP - Quick start
    Develop Locally
      1 - Setup Your Gateway2 - Rate Limiting3 - API Key Auth
Concepts
Development
    CORSEnvironment VariablesBranch-Based DeploymentsTestingTroubleshootingGitOps vs TerraformCustom Code
    Local Development
    Guides
Policies
Handlers
API Keys
MCP Server
MCP Gateway
AI Gateway
Developer Portal
Monetization
Deploying & Source Control
Observability
Networking & Infrastructure
Account Management
Programming API
Build with AI
Zuplo CLI
Migration Guides
Platform LimitsSecuritySupportTrust & ComplianceChangelog
powered by Zudoku
Development

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
"x-zuplo-route": { "corsPolicy": "none", "handler": { "export": "urlForwardHandler", "module": "$import(@zuplo/runtime)" } }

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 methods
  • Access-Control-Allow-Headers: *
  • Access-Control-Expose-Headers: *
  • Access-Control-Allow-Credentials: true
  • Access-Control-Max-Age: 600
config/routes.oas.json
"x-zuplo-route": { "corsPolicy": "anything-goes", "handler": { "export": "urlForwardHandler", "module": "$import(@zuplo/runtime)" } }

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
{ "policies": [], "corsPolicies": [ { "name": "my-cors-policy", "allowedOrigins": [ "https://app.example.com", "https://admin.example.com" ], "allowedMethods": ["GET", "POST", "PUT", "DELETE"], "allowedHeaders": ["Authorization", "Content-Type"], "exposeHeaders": ["X-Request-Id"], "maxAge": 3600, "allowCredentials": true } ] }

Then reference the policy by name on each route:

config/routes.oas.json
"x-zuplo-route": { "corsPolicy": "my-cors-policy", "handler": { "export": "urlForwardHandler", "module": "$import(@zuplo/runtime)" } }

You can also select the CORS policy from the route designer dropdown in the Zuplo Portal.

Policy Properties

PropertyTypeRequiredDescription
namestringYesA unique name used to reference this policy on routes.
allowedOriginsstring[] or stringYesOrigins permitted to make cross-origin requests. Supports wildcards (see Origin Matching).
allowedMethodsstring[] or stringNoHTTP methods allowed for cross-origin requests (e.g., GET, POST).
allowedHeadersstring[] or stringNoRequest headers the client can send. Use * to allow any header.
exposeHeadersstring[] or stringNoResponse headers the browser can access from JavaScript.
maxAgenumberNoTime in seconds the browser caches preflight results.
allowCredentialsbooleanNoWhether 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
// Array format "allowedOrigins": ["https://app.example.com", "https://admin.example.com"] // Comma-separated string format "allowedOrigins": "https://app.example.com, https://admin.example.com"

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
"allowedOrigins": ["https://app.example.com"]

Origin matching is case-insensitive, so https://APP.EXAMPLE.COM matches https://app.example.com.

Wildcard (*)

Allow any origin:

Code
"allowedOrigins": ["*"]

Subdomain Wildcards

Use *. to match a single subdomain level:

Code
"allowedOrigins": ["https://*.example.com"]

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
"allowedOrigins": ["http://*.localhost:3000"]

This matches http://app.localhost:3000 but not http://localhost:3000.

Multiple Patterns

Combine exact origins and wildcard patterns:

Code
"allowedOrigins": [ "https://*.example.com", "https://specific.domain.com", "http://localhost:3000" ]

Using Environment Variables

Use environment variables to configure different origins per environment:

config/policies.json
{ "corsPolicies": [ { "name": "my-cors-policy", "allowedOrigins": "$env(ALLOWED_ORIGINS)", "allowedHeaders": "$env(ALLOWED_HEADERS)", "allowedMethods": ["GET", "POST", "PUT"], "maxAge": 600, "allowCredentials": true } ] }

Set the environment variable as a comma-separated string:

Code
ALLOWED_ORIGINS=https://app.example.com, https://admin.example.com

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:

  1. Zuplo matches the OPTIONS request path and the requested method to a configured route.
  2. If the route has a CORS policy, Zuplo checks whether the request origin matches the policy's allowedOrigins.
  3. If the origin matches, Zuplo responds with a 200 OK and the appropriate CORS headers.
  4. 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 corsPolicy set (not none).
  • Check that the request includes an Origin header. Browsers add this automatically for cross-origin requests, but tools like curl do not.
  • Confirm the Origin value matches one of the allowedOrigins patterns exactly (including the protocol like https://).

Preflight returns 404

  • Ensure the corsPolicy on the matching route is not set to none.
  • Verify the Access-Control-Request-Method header 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 Origin and Access-Control-Request-Method headers. A 400 response means one or both are missing.

Wildcard subdomain not matching

  • The *. pattern only matches a single subdomain level. https://*.example.com does not match https://v2.api.example.com.
  • The *. pattern does not match the base domain. https://*.example.com does not match https://example.com. Add the base domain separately if needed.

Credentials not working

  • Set allowCredentials to true in the CORS policy.
  • When using credentials, allowedOrigins cannot rely on a literal * being sent as the Access-Control-Allow-Origin header 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
{ "name": "strip-backend-cors-headers", "policyType": "remove-headers-outbound", "handler": { "export": "RemoveHeadersOutboundPolicy", "module": "$import(@zuplo/runtime)", "options": { "headers": [ "access-control-allow-origin", "access-control-allow-methods", "access-control-allow-headers", "access-control-expose-headers", "access-control-allow-credentials", "access-control-max-age" ] } } }

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:

  1. Check the request in the browser's Network tab. Look at the actual HTTP status code -- if it is 401 or 403, the problem is authentication, not CORS.
  2. Test the same request with curl and include an Origin header to see the full response:
    TerminalCode
    curl -v -H "Origin: https://app.example.com" \ -H "Authorization: Bearer YOUR_TOKEN" \ https://your-api.zuplo.dev/your-route
  3. 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
export default async function ( response: Response, request: ZuploRequest, context: ZuploContext, ) { const data = await response.json(); // Transform the data as needed data.transformed = true; // Preserve headers (including CORS headers) from the original response return new Response(JSON.stringify(data), { status: response.status, headers: response.headers, }); }

If you need to modify headers, copy them into a new Headers object first:

Code
const headers = new Headers(response.headers); headers.set("x-custom-header", "value"); return new Response(body, { status: response.status, headers, });

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:3000 and http://localhost:5173 are different origins. Add each port you use to allowedOrigins.
  • Protocol mismatch: http://localhost:3000 and https://localhost:3000 are different origins. Make sure the protocol matches.
  • Missing localhost: If you use a custom CORS policy without localhost in allowedOrigins, browser requests from your local development server are blocked.

For development, either add your local origins to a custom CORS policy:

Code
"allowedOrigins": [ "https://app.example.com", "http://localhost:3000", "http://localhost:5173" ]

Or use environment variables to keep production and development origins separate:

config/policies.json
{ "corsPolicies": [ { "name": "my-cors-policy", "allowedOrigins": "$env(ALLOWED_ORIGINS)" } ] }

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).

Edit this page
Last modified on March 27, 2026
Development OptionsEnvironment Variables
On this page
  • Built-in Policies
    • none
    • anything-goes
  • Custom CORS Policies
    • Policy Properties
  • Origin Matching
    • Exact Match
    • Wildcard (*)
    • Subdomain Wildcards
    • Wildcards with Ports
    • Multiple Patterns
  • Using Environment Variables
  • How CORS Works in Zuplo
    • Preflight Requests
    • Simple Requests
    • Header Precedence
  • Troubleshooting
    • No CORS headers in response
    • Preflight returns 404
    • Preflight returns 400
    • Wildcard subdomain not matching
    • Credentials not working
    • Backend CORS headers conflicting with Zuplo
    • Browser shows "CORS error" but the real issue is a 401 or 403
    • CORS headers lost in custom outbound policies
    • CORS on localhost during development
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
TypeScript
TypeScript
JSON
JSON