DEV Community

Daniel Pertu
Daniel Pertu

Posted on

The only cross-origin browser caller of our API is our own app

Munchable ships to two places from one React Native codebase. There is an Android app, and there is app.munchable.app, which is the same Expo project built for the web. The marketing site at munchable.app is a separate Next.js app, and it owns the API.

That arrangement produces a question worth asking out loud before writing any CORS config: who actually calls this API from a browser?

Going through the list:

  • The Next.js site itself. Same origin, so CORS never applies.
  • The native Android app. It has no browser and no origin, so it never sends Origin and never triggers a preflight. Every CORS rule in the world is invisible to it.
  • The Expo app running in a browser. app.munchable.app calling munchable.app, and on a developer machine, the Metro dev server on localhost:8081 calling localhost:3000.

So the only cross-origin browser caller of this API is our own app, and the entire policy exists for that one case. Before it existed, route handlers emitted no CORS headers at all and every preflight from the Expo web build failed, which is a confusing bug the first time you hit it because the native app works perfectly.

Never allow credentials

The policy is: reflect the request Origin when it is allowed, and never send Access-Control-Allow-Credentials.

That second half sounds like a limitation and is actually the point. The client in a browser carries a bearer token in an Authorization header, which travels fine without credentials mode. The cookie-based session that the Next.js site uses is a different mechanism, and without the credentials header a browser will refuse to attach those cookies to a cross-origin request at all.

So the cookie session physically cannot be replayed from another origin, whatever a page manages to talk our API into. Skipping one header removes a whole class of cross-site request forgery from consideration rather than mitigating it.

It is also why Access-Control-Allow-Origin reflects the origin instead of being a wildcard. A wildcard would be simpler, but reflecting means the allowed set is an explicit decision, and a reflected origin is required the moment you would ever want credentials.

What counts as allowed

export function isAllowedOrigin(origin: string | null): origin is string {
  const url = parseUrl(origin ?? undefined);
  if (!url) return false; // absent, malformed, or the opaque "null" origin
  if (url.origin === SITE.origin || EXTRA_ORIGINS.has(url.origin)) return true;
  if (
    url.protocol === 'https:' &&
    (url.hostname === SITE.hostname || url.hostname.endsWith(`.${SITE.hostname}`))
  ) {
    return true;
  }
  return !IS_PRODUCTION && LOOPBACK_ORIGIN.test(url.origin);
}
Enter fullscreen mode Exit fullscreen mode

Four things in there earn their place.

Parse, do not compare strings. new URL() normalises the value and rejects junk. String prefix matching on origins is how munchable.app.evil.com gets allowed.

The subdomain rule requires https explicitly, and checks endsWith('.' + hostname) with the dot included. Without the leading dot, notmunchable.app matches. This is the single most common bug in hand-rolled origin checks.

Loopback is allowed outside production only. Metro picks whatever port is free, so pinning one port in config means a developer whose 8081 was busy spends twenty minutes on a CORS error. The production guard is what makes that convenience safe.

The literal string "null" is an origin, sent by sandboxed iframes and some file-protocol contexts. parseUrl returns null for it, so it falls through to false rather than being compared as text.

Denial is silence

When an origin is not allowed, nothing is refused. The response simply goes back without any Access-Control-Allow-* headers:

const origin = request.headers.get('origin');
if (!isAllowedOrigin(origin)) return response;
Enter fullscreen mode Exit fullscreen mode

The browser is the thing that enforces CORS, so the absence of the header is the enforcement. Returning a 403 instead would be worse: it tells a scripted caller, which is not bound by CORS in the first place, exactly what your policy is, while doing nothing extra to the browser.

The preflight works the same way. It always answers 204, with the allow headers only when the origin passes.

Vary, or your CDN will serve the wrong answer

response.headers.append('Vary', 'Origin');
Enter fullscreen mode Exit fullscreen mode

That line is appended before the origin is even checked, which is deliberate. The response body may be identical, but the headers differ by origin, so any shared cache has to key on it. Miss this and a cached response carrying one origin's allow header gets served to another, which fails in a way that is maddening to reproduce because it depends on who warmed the cache.

The preflight adds Access-Control-Request-Headers to its Vary for the same reason, since the allowed header list is reflected from the request.

Headers a client cannot read do not exist

By default, JavaScript on a cross-origin response can read a handful of headers and nothing else. Everything else is there in the network tab and unreadable from code, which is a genuinely disorienting first encounter.

So the policy names the ones the client needs:

const EXPOSED_HEADERS =
  'Retry-After, RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, ETag, x-taxonomy-version';
Enter fullscreen mode Exit fullscreen mode

The rate limit headers let the client back off properly instead of treating a 429 as a random failure. ETag and x-taxonomy-version are how the app learns that its on-device ingredient data is behind the server's, which is the mechanism that lets curation reach a phone without an app release.

That list is a real coupling between the CORS module and two features in other parts of the codebase, and it is the kind of coupling that breaks quietly. A header added to a response is invisible to the browser client until it is added here too.

Preflight caching is a request, not a promise

const PREFLIGHT_MAX_AGE = '86400';
Enter fullscreen mode Exit fullscreen mode

Browsers cap this. Chromium's ceiling is two hours and Firefox's is twenty four, so asking for a day means "as long as you are willing to". It is still worth asking for, because the alternative is an extra round trip in front of a meaningful fraction of requests.

One place, not thirty

None of this lives in route handlers. It is applied in the proxy, which is Next.js's renamed middleware convention:

export async function proxy(request: NextRequest) {
  const isApi = request.nextUrl.pathname.startsWith('/api/');
  if (isApi && request.method === 'OPTIONS') return corsPreflightResponse(request);
  const response = await updateSession(request);
  return isApi ? applyCorsHeaders(request, response) : response;
}
Enter fullscreen mode Exit fullscreen mode

A policy that every route has to remember is a policy with a hole in it, and the hole is always in the route added last. Two lines here cover every current route and every future one.

The webhook routes are excluded from the matcher entirely. They are server-to-server, unauthenticated, and verified by signature, so there is no browser involved and no CORS question to answer. Excluding them also keeps a session refresh off a path that has no session.

The other half: the app is deliberately not in the index

Since the Expo build is served as a static single-page app, its hosting config rewrites every path to index.html, caches the hashed bundle immutably for a year, and makes everything else revalidate. Standard SPA hosting.

It also sends this on every response:

X-Robots-Tag: noindex, nofollow
Enter fullscreen mode Exit fullscreen mode

The marketing site is the surface that is meant to rank, with hundreds of generated pages behind it. The app subdomain is an app shell that renders nothing useful without a session. Leaving it crawlable gets you a thin, duplicate, permanently unhelpful result competing against the pages you actually wrote.

Worth the reminder that this is a header on a different deployment, not a line in the site's robots.txt, because the two hosts are two different properties and only one of them has anything to say to a crawler.

Go and look

  • Open app.munchable.app with the network tab open. The requests going to munchable.app are cross-origin, carry an Origin header, and come back with a reflected allow header, an expose-headers list, and Vary: Origin. No cookies attached.
  • Compare with munchable.app, where the same API is same-origin and none of those headers appear at all.
  • The x-taxonomy-version header in that expose list is the one that tells the app its ingredient knowledge is stale. There is a separate post on how that snapshot is versioned and delivered, and what it produces is visible on the ingredient answers pages.

The summary I would give a past version of myself: write down every browser that will ever call your API before you write any CORS config. The list is usually shorter than you think, and once it is written the policy is mostly a matter of not allowing anything that is not on it.

Top comments (0)