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

Source: https://saascode.ai/inside/widget-cors · Published: 2026-08-21 · Section: academy

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

```ts
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.
