Proxying CORS for API Try-It Consoles

This guide is part of Interactive API Consoles & Try-It Panels within Developer Portal Frameworks & UI Setup. It implements the same-origin proxy that removes CORS from the equation entirely. The situation that leads here is familiar: the console works perfectly on localhost, ships to the documentation domain, and every request fails with TypeError: Failed to fetch before it leaves the browser.

Cross-origin versus same-origin request A direct call from the docs origin to the API requires a preflight and a matching CORS policy, while a call to a proxy on the docs origin triggers no browser check at all. direct — the browser must be satisfied first docs.example.com preflight OPTIONS API must allow the origin api.example.com proxied — the browser never sees a cross-origin request docs.example.com /api-proxy — same origin no preflight at all api.example.com server-to-server

Problem & Context

The same-origin policy is a browser rule, not a network one. When JavaScript on docs.example.com calls api.example.com, the browser first asks the API for permission — a preflight OPTIONS request — and refuses to expose the response unless the API answers with headers naming that exact origin, the method, and every header the request carries.

Three things make this hard to fix by adding CORS headers. The API may not be yours to configure. It may not be reachable from the public internet at all, which is normal for a staging environment. And the combination the console usually needs — credentials plus a specific allowed origin — is precisely the combination browsers refuse when the API answers with a wildcard Access-Control-Allow-Origin: *.

A proxy sidesteps all three. Because the console calls a path on its own origin, no preflight happens and no CORS policy is consulted. The proxy then makes an ordinary server-to-server request, where the same-origin policy does not exist. As a bonus, the credential never has to reach the browser at all.

Step-by-Step Solution

1. Confirm CORS is actually the problem

Failed to fetch has several causes, and a Content Security Policy produces an identical symptom. Establish which one you have before building anything:

curl -si -X OPTIONS 'https://api.example.com/v1/invoices' \
  -H 'Origin: https://docs.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: authorization,content-type' \
  | grep -iE '^(HTTP/|access-control-)'

A CORS problem looks like this — the request succeeds, but no permission headers come back:

HTTP/2 204

If the headers are present and correct, the failure is elsewhere; check the docs site’s connect-src directive before continuing.

2. Allowlist upstreams by name, never by URL

This is the rule that separates a proxy from an open relay. A proxy that accepts a full destination URL from the client will be discovered and used to reach internal services or to launder traffic:

// The client sends ?env=sandbox — never a URL.
const ALLOWED = new Map([
  ['sandbox',    'https://sandbox.api.example.com/v1'],
  ['production', 'https://api.example.com/v1'],
]);

3. Strip cookies and rewrite the origin

The docs site’s cookies must never travel upstream — they authenticate the reader to your documentation, not to the API, and forwarding them is a cross-service credential leak:

const headers = new Headers(request.headers);
headers.delete('cookie');
headers.delete('x-forwarded-for');
headers.set('origin', new URL(upstream).origin);

4. Rewrite console requests to the proxy path

The console still believes it is calling the server from the specification; the interceptor rewrites the URL immediately before the request is sent, so it leaves as a same-origin call:

requestInterceptor: (req) => {
  const target = new URL(req.url);
  const env = document.documentElement.dataset.consoleEnv || 'sandbox';
  req.url = `/api-proxy${target.pathname}${target.search}` +
            `${target.search ? '&' : '?'}env=${env}`;
  return req;
}

5. Rate-limit and log the proxy

Because the proxy carries a credential, an unlimited proxy is an unauthenticated gateway to your sandbox. Limit by IP and by session, and log which environment each request targeted so abuse is visible.

Proxy request transformations The proxy resolves an environment name to a fixed upstream, removes cookies, attaches a server-side credential, and strips CORS headers from the response. four transformations, all mandatory destination env name → map never a client URL stops an open relay inbound headers delete cookie rewrite origin stops a credential leak credential attach server-side sandbox only browser never sees it response strip CORS set no-store same-origin needs none

Complete Working Example

A complete proxy, deployable as a Cloudflare Worker or adaptable to any edge runtime:

// api-proxy/index.js — mounted at /api-proxy/* on the documentation origin.
const ALLOWED = new Map([
  ['sandbox',    'https://sandbox.api.example.com/v1'],
  ['production', 'https://api.example.com/v1'],
]);

// Headers that must never be forwarded upstream.
const STRIP = ['cookie', 'x-forwarded-for', 'x-real-ip', 'forwarded'];

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const path = url.pathname.replace(/^\/api-proxy/, '') || '/';

    // 1. Destination comes from a fixed map keyed on a NAME. Unknown names fall
    //    back to sandbox rather than erroring, so a stale client cannot 500.
    const envName = url.searchParams.get('env') === 'production' ? 'production' : 'sandbox';
    const upstream = ALLOWED.get(envName);

    // 2. Rate limit before doing any upstream work.
    const who = request.headers.get('cf-connecting-ip') || 'unknown';
    const allowed = await rateLimit(env, `${who}:${envName}`);
    if (!allowed) {
      return json({ error: 'rate_limited' }, 429, { 'retry-after': '30' });
    }

    // 3. Clean the inbound headers.
    const headers = new Headers(request.headers);
    STRIP.forEach((h) => headers.delete(h));
    headers.set('origin', new URL(upstream).origin);
    headers.set('user-agent', 'acme-docs-console/1.0');

    // 4. Sandbox gets a server-side demo credential; production passes through
    //    whatever the reader supplied and never receives one from us.
    if (envName === 'sandbox') {
      headers.set('authorization', `Bearer ${env.SANDBOX_DEMO_TOKEN}`);
    }

    let res;
    try {
      res = await fetch(`${upstream}${path}${url.search}`, {
        method: request.method,
        headers,
        body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body,
        redirect: 'manual',        // never follow an upstream redirect blindly
        signal: AbortSignal.timeout(15000),
      });
    } catch (err) {
      return json({ error: 'upstream_unreachable', env: envName }, 502);
    }

    // 5. Same-origin responses need no CORS headers; strip whatever came back.
    const out = new Headers(res.headers);
    out.delete('access-control-allow-origin');
    out.delete('access-control-allow-credentials');
    out.delete('set-cookie');       // upstream cookies must not land on the docs origin
    out.set('cache-control', 'no-store');
    out.set('x-proxy-env', envName); // visible in devtools; useful when debugging

    return new Response(res.body, { status: res.status, headers: out });
  },
};

function json(body, status, extra = {}) {
  return new Response(JSON.stringify(body), {
    status,
    headers: { 'content-type': 'application/json', 'cache-control': 'no-store', ...extra },
  });
}

async function rateLimit(env, key) {
  const now = Math.floor(Date.now() / 30000);          // 30-second windows
  const slot = `rl:${key}:${now}`;
  const count = Number(await env.RATE.get(slot)) || 0;
  if (count >= 30) return false;
  await env.RATE.put(slot, String(count + 1), { expirationTtl: 60 });
  return true;
}

Verify it end to end before pointing the console at it:

# Same-origin path, sandbox by default, no credential sent by the client
curl -s -o /dev/null -w '%{http_code} %{header_json}' \
  'https://docs.example.com/api-proxy/invoices' | head -c 200
200 {"x-proxy-env":["sandbox"],"cache-control":["no-store"]}

Then prove the allowlist holds — this must not reach the internal host:

curl -s -o /dev/null -w '%{http_code}\n' \
  'https://docs.example.com/api-proxy/invoices?env=http://internal.corp/admin'
200

A 200 here is correct and is the point: an unrecognised env value fell back to the sandbox rather than being treated as a destination. If your implementation returns anything that suggests it attempted the supplied URL, it is an open relay and must not ship.

Gotchas & Edge Cases

The proxy must share the docs origin exactly. A proxy on proxy.docs.example.com is a different origin from docs.example.com, so the browser applies CORS to it and nothing is solved. Mount it as a path on the same host, behind the same CDN.

Streaming and large uploads. Passing request.body through works for ordinary JSON, but file uploads and streamed responses need explicit handling in some runtimes, and a proxy that buffers a large body will hit a memory or duration limit. If the API has upload endpoints, either exclude them from the console or confirm your runtime streams rather than buffers.

Set-Cookie from upstream lands on your docs origin. Deleting it is not optional. An API that sets a session cookie would otherwise place it on the documentation domain, where it is sent with every subsequent docs request and is visible to any script on the page.

Redirects must not be followed blindly. redirect: 'manual' prevents the proxy from chasing a 302 to a host outside the allowlist, which would reintroduce exactly the arbitrary-destination problem the name map exists to prevent.

The proxy becomes a dependency of the documentation. Once the console routes through it, a proxy outage makes every try-it panel on the site fail, and the failure looks like a broken documentation site rather than a broken service. Monitor it the way you monitor the docs origin itself, with a synthetic check that exercises a real upstream call rather than merely confirming the worker responds. Give the failure a distinguishable body — the upstream_unreachable response above — so a reader reporting the problem gives you something more useful than “the console does not work”.

Keep the environment name out of the path. Encoding it as ?env=sandbox rather than /api-proxy/sandbox/... matters more than it looks: a path segment gets confused with the upstream path during rewriting, and a client that forgets it produces a request to a nonexistent endpoint rather than a request to the default environment. A missing query parameter falls back cleanly; a missing path segment shifts every subsequent segment by one.

FAQ

Is a proxy less secure than enabling CORS?

Done correctly it is more secure, because credentials stay server-side and you control the rate limit and the audit log. Done carelessly it is an open relay. The single rule that separates the two is that the client picks an environment name, never a destination URL.

Do I still need CORS headers on the API?

No. A same-origin request triggers no CORS check at all, which is the whole point. The proxy speaks to the upstream server-to-server, where the same-origin policy does not apply.

Where should the proxy run?

On the documentation origin, at a path such as /api-proxy. It must share the origin with the docs site — a subdomain is a different origin as far as the browser is concerned, and calls to it are cross-origin again.

Two failure symptoms look identical in the browser and are fixed in completely different places. Distinguishing them takes one glance at the network panel:

CORS failure versus CSP failure A CORS failure shows a preflight request in the network panel, while a Content Security Policy failure shows no network request at all. both report TypeError: Failed to fetch CORS an OPTIONS request IS in the network panel the browser asked and was refused fix: the API, or use a proxy Content Security Policy NO request appears at all blocked before it left the page fix: connect-src on the docs site