Handling the Access-Control-Request-Private-Network Header

A dashboard at https://console.metricly.dev needs to read from an on-prem collector at http://10.20.0.15:9000. The collector already has CORS configured, the origin is allowlisted, and the request is a bare GET. Chromium blocks it anyway:

Access to fetch at 'http://10.20.0.15:9000/v1/metrics' from origin 'https://console.metricly.dev'
has been blocked by CORS policy: Response to preflight request doesn't pass access control
check: The 'Access-Control-Allow-Private-Network' header was not present on the requested
resource.

Root cause

Chromium compared the address space of the document with the address space of the target. The page came from a routable address, so it is public; 10.20.0.15 falls inside 10.0.0.0/8, so it is private. Any request stepping from a more public space into a more private one is treated as a request that could only have been made because the browser happens to be sitting inside the network, and the specification requires the target to opt in first. Chromium expresses that by inserting a preflight — even though a header-free GET would otherwise be a simple request — and stamping it with Access-Control-Request-Private-Network: true. Your collector answered the preflight perfectly by ordinary CORS standards and said nothing about the address space transition, so the browser refused to release the real request. The full model behind that decision is set out in Private Network Access Controls; this guide is only about emitting the one line that answers it.

The round trip a header-free GET actually takes Five stacked steps read top to bottom: the page calls fetch, the browser detects the address space transition, an OPTIONS preflight goes out carrying the private network request header, the collector answers 204 with the grant, and only then is the original GET released. page fetch('http://10.20.0.15:9000/v1/metrics') no custom headers, no credentials — a simple request by every ordinary measure browser Document address space is public; 10.20.0.15 resolves into the private space A preflight is synthesised that the application code never asked for preflight OPTIONS /v1/metrics Access-Control-Request-Private-Network: true collector 204 + Allow-Origin + Allow-Methods Access-Control-Allow-Private-Network: true browser Result cached for the lifetime of Access-Control-Max-Age The original GET is finally put on the wire, unchanged Step four is the only one you control — every other row happens whether you configure anything or not

Prerequisite state

Step-by-step fix

Step 1 — Confirm the request header is really arriving

Before changing any code, prove the browser is asking. Open DevTools, filter the Network panel to Options, select the preflight, and look in Request Headers for Access-Control-Request-Private-Network: true. If it is not there, the failure is something else and this page will not fix it — go back to the parent guidance on reading a preflight OPTIONS request in DevTools.

Step 2 — Emit the grant conditionally in FastAPI

Starlette’s bundled CORSMiddleware has no knob for this header, so add a thin middleware in front of it that answers the preflight itself. The grant is gated on two things at once: the origin must be allowlisted and the browser must have asked.

from fastapi import FastAPI, Request, Response
from starlette.middleware.base import BaseHTTPMiddleware

ALLOWED_ORIGINS = {"https://console.metricly.dev"}
VARY = "Origin, Access-Control-Request-Private-Network"


class PrivateNetworkCORS(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        origin = request.headers.get("origin")
        allowed = origin in ALLOWED_ORIGINS
        asked = request.headers.get("access-control-request-private-network") == "true"
        is_preflight = (
            request.method == "OPTIONS"
            and "access-control-request-method" in request.headers
        )

        if is_preflight:
            if not allowed:
                return Response(status_code=403, headers={"Vary": VARY})
            headers = {
                "Access-Control-Allow-Origin": origin,
                "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
                "Access-Control-Allow-Headers": "Content-Type, X-Collector-Key",
                "Access-Control-Max-Age": "300",
                "Vary": VARY,
            }
            if asked:
                headers["Access-Control-Allow-Private-Network"] = "true"
            return Response(status_code=204, headers=headers)

        response = await call_next(request)
        response.headers["Vary"] = VARY
        if allowed:
            response.headers["Access-Control-Allow-Origin"] = origin
        return response


app = FastAPI()
app.add_middleware(PrivateNetworkCORS)


@app.get("/v1/metrics")
async def metrics():
    return {"cpu": 0.42, "mem": 0.68}

Step 3 — Or emit it from Apache in front of the collector

Many on-prem collectors sit behind an Apache instance that terminates the port. SetEnvIf reads arbitrary request headers, and SetEnvIfExpr lets you require both conditions before the grant is written.


    SetEnvIf Origin "^https://console\.metricly\.dev$" METRICLY_ORIGIN=$0
    SetEnvIf Access-Control-Request-Private-Network "^true$" PNA_ASKED=1
    SetEnvIfExpr "reqenv('METRICLY_ORIGIN') != '' && reqenv('PNA_ASKED') != ''" PNA_GRANT=1

    Header always append Vary "Origin"
    Header always append Vary "Access-Control-Request-Private-Network"
    Header always set Access-Control-Allow-Origin "%{METRICLY_ORIGIN}e" env=METRICLY_ORIGIN
    Header always set Access-Control-Allow-Methods "GET, POST, OPTIONS" env=METRICLY_ORIGIN
    Header always set Access-Control-Allow-Headers "Content-Type, X-Collector-Key" env=METRICLY_ORIGIN
    Header always set Access-Control-Max-Age "300" env=METRICLY_ORIGIN
    Header always set Access-Control-Allow-Private-Network "true" env=PNA_GRANT

    RewriteEngine On
    RewriteCond %{REQUEST_METHOD} =OPTIONS
    RewriteRule ^ - [R=204,L]

The double gate matters. Writing the grant on env=PNA_ASKED alone would hand it to any caller that sets the request header by hand, including one whose origin you never allowlisted.

When the grant is written and when it is withheld A three-column table pairs whether the origin is on the allowlist with whether the request carried Access-Control-Request-Private-Network. Only the row where both are true results in the grant being written; the other three rows withhold it. Origin on the allowlist Request header present Response writes the grant yes yes Access-Control-Allow-Private-Network: true yes no withheld — nobody asked for it no yes withheld — 403, and no CORS headers no no withheld — 403, and no CORS headers

Step 4 — Put the request header in Vary

The response now differs depending on a request header, which means every cache between the browser and the collector must be told. Both examples above already append Access-Control-Request-Private-Network to Vary alongside Origin; the reasoning is the same one covered in Handling the Vary: Origin Header Correctly, extended by one header name.

Step 5 — Choose a short Access-Control-Max-Age

The grant is cached with the rest of the preflight result. During rollout, keep the value low — 300 seconds in both examples — so a mistake clears itself within minutes instead of persisting for the rest of the browsing session.

Lifetime of a cached private network grant Three states run left to right: no cache entry, preflight in flight, and grant cached. A rejected preflight leaves the in-flight state upward to a blocked outcome that caches nothing, while the cached grant returns to no entry once Access-Control-Max-Age elapses. Blocked, nothing cached No cache entry for this origin and URL Preflight in flight OPTIONS on the wire Grant cached real requests flow freely first call grant seen grant missing no new OPTIONS Access-Control-Max-Age elapses and the entry is discarded

Step 6 — If the grant is present and it still fails

Three things can consume a correct grant before the browser ever evaluates it, and each one presents as “I set the header and nothing changed”.

The first is an intermediary. A TLS-inspecting corporate proxy re-terminates the connection and rewrites the response, and older ones drop headers they do not recognise. Run the curl probe below from a machine inside the same proxy path rather than from your laptop’s untunnelled connection; if the grant is present on one and missing on the other, the collector is fine and the proxy is the offender.

The second is a browser extension. Extensions that rewrite request URLs — ad blockers, corporate agents, local development helpers — can change the target after your code calls fetch, which changes the address space transition the browser evaluates. Reproduce in a clean profile with every extension disabled before you conclude the server is at fault.

The third is Chromium itself. Recent versions layer a user-facing permission on top of the header handshake for local network access, so a technically correct exchange can still stall waiting for the visitor to approve the connection. That path is silent in the Network panel — the preflight succeeds, the real request never leaves — so watch for a permission chip in the address bar before assuming a header problem. Design the dashboard to degrade gracefully when that approval is refused, rather than retrying a request that will never be released.

None of these is fixed by changing the response, which is why it is worth ruling them out with curl first: curl has no address space rules, no extensions and no permission model, so if it shows the grant, your server is doing its job.

Verification

Security boundary note

Do not reach for Access-Control-Allow-Origin: * here. On an ordinary public API a wildcard is merely broad; paired with a private network grant it means every website your users visit can drive requests into your internal network from inside the perimeter, using their browser as the relay. Keep the exact-match allowlist, and treat the grant as a statement about which pages may cross the boundary — never as a general “this service is reachable” flag. A collector that also accepts credentials must additionally reject Origin: null, because a sandboxed iframe is the cheapest way for an attacker to hide the attempt.

Common mistakes

Issue Technical impact Mitigation
Setting the grant on the real GET response instead of the preflight The browser only reads it while evaluating the preflight, so every request stays blocked Emit it inside the OPTIONS branch next to Access-Control-Allow-Methods
Relying on the framework’s CORS middleware to add it Starlette, the Node cors package and most others have no option for this header and silently omit it Add a thin layer that appends the grant after the library builds its headers
Writing the grant whenever the request header is present, without checking Origin Any caller that sets the header by hand receives an opt-in your allowlist never approved Require both conditions, as SetEnvIfExpr does in the Apache example
Leaving Access-Control-Request-Private-Network out of Vary A shared proxy caches a grant-free preflight and serves it to a browser that needed the grant Append the header name to Vary on every response the location emits

FAQ

Why does my CORS library not have an option for this header?

Most CORS middleware predates the private network specification and models only the headers in the original CORS algorithm, so there is no configuration key to set. Starlette’s CORSMiddleware, the Node cors package, and rack-cors all fall into this group. The fix is not to replace the library but to add a thin layer that runs before it, inspects Access-Control-Request-Private-Network on the OPTIONS request, and appends the grant when the library has finished building its own headers.

Should I emit the grant on every response or only on the preflight?

Only on the preflight, and only when the request actually carried Access-Control-Request-Private-Network: true. The browser reads the grant exclusively while evaluating the preflight result; on the actual response it is ignored. Emitting it unconditionally costs nothing functionally but advertises to every scanner and every caller that the service is willing to be reached across an address space boundary, which is information you do not need to publish.

Does the grant have to be repeated after Access-Control-Max-Age expires?

Yes. The grant is part of the cached preflight result, not a persistent property of the host. When the entry expires the browser sends a fresh OPTIONS request carrying Access-Control-Request-Private-Network: true again, and your server must answer it the same way. This is why a service that loses its CORS configuration during a restart can appear healthy for several minutes before every client fails at once.