Summary
fetchWithCorsProxy only falls back to the CORS proxy when the direct fetch() throws (CORS preflight failure or network error). When the target host has permissive CORS that passes preflight but the browser still strips JS-set Authorization because the request isn't credentials: 'include', the direct fetch resolves with status 401 rather than throwing — so the catch block never runs, the retry-through-proxy path is unreachable, and credentialed requests permanently fail in Playground.
This affects any plugin that calls a third-party REST API which (a) supports CORS broadly enough to pass preflight from https://playground.wordpress.net and (b) requires an Authorization header. Stripe's REST API is the concrete case I'm hitting (filing this from a WooCommerce Stripe Gateway PR-preview workflow), but Mailchimp, Twilio, SendGrid, OpenAI, and any other auth-token-style API will behave the same way.
Reproduction
Stripe's CORS preflight is fully permissive — it echoes back any origin and any header:
$ curl -sS -i -X OPTIONS \
-H "Origin: https://playground.wordpress.net" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: authorization,stripe-version" \
https://api.stripe.com/v1/account
HTTP/2 204
access-control-allow-credentials: true
access-control-allow-headers: authorization,stripe-version
access-control-allow-methods: GET, HEAD, PUT, PATCH, POST, DELETE
access-control-allow-origin: https://playground.wordpress.net
Now in a Playground sandbox with features.networking: true, run from PHP:
$response = wp_safe_remote_get( 'https://api.stripe.com/v1/account', [
'headers' => [
'Authorization' => 'Basic ' . base64_encode( 'sk_test_REAL_KEY:' ),
'Stripe-Version' => '2025-09-30.clover',
],
] );
Expected: Stripe responds 200 with account JSON (because the API key is valid).
Actual: Stripe responds 401 with {"error":{"message":"You did not provide an API key. You need to provide your API key in the Authorization header, using Bearer auth (e.g. 'Authorization: Bearer YOUR_SECRET_KEY')…","type":"invalid_request_error"}}.
The Authorization header is stripped by the browser before the request leaves the sandbox, because the fetch issued by the TLS bridge doesn't use credentials: 'include' (per Fetch §http-network-or-cache-fetch step 8.10.2, Authorization is removed from CORS-tainted requests unless the request is "credentialed").
Why the proxy retry doesn't save us
In packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.ts:
try {
return await fetch(requestObject);
} catch {
// build retry through proxy with credentials: 'include' if
// X-Cors-Proxy-Allowed-Request-Headers contains 'authorization'
}
The catch only fires on exception. Stripe's permissive CORS means the direct fetch resolves successfully (with status 401, body containing the "no API key" error), so:
fetch(requestObject) resolves → fetchWithCorsProxy returns it.
- The retry-through-proxy path with
credentials: 'include' is never reached.
- The plugin sees 401, the user can't connect their Stripe account.
I confirmed this end-to-end by reading the PHP plugin's logs inside a Playground sandbox: every call to api.stripe.com/v1/account produces Stripe's "you did not provide an API key" response, and there is zero traffic to cors.wordpress.net/proxy.php in the browser's Network tab — exactly because the proxy retry never fires.
I also verified an attempted plugin-side workaround (a Playground-only mu-plugin that hooks http_request_args to add X-Cors-Proxy-Allowed-Request-Headers: authorization, … for api.stripe.com URLs): it correctly sets the opt-in for the proxy retry, but the retry doesn't run, so it changes nothing observable. Code: https://github.com/woocommerce/woocommerce-gateway-stripe/blob/add/playground-stripe-cors-passthrough/.github/workflows/scripts/playground-mu-plugins/stripe-cors-headers.php.
Proposed fixes (any one is sufficient)
In rough order of conservatism:
-
When X-Cors-Proxy-Allowed-Request-Headers is present on the request, skip the direct fetch entirely and go straight through the CORS proxy with credentials: 'include'. The opt-in header is itself a developer signal that "I know I need credentialed access; don't bother trying direct."
-
Treat any 4xx response from the direct fetch as a fall-through trigger to the proxy retry when the opt-in header is set. Slightly more complex (you have to clone the request body twice) but minimally invasive otherwise.
-
Set credentials: 'include' on the direct fetch too when X-Cors-Proxy-Allowed-Request-Headers includes authorization or cookie. This is the most surgical change and lines up with the existing retry-path logic.
(There's also a minor case-sensitivity bug nearby: corsProxyAllowedHeaders.includes('authorization') is case-sensitive against the comma-split header value, so Authorization, Cookie doesn't match — has to be lowercase. I worked around it locally but flagging it in case it's worth lowercasing the comparison too.)
Why this matters
Playground PR previews are a major DX win for plugins that don't want every reviewer to spin up a local environment. But for plugins talking to common third-party REST APIs (Stripe, Mailchimp, Twilio, etc.), the previews can only show admin UI shape — any flow that needs a real upstream auth check breaks at "your API keys are invalid", regardless of whether the keys are valid or not. Fixing the credentialed-fetch path opens up a much larger class of useful previews.
Happy to put up a PR for option 3 (or whichever option you prefer) if it'd help.
Summary
fetchWithCorsProxyonly falls back to the CORS proxy when the directfetch()throws (CORS preflight failure or network error). When the target host has permissive CORS that passes preflight but the browser still strips JS-setAuthorizationbecause the request isn'tcredentials: 'include', the direct fetch resolves with status 401 rather than throwing — so the catch block never runs, the retry-through-proxy path is unreachable, and credentialed requests permanently fail in Playground.This affects any plugin that calls a third-party REST API which (a) supports CORS broadly enough to pass preflight from
https://playground.wordpress.netand (b) requires anAuthorizationheader. Stripe's REST API is the concrete case I'm hitting (filing this from a WooCommerce Stripe Gateway PR-preview workflow), but Mailchimp, Twilio, SendGrid, OpenAI, and any other auth-token-style API will behave the same way.Reproduction
Stripe's CORS preflight is fully permissive — it echoes back any origin and any header:
Now in a Playground sandbox with
features.networking: true, run from PHP:Expected: Stripe responds 200 with account JSON (because the API key is valid).
Actual: Stripe responds 401 with
{"error":{"message":"You did not provide an API key. You need to provide your API key in the Authorization header, using Bearer auth (e.g. 'Authorization: Bearer YOUR_SECRET_KEY')…","type":"invalid_request_error"}}.The
Authorizationheader is stripped by the browser before the request leaves the sandbox, because the fetch issued by the TLS bridge doesn't usecredentials: 'include'(per Fetch §http-network-or-cache-fetch step 8.10.2,Authorizationis removed from CORS-tainted requests unless the request is "credentialed").Why the proxy retry doesn't save us
In
packages/php-wasm/web-service-worker/src/fetch-with-cors-proxy.ts:The catch only fires on exception. Stripe's permissive CORS means the direct fetch resolves successfully (with status 401, body containing the "no API key" error), so:
fetch(requestObject)resolves →fetchWithCorsProxyreturns it.credentials: 'include'is never reached.I confirmed this end-to-end by reading the PHP plugin's logs inside a Playground sandbox: every call to
api.stripe.com/v1/accountproduces Stripe's "you did not provide an API key" response, and there is zero traffic tocors.wordpress.net/proxy.phpin the browser's Network tab — exactly because the proxy retry never fires.I also verified an attempted plugin-side workaround (a Playground-only mu-plugin that hooks
http_request_argsto addX-Cors-Proxy-Allowed-Request-Headers: authorization, …forapi.stripe.comURLs): it correctly sets the opt-in for the proxy retry, but the retry doesn't run, so it changes nothing observable. Code: https://github.com/woocommerce/woocommerce-gateway-stripe/blob/add/playground-stripe-cors-passthrough/.github/workflows/scripts/playground-mu-plugins/stripe-cors-headers.php.Proposed fixes (any one is sufficient)
In rough order of conservatism:
When
X-Cors-Proxy-Allowed-Request-Headersis present on the request, skip the direct fetch entirely and go straight through the CORS proxy withcredentials: 'include'. The opt-in header is itself a developer signal that "I know I need credentialed access; don't bother trying direct."Treat any 4xx response from the direct fetch as a fall-through trigger to the proxy retry when the opt-in header is set. Slightly more complex (you have to clone the request body twice) but minimally invasive otherwise.
Set
credentials: 'include'on the direct fetch too whenX-Cors-Proxy-Allowed-Request-Headersincludesauthorizationorcookie. This is the most surgical change and lines up with the existing retry-path logic.(There's also a minor case-sensitivity bug nearby:
corsProxyAllowedHeaders.includes('authorization')is case-sensitive against the comma-split header value, soAuthorization, Cookiedoesn't match — has to be lowercase. I worked around it locally but flagging it in case it's worth lowercasing the comparison too.)Why this matters
Playground PR previews are a major DX win for plugins that don't want every reviewer to spin up a local environment. But for plugins talking to common third-party REST APIs (Stripe, Mailchimp, Twilio, etc.), the previews can only show admin UI shape — any flow that needs a real upstream auth check breaks at "your API keys are invalid", regardless of whether the keys are valid or not. Fixing the credentialed-fetch path opens up a much larger class of useful previews.
Happy to put up a PR for option 3 (or whichever option you prefer) if it'd help.