Summary
An authentication header added in Playwright reached another origin both with extraHTTPHeaders and with a method that checks the destination in context.route and then adds the header with route.continue. The header is the Cloudflare Access service token sent by the script that opens this site's staging. A service token is a credential that Access accepts in place of a person's login. The header added with route.continue was attached to the target of a redirect to another origin.
With a function that adds the header only after confirming that the redirect targets are also the staging origin, the header did not reach another origin. In the reproduction test on 2026-10-13, the result was the same in Chromium 145, WebKit 26.0, and Firefox 146. However, the navigation request that fetches the page's HTML reached the same server twice.
The three browsers were the ones provided by Playwright version 1.58.2. Neither the browsers for Playwright 1.61.1, which the lockfile specifies, nor the regular releases of Safari and Firefox were tested.
What you can take away
This article is for people who write scripts that check staging with an authentication header attached in Playwright.
- It shows a TypeScript function that adds the header only after confirming that the redirect targets are also the staging origin.
- For each way of attaching the header, it compares the headers that reached another origin in a table covering four browser versions, including WebKit 26.5.
- It describes the extraHTTPHeaders leak and the redirect problem found in review.
- It shows that, under the destination's CORS settings, browsers differed in whether a request carrying the header arrived.
The article is based on three sources: the records of the fixes to the script that checks this site's staging, the results of the reproduction test run on 2026-10-13 along with external materials checked on the same day, and the results of running the code in this article on 2026-10-28.
Attaching the auth header only to the staging origin
To send an authentication header in Playwright only to the staging origin, add the header after checking the origins of the redirect targets as well. Both a setting that adds the header regardless of destination and a method that judges only the destination before sending let the header reach another origin. On this site's staging, Cloudflare Access accepts a service token in place of a person's login. The script that opens staging puts this token in two headers, Client ID and Client Secret, and sends them.
The 2026-10-13 reproduction test used a function that adds the header after checking the redirect targets. Two local servers with different origins were started, and a page on one was made to send requests to the other. In each of Chromium 145, WebKit 26.0, and Firefox 146, the header did not reach the other server. A page that redirects to the other server could not be opened, because this function stops the request.
How addAuthHeaders works
addAuthHeaders is a function that checks the redirect targets and adds the header only when all of them are the staging origin. In exchange, it sends the navigation request that fetches the page's HTML twice, once to check and once to send. In the 2026-10-13 reproduction test, with a function that does the same processing, this request reached the same server twice in each of the three browser types. Cases in which the server's response differs between the first and second requests were not tested.
If the destination is not the staging origin, addAuthHeaders calls route.continue, which sends the request on, without the header. For a GET or HEAD navigation request to staging, it calls route.fetch, which sends the request first and receives the response, with maxRedirects set to 0. According to the description of maxRedirects, when it is 0, route.fetch does not follow redirects.
addAuthHeaders follows the destinations given by the Location header of each response, one at a time, within staging. If every destination it followed is staging, it adds the header with route.continue and sends the request. If another origin appears, or the redirects exceed five, it stops the request with route.abort.
For requests that are not navigations to staging, and for navigation requests other than GET and HEAD, it calls route.fetch only once, with the header added. It sets maxRedirects to 0 and returns the response to the browser. If the response is a redirect, it stops the request with route.abort.
The following addAuthHeaders is code written out for this article from the function tested in the 2026-10-13 reproduction test. When this code was run on Chromium 145 on 2026-10-28, the header did not reach the server on the other origin. The results of running it on the other browsers have not been checked.
import type { Route, Request } from "playwright";
// Read the staging URL and the header names and values from environment variables (the names are placeholders)
const STAGING_ORIGIN = new URL(process.env.STAGING_URL ?? "").origin;
const AUTH_HEADERS: Record<string, string> = {
[process.env.AUTH_ID_HEADER_NAME ?? ""]: process.env.AUTH_CLIENT_ID ?? "",
[process.env.AUTH_SECRET_HEADER_NAME ?? ""]: process.env.AUTH_CLIENT_SECRET ?? "",
};
const MAX_REDIRECTS = 5;
const isStaging = (url: string) => new URL(url).origin === STAGING_ORIGIN;
const isRedirect = (status: number) => status >= 300 && status < 400;
export async function addAuthHeaders(route: Route, request: Request) {
// If the destination is not the staging origin, send the request without the headers
if (!isStaging(request.url())) return route.continue();
const headers = { ...request.headers(), ...AUTH_HEADERS };
const method = request.method();
if (request.isNavigationRequest() && (method === "GET" || method === "HEAD")) {
// Send without following redirects, and check each Location target one by one
let url = request.url();
for (let redirects = 0; ; redirects++) {
const response = await route.fetch({ url, headers, maxRedirects: 0 });
const location = response.headers()["location"];
if (!isRedirect(response.status()) || !location) break;
url = new URL(location, url).href;
if (!isStaging(url) || redirects >= MAX_REDIRECTS) return route.abort();
}
// Every target followed is the staging origin, so send the request with the headers added
return route.continue({ headers });
}
// Send any other request only once, and return the response to the browser
const response = await route.fetch({ headers, maxRedirects: 0 });
if (isRedirect(response.status())) return route.abort();
return route.fulfill({ response });
}
// Usage: await context.route("**/*", addAuthHeaders);
Headers that reached another origin, by way of attaching them
With extraHTTPHeaders, which adds the header to every request, the header was attached both to fetches that the page sent to another origin and to redirect targets. A method that judges the destination in context.route, which receives requests before they are sent, and adds the header with route.continue, which sends them on, was also tried. With this method, the header was not attached to the page's fetch but was attached to the redirect target. Three browser types in four versions were tried, and the results were the same in all of them.
In the 2026-10-13 reproduction test, two local servers with different origins were started. A page on one was made to send a fetch to the other. On the first server, a path that redirects to the other with a 302 was also set up. The other server allowed, in its CORS response, the names of the headers that the script added.
The browsers used were Chromium 145.0.7632.6, WebKit 26.0, and Firefox 146.0.1, provided by Playwright version 1.58.2. For WebKit, 26.5, built for Playwright 1.62.0, was also used. The browsers for Playwright 1.61.1, which this site's lockfile specifies, were not available locally and are not in the table. The results at the other server are shown in the following table.
| Way of attaching the header | Request to the other server | Chromium 145 | WebKit 26.0 | WebKit 26.5 | Firefox 146 |
|---|---|---|---|---|---|
| extraHTTPHeaders | Page's fetch | Attached | Attached | Attached | Attached |
| extraHTTPHeaders | Redirect target | Attached | Attached | Attached | Attached |
| Judge the destination in context.route and use route.continue | Page's fetch | Not attached | Not attached | Not attached | Not attached |
| Judge the destination in context.route and use route.continue | Redirect target | Attached | Attached | Attached | Attached |
The extraHTTPHeaders leak and the redirect problem found in review
The overflow check script, which counts horizontal overflow, at first put the service token used for Access authentication in extraHTTPHeaders. As a result, the token was also attached to requests to the Supabase API. However, the destination was this site's backend, and the connection used HTTPS.
To attach the header only to the destination origin, the AI agent doing the work removed extraHTTPHeaders from this script. Instead, it judged the destination in context.route, which receives requests before they are sent. Only when the destination was staging did it add the header with route.continue, which sends the request on.
On the same day as the fix that narrowed the header to the destination origin, the AI agent that reviewed the fix found a path through which the header passes to redirect targets. A header added with route.continue is also attached to the request to the redirect target. For that request, the context.route function is not called. The reviewing AI agent had in mind the redirect that Access makes when the token is invalid, and the forwarding from the purchase entry point to the payment service.
The description of route.continue says that the headers option also applies to redirects. The note on page.route says that if the response is a redirect, the function passed to route is called only for the first URL. On the other hand, the description of route.fetch, which sends the request first, gives the opposite guidance: to attach headers only to the original request, use route.continue. The approach of avoiding the leak to redirect targets with route.fetch and maxRedirects set to 0 was shown by a Playwright developer in issue 32939.
The destination's CORS settings and differences between browsers
Some browsers delivered a request carrying the header even though the server on the other origin did not allow the header name through CORS. The request arrived in three of the four versions tested. The destination's CORS settings cannot be relied on as a means of preventing the header from being sent to another origin. The 2026-10-13 reproduction test used extraHTTPHeaders, which adds the header to every request.
According to MDN's explanation of CORS, when a header that is not on the CORS safelist is attached, the browser first asks for permission with a preflight request. It sends the actual request after receiving permission. Chromium 145 in the reproduction test sent only the preflight request, which does not contain the header values, and did not send the actual request.
In WebKit 26.0 and 26.5 and in Firefox 146, no preflight request arrived, and the actual request carrying the header arrived. Every browser was a version provided by Playwright, and the regular releases of Safari and Firefox were not tested.
Materials I referred to
- Browser | Playwright
- Route | Playwright
- Page | Playwright
- Playwright issue 32939
- Cross-Origin Resource Sharing (CORS) - HTTP | MDN
Originally published at The Future of Humans, AI, and the Web, a site where my research and development is recorded and analyzed by a human and an AI.
Top comments (0)