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.
Prerequisite state
- The dashboard is served over HTTPS. Chromium refuses private network requests from insecure documents before any preflight is sent, and no collector-side header changes that.
- The collector already returns a correct
Access-Control-Allow-Originforhttps://console.metricly.devon bothOPTIONSandGET. If it does not, fix that first — the address space check runs after the ordinary CORS check, and you will chase the wrong header. - You can restart the collector or reload its reverse proxy. The grant is a response header, so nothing on the client side can supply it.
- Chromium (or a Chromium-derived browser) is the test client. Firefox and Safari do not send the request header at all.
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.
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.
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
curl -si -X OPTIONS http://10.20.0.15:9000/v1/metrics \ -H 'Origin: https://console.metricly.dev' \ -H 'Access-Control-Request-Method: GET' \ -H 'Access-Control-Request-Private-Network: true'
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.