Private Network Access Controls
A page on the public internet asks the browser for http://192.168.4.21:8443/api/status. Nothing about that request is unusual by CORS standards — it is a GET, it carries no custom headers, and the device answers with a permissive Access-Control-Allow-Origin. It is blocked anyway, and the console message mentions a header most developers have never emitted: Access-Control-Allow-Private-Network. This page covers the second, less familiar admission check that Chromium applies to any request crossing from a more public network into a more private one, and it belongs to Core CORS Mechanics & Same-Origin Policy Fundamentals, which sets out the origin model the rest of these rules build on.
The check exists because the browser is the only component that sits on both sides of the boundary. A router’s firewall stops packets arriving from the internet, but it does nothing about a packet that a machine inside the LAN sends to another machine inside the LAN — and that is exactly what happens when an attacker’s page runs JavaScript in a visitor’s browser and points it at 192.168.1.1. Historically that gave any website a free port scanner and a way to reach admin panels, printers, media servers, and development agents that were never designed to be exposed. The W3C Private Network Access specification (originally drafted as CORS-RFC1918) closes that path by requiring the private target to explicitly opt in before the browser will deliver the request.
The Three Address Spaces
The specification partitions every IP address the browser might connect to into one of three address spaces, ordered from least to most private. Every request has an initiator address space (where the document was loaded from) and a target address space (where the request is going).
| Address space | Ranges it covers | Typical inhabitants |
|---|---|---|
public |
Everything not listed in the two rows below | Your CDN, your API, any site on the internet |
private |
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, fc00::/7 |
Routers, NAS boxes, printers, staging servers, IoT bridges |
local |
127.0.0.0/8, ::1, and the name localhost |
Dev servers, desktop agents, hardware SDK daemons |
Later revisions of the specification rename these to public, local, and loopback respectively, but the header names shipped in browsers still say “private network”, and it is those names your server has to match byte for byte. Throughout this page “loopback” and the spec’s original “local” mean the same thing: 127.0.0.1 and friends.
The rule the browser enforces is directional. Moving down the ladder — from a more public space to a more private one — is what needs permission. Moving up or staying level is ordinary CORS and follows the origin matching rules and validation you already know.
Note that the initiator’s address space is decided by the IP the document was fetched from, not by the hostname. A page served from https://dash.observa.io resolves to a routable address and is therefore public, even if the machine rendering it is sitting on the same LAN as the target. That asymmetry is the source of most “it works on my laptop” reports: an engineer testing from http://192.168.4.30:3000 is already in the private space, so a fetch to 192.168.4.21 never crosses a boundary and never produces the failure the customer sees.
Header Reference
Only two headers are specific to this mechanism, but they sit inside the normal preflight exchange and cannot be read in isolation.
| Header | Direction | Value | Set by | Notes |
|---|---|---|---|---|
Access-Control-Request-Private-Network |
Request (preflight only) | true |
Browser, automatically | Appears only on the OPTIONS preflight, never on the actual request. You cannot add it from JavaScript — it is on the forbidden header name list |
Access-Control-Allow-Private-Network |
Response (preflight only) | true |
Your server | Any other value, including True or 1, is treated as absent. Case-sensitive |
Access-Control-Allow-Origin |
Response | Exact origin or * |
Your server | Still mandatory; the private network grant does not substitute for it |
Access-Control-Allow-Methods |
Response (preflight) | Method list | Your server | Must cover the method named in Access-Control-Request-Method |
Access-Control-Max-Age |
Response (preflight) | Seconds | Your server | Caches the whole preflight result, private network grant included |
Content-Security-Policy: treat-as-public-address |
Response (on the page) | Directive, no value | Your server | Forces the document into the public space so LAN-hosted test pages still exercise the check |
The preflight itself looks almost exactly like the one described in Simple vs Preflight Requests — one extra request line in, one extra response line out.
When the Browser Inserts the Check
Three conditions must all hold before a private network preflight is generated. Getting any one of them wrong produces a different failure with a different console message, so it is worth walking the decision explicitly.
- The document must be a secure context. HTTPS pages qualify; so do pages served from
http://localhostandhttp://127.0.0.1, because those origins are potentially trustworthy. A page served over plainhttp://from a routable address is refused outright — Chromium does not even send the preflight, and no response header can re-enable the request. - The target must be in a strictly more private address space than the initiator. The comparison uses the resolved IP, so a hostname that resolves to
192.168.4.21is treated asprivateeven though it looks like an ordinary domain name. This is deliberate: it is what blocks the DNS rebinding trick where a public hostname is re-resolved to a LAN address between the first and second request. - The request must not be a navigation. Top-level navigations and iframe loads follow a separate (and currently looser) path; the header handshake described here applies to subresource and
fetchtraffic.
Reading the Failure Message
Each of those three conditions produces a distinct console string, and telling them apart saves hours because the fixes have nothing in common. The message you almost certainly arrived here with is the third one:
Access to fetch at 'http://192.168.4.21:8443/api/status' from origin
'https://dash.observa.io' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check: No
'Access-Control-Allow-Private-Network' header was present on the
requested resource.
That is a server-side fix: the preflight reached the device, the device answered, and its answer lacked one line. Compare it with the secure context refusal, which never reaches the device at all:
The request client is not a secure context and the resource is in
more-private address space `private`.
Nothing you deploy to the device changes that outcome — the page has to move to HTTPS. The third variant appears when the address space transition is fine but the ordinary CORS contract is broken, and it reads exactly like any other preflight rejection, which is why it is so often misdiagnosed as a private network problem.
| Console fragment | What actually failed | Where the fix lives |
|---|---|---|
No 'Access-Control-Allow-Private-Network' header was present |
The device answered the preflight but withheld the grant | Device or the proxy in front of it |
The request client is not a secure context |
The calling page is plain HTTP on a routable address | The page’s hosting, not the device |
No 'Access-Control-Allow-Origin' header is present |
Ordinary CORS failed first; the address space check was never reached | The device’s origin allowlist |
Method GET is not allowed by Access-Control-Allow-Methods |
The grant may be present, but the method list is wrong | The device’s preflight branch |
| Request never appears in the Network panel | The extension or service worker layer intercepted it, or the browser is not Chromium | The client, before the network stack |
The last row deserves emphasis. Because the private network preflight is synthesised by the browser rather than by your code, it can be absent for reasons that have nothing to do with your configuration — a different engine, an enterprise policy, or an extension rewriting the URL. Establish that the OPTIONS request exists before you start changing headers, using the techniques in Inspecting Preflight in the DevTools Network Panel.
Step-by-Step Implementation
The work splits cleanly: recognise the request header, decide whether this origin is entitled to reach the device at all, and emit the grant only when it is.
1. Recognise the header without special-casing the method
The private network preflight arrives as an OPTIONS request like any other, so your existing preflight branch already receives it. The only change is one conditional response header. Resist the temptation to key the grant off the path or the method — key it off the presence of the request header, so a browser that stops sending it never receives a grant it did not ask for.
2. Express: a version-agnostic middleware
Registering the branch as middleware rather than an app.options() route keeps it working across Express 4 and Express 5, whose wildcard path syntax differs.
const express = require('express');
const app = express();
const ALLOWED_ORIGINS = new Set(['https://dash.observa.io']);
app.use((req, res, next) => {
const origin = req.get('Origin');
res.setHeader('Vary', 'Origin, Access-Control-Request-Private-Network');
if (!origin || !ALLOWED_ORIGINS.has(origin)) {
return req.method === 'OPTIONS' ? res.status(403).end() : next();
}
res.setHeader('Access-Control-Allow-Origin', origin);
if (req.method !== 'OPTIONS') return next();
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Agent-Token');
res.setHeader('Access-Control-Max-Age', '600');
if (req.get('Access-Control-Request-Private-Network') === 'true') {
res.setHeader('Access-Control-Allow-Private-Network', 'true');
}
return res.status(204).end();
});
app.get('/api/status', (req, res) => res.json({ state: 'ready' }));
app.listen(8443);
The Vary value is doing real work here. Because the grant is conditional on a request header, any cache between the browser and the process — including a corporate proxy — must key on that header or it will hand a cached, grant-free 204 to the next caller.
3. Nginx: two map blocks and one conditional header
Nginx exposes arbitrary request headers as $http_ variables with dashes lowered to underscores, so the private network request header is $http_access_control_request_private_network. Mapping it to an empty string by default is what makes add_header skip the line entirely for callers that did not ask.
map $http_origin $observa_allow_origin {
default "";
"https://dash.observa.io" $http_origin;
}
map $http_access_control_request_private_network $observa_pna_grant {
default "";
"true" "true";
}
server {
listen 8443;
server_name _;
location /api/ {
add_header Vary "Origin, Access-Control-Request-Private-Network" always;
add_header Access-Control-Allow-Origin $observa_allow_origin always;
if ($request_method = OPTIONS) {
add_header Vary "Origin, Access-Control-Request-Private-Network" always;
add_header Access-Control-Allow-Origin $observa_allow_origin always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, X-Agent-Token" always;
add_header Access-Control-Allow-Private-Network $observa_pna_grant always;
add_header Access-Control-Max-Age 600 always;
return 204;
}
proxy_pass http://127.0.0.1:9100;
}
}
Nginx resets the inherited add_header set inside an if block, which is why the two shared headers are repeated. That duplication is not cosmetic — omit it and preflight responses ship without Access-Control-Allow-Origin while normal responses have it, producing a failure that looks like an intermittent policy bug.
4. Go: the shape most embedded agents need
Devices and desktop agents rarely have Nginx in front of them. A small net/http middleware gives the same behaviour with no dependencies.
package main
import "net/http"
var allowedOrigins = map[string]bool{
"https://dash.observa.io": true,
}
func corsPNA(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Add("Vary", "Origin")
h.Add("Vary", "Access-Control-Request-Private-Network")
origin := r.Header.Get("Origin")
if !allowedOrigins[origin] {
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
return
}
h.Set("Access-Control-Allow-Origin", origin)
if r.Method == http.MethodOptions {
h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
h.Set("Access-Control-Allow-Headers", "Content-Type, X-Agent-Token")
h.Set("Access-Control-Max-Age", "600")
if r.Header.Get("Access-Control-Request-Private-Network") == "true" {
h.Set("Access-Control-Allow-Private-Network", "true")
}
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"state":"ready"}`))
})
http.ListenAndServe("0.0.0.0:8443", corsPNA(mux))
}
5. Force the check on during development
A developer testing from a laptop already on the LAN will never see the preflight, because both ends sit in the private space. Send this header on the page — not the device — to move the document into the public space artificially:
Content-Security-Policy: treat-as-public-address
With that directive in place, a test harness served from http://192.168.4.30:3000 produces exactly the transition a real customer’s browser produces from https://dash.observa.io, including the extra preflight and the failure when the grant is missing.
Edge Cases and Security Boundaries
The grant is not authentication
Access-Control-Allow-Private-Network: true says “a browser may deliver cross-origin requests here from an allowlisted origin”. It says nothing about who is driving that browser. A device that trusts any request arriving on the LAN is still exposed to every user of every machine on that LAN, and to any page whose origin you allowlisted. Keep a real credential — a bearer token, a paired device secret, a mutual TLS certificate — on top of the CORS layer, and treat the topics in Credential Sharing & Security Boundaries in CORS as the baseline rather than the ceiling.
DNS rebinding still needs its own defence
The address space comparison uses resolved IPs, which blocks the naive rebinding attack, but a device that accepts any Host header remains reachable if an attacker can point a hostname they control at the device’s address and keep the browser from noticing the change. Validate the Host header against the addresses and names the device actually answers on, and reject anything else with a 421 or 400.
Wildcards and credentials do not mix here either
Because a LAN device rarely has a list of the origins it will serve, wildcards are tempting. They are worse here than in ordinary CORS: Access-Control-Allow-Origin: * combined with the private network grant makes the device reachable from every website a user visits. The wildcard is also silently incompatible with credentialed requests — the same conflict described in Wildcard CORS Risks and Safe Origin Allowlisting — so an agent that relies on cookies will fail anyway.
The null origin
Sandboxed iframes, data: URLs, and some redirect chains present Origin: null. A device that accepts null accepts all of them at once, and a sandboxed iframe is exactly the container an attacker would use to hide the attempt. Reject null on private network paths without exception.
The grant is cached with everything else
Access-Control-Max-Age caches the whole preflight result, so a device that answers correctly once and then loses its configuration will keep working until the cache entry expires. Conversely, a device that answered incorrectly keeps failing after you fix it. The clamping and eviction behaviour is the same as for any preflight and is covered in Cache Duration Tuning & Max-Age; a modest value such as 600 seconds keeps a firmware rollout from being masked by stale grants.
Proxy, Extension and Platform Interaction
Private network traffic rarely passes through a CDN, but it passes through plenty of other things.
Corporate TLS-inspecting proxies re-terminate connections and frequently strip response headers they do not recognise. Access-Control-Allow-Private-Network is exactly the kind of header an older proxy drops, producing a failure that reproduces only on the corporate network. Confirm at the proxy, not only at the device.
Service workers intercept fetch before the network layer, but the address space check is applied to the request the service worker ultimately issues. A worker that rewrites a public URL into a LAN address moves the request into a transition it did not previously have, and the preflight appears where none existed before.
Browser support diverges sharply. Only Chromium-based browsers implement the header handshake today, and Chromium is layering a user-facing permission prompt on top of it for local network access — meaning a correct header exchange may still require the visitor to approve the connection. Firefox and Safari apply their own, narrower restrictions to loopback and mixed content instead. Building for “the browser will ask and my device will answer” is only safe on Chromium; everywhere else the request either works under ordinary CORS rules or is refused for a different reason entirely.
DevTools and curl Verification Checklist
curl -si -X OPTIONS http://192.168.4.21:8443/api/status \ -H 'Origin: https://dash.observa.io' \ -H 'Access-Control-Request-Method: GET' \ -H 'Access-Control-Request-Private-Network: true'curl -si -X OPTIONS http://192.168.4.21:8443/api/status \ -H 'Origin: https://attacker.example' \ -H 'Access-Control-Request-Method: GET' \ -H 'Access-Control-Request-Private-Network: true' | grep -i 'access-control'
Common Mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
Emitting Access-Control-Allow-Private-Network only on the actual response |
The preflight is what carries the check; the grant on the real response is never read, so every request is blocked | Set it inside the OPTIONS branch, alongside Access-Control-Allow-Methods |
Returning True or 1 instead of true |
Value comparison is exact and case-sensitive; the grant is treated as absent | Emit the literal lowercase string true |
Pairing the grant with Access-Control-Allow-Origin: * |
Every website a visitor opens can drive the device through their browser | Validate Origin against a fixed allowlist and echo the exact match |
Omitting Access-Control-Request-Private-Network from Vary |
A proxy or shared cache serves a grant-free preflight to a caller that needed one, or vice versa | Add both Origin and the request header to Vary on every response |
| Testing from a machine already on the LAN | No address space transition occurs, so the preflight never fires and the bug is invisible until release | Add Content-Security-Policy: treat-as-public-address to the test page |
| Serving the calling page over plain HTTP | Chromium refuses before sending anything and no device-side header can help | Serve the page over HTTPS, or test from http://localhost which is a secure context |
| Assuming a working grant means every browser will connect | Only Chromium implements the handshake; other engines block or allow for unrelated reasons | Feature-detect the failure and offer a fallback path rather than assuming the header is sufficient |
FAQ
Why does a plain GET to my LAN device trigger a preflight?
Because the private network check is not the ordinary CORS preflight trigger. A GET with no custom headers is a simple request under the Fetch Standard and normally travels without an OPTIONS round trip, but when the target IP sits in a more private address space than the page that initiated it, the browser inserts a preflight anyway and marks it with Access-Control-Request-Private-Network: true. The method and headers are irrelevant; the address space transition alone is the trigger.
Does Access-Control-Allow-Private-Network replace the normal CORS headers?
No. It is an additional requirement layered on top of them. The preflight response still needs Access-Control-Allow-Origin matching the requesting origin, Access-Control-Allow-Methods covering the intended method, and Access-Control-Allow-Headers covering any non-safelisted request headers. Access-Control-Allow-Private-Network: true only clears the address space transition. Send the grant without a valid Allow-Origin and the preflight still fails.
Can an HTTP page reach a private network address?
Not in Chromium. The private network check requires the initiating document to be a secure context, so a page served over plain HTTP from a public address is refused before any preflight is attempted and no header on the device can re-enable it. Pages served from http://localhost or http://127.0.0.1 are treated as potentially trustworthy and therefore count as secure contexts, which is why local development often works while the deployed HTTP version does not.
How do I test the private network preflight from a machine that is already on the LAN?
Send the Content-Security-Policy: treat-as-public-address header on the page doing the fetching. That directive forces the browser to classify the document as if it were loaded from a public address, so a request to a 192.168 or 127.0.0.1 target becomes an address space transition and produces the private network preflight you want to exercise. Without it a page already on the LAN sits in the same address space as the device and the extra preflight never fires.
Should I set Access-Control-Allow-Private-Network with a wildcard origin?
No. Pairing the grant with Access-Control-Allow-Origin: * turns the device into an endpoint that any website on the internet can drive through a visitor’s browser, which is exactly the attack the address space check exists to stop. Validate the incoming Origin against a fixed allowlist, echo only the exact matched string, and emit Vary: Origin, Access-Control-Request-Private-Network so intermediaries never reuse a grant across origins.