saascode

CORS is part of an embeddable widget's API contract

An embeddable widget always calls home from another origin. Define CORS once, handle preflights, and keep authorization separate from browser access.

academy · aug 21, 2026 · 2 min read

Your widget works on localhost and fails the moment a customer embeds it. The API returns 200, the response looks correct in server logs, and the browser still refuses to give it to the script.

That pattern repeated in four embeddable-widget builds in a row. Same-origin development hid the missing cross-origin contract every time.

CORS decides whether browser JavaScript from one origin may read a response from another. It is not a server-to-server access control, and it is not authentication.

Decide which browser origins may read the API

For a genuinely public, non-credentialed widget API, returning Access-Control-Allow-Origin: * can be appropriate. Do not combine that wildcard with cookies or Access-Control-Allow-Credentials: true; browsers reject credentialed responses with a wildcard origin.

If the widget uses cookies or must be restricted to registered sites, compare the request origin with a tenant-specific allowlist and echo only an allowed origin. Add Vary: Origin so caches do not serve one tenant's CORS decision to another.

function corsHeaders(origin: string | null, allowed: Set<string>) {
  if (!origin || !allowed.has(origin)) return null

  return {
    'Access-Control-Allow-Origin': origin,
    'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, X-Widget-Key',
    'Vary': 'Origin',
  }
}

Apply the same policy to success and error responses. A browser that can read 200 but not 401 leaves the widget with an opaque “network error” exactly when diagnostics matter.

Handle preflight where it is actually required

Simple GET requests are not preflighted. Requests with JSON content, custom headers, or non-simple methods usually are. Those routes need an OPTIONS response that declares the allowed origin, method, and headers before the browser sends the real request.

Centralize this behavior in a route wrapper or gateway. Copying a CORS object into every handler is how one forgotten endpoint ships without it.

Keep authorization on the server

An allowed origin is not proof of identity; non-browser clients can forge the Origin header. Authenticate the widget request with a scoped public identifier, signed token, or another design appropriate to the feature. Validate tenant, widget state, feature access, and rate limits before reading data.

A server route may use a privileged database client internally, but only after it has derived and enforced the narrow tenant scope. Never expose a service credential to the widget, and never treat “CORS allowed this site” as permission to query arbitrary rows.

Test from a second origin

Run the widget on a different local port or test host. Cover a simple GET, a preflighted POST, a rejected origin, an expired widget token, and an error response. Inspect both the OPTIONS exchange and the final response.

The bug repeated four times because same-origin testing proved the API while never exercising the widget's defining condition. Cross-origin is not an edge case for an embed. It is the product boundary.

end