Browsers used to treat “public website calls my router or local dev box” as mostly a weird edge case. That changed. Private Network Access, or PNA, adds another browser-enforced check when a page on a less-private network tries to reach a more-private one.
If you build APIs, admin panels, local device UIs, or anything that runs on localhost, your CORS setup now has a second layer to think about.
The short version:
- CORS controls whether a cross-origin response can be read
- PNA controls whether a public or less-private page may even make the request to a private target
- PNA often adds an extra preflight
- Your server may need to send both classic CORS headers and PNA-specific headers
What counts as a private network?
Browsers classify address spaces roughly like this:
- public: normal internet addresses
- private: RFC1918 ranges like
192.168.0.0/16,10.0.0.0/8,172.16.0.0/12 - local: loopback and directly local targets like
127.0.0.1, sometimeslocalhost
The dangerous transition is from less private to more private.
Examples:
https://app.example.comcallinghttp://192.168.1.1→ public to privatehttps://dashboard.example.comcallinghttp://127.0.0.1:3000→ public to localhttp://192.168.1.50callinghttp://127.0.0.1:8080→ private to local
That’s where PNA kicks in.
Why browsers added PNA
Without PNA, a malicious public site could try to talk to devices on your LAN: routers, NAS boxes, printer panels, internal admin tools, or local developer services.
Even if the attacker couldn’t read every response because of CORS, they could still trigger state-changing requests or probe what exists on the network.
PNA tightens that path by requiring explicit permission from the target server.
CORS first: the baseline
A lot of people still mix up CORS with “who can send requests.” Browsers can send cross-origin requests all day. CORS decides whether frontend JavaScript gets access to the response.
A typical preflighted CORS flow looks like this:
OPTIONS /api/data HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
Server:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600
Then the browser sends the real request.
You can see how common permissive CORS is in public APIs. For example, api.github.com sends:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
That tells the browser:
- any origin may read the response
- JavaScript may also read those non-simple response headers
That’s standard CORS. PNA is separate.
What PNA adds to the preflight
When a request targets a more-private address space, the browser can send a preflight with an extra header:
OPTIONS /status HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: GET
Access-Control-Request-Private-Network: true
To allow it, the server needs to answer with the usual CORS headers and:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, OPTIONS
Access-Control-Allow-Private-Network: true
That Access-Control-Allow-Private-Network: true is the key PNA signal.
If you forget it, the browser blocks the request even if your CORS policy is otherwise fine.
A concrete example: public app calling a local agent
Say you ship a web app at:
https://app.example.com
And it needs to talk to a local desktop agent on:
http://127.0.0.1:43110
Frontend:
async function getLocalStatus() {
const res = await fetch('http://127.0.0.1:43110/status', {
method: 'GET',
mode: 'cors'
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
}
That looks innocent, but from the browser’s perspective it’s public → local, so expect a PNA preflight.
Express server example
Here’s a minimal Node/Express service that handles it correctly:
import express from 'express';
const app = express();
const allowedOrigins = new Set([
'https://app.example.com'
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && allowedOrigins.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
next();
});
app.options('/status', (req, res) => {
const origin = req.headers.origin;
const reqMethod = req.headers['access-control-request-method'];
const wantsPrivateNetwork = req.headers['access-control-request-private-network'];
if (!origin || !allowedOrigins.has(origin)) {
return res.sendStatus(403);
}
if (reqMethod !== 'GET') {
return res.sendStatus(405);
}
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Private-Network', 'true');
res.setHeader('Access-Control-Max-Age', '600');
res.setHeader('Vary', 'Origin');
if (wantsPrivateNetwork === 'true') {
return res.sendStatus(204);
}
return res.sendStatus(400);
});
app.get('/status', (req, res) => {
res.json({
ok: true,
service: 'local-agent'
});
});
app.listen(43110, '127.0.0.1', () => {
console.log('Listening on http://127.0.0.1:43110');
});
A few opinions from painful experience:
- Don’t use
Access-Control-Allow-Origin: *for local admin or agent services. - Don’t blindly reflect any
Origin. - Don’t treat PNA as a replacement for auth. It’s not.
If that local service can do anything sensitive, require a real token or user-mediated pairing flow.
Full preflight response example
A browser-friendly preflight response for a local JSON API might look like this:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Private-Network: true
Access-Control-Max-Age: 600
Vary: Origin
That covers:
- origin permission
- methods
- request headers
- private network permission
- cache duration
- caching safety with
Vary: Origin
Credentials still follow normal CORS rules
PNA does not relax credential handling.
If your frontend uses cookies or HTTP auth:
fetch('http://192.168.1.20/api', {
credentials: 'include'
});
then your server must still use explicit CORS settings:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
And no, you cannot combine credentials with Access-Control-Allow-Origin: *. Browsers reject that. Same as regular CORS.
Common failure modes
1. “CORS looks correct, but the request still fails”
Check the preflight in DevTools. If you see Access-Control-Request-Private-Network: true, then you need Access-Control-Allow-Private-Network: true.
2. Reflecting every origin
This is the classic bad middleware:
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
That’s basically “every website may read and drive my internal service.” Terrible idea for anything on localhost or a LAN IP.
3. Forgetting OPTIONS
A lot of small APIs only implement GET and POST, then wonder why browsers fail before the request ever reaches the handler. Preflight means you need a clean OPTIONS path.
4. Missing Vary: Origin
If your response changes per origin and you don’t send Vary: Origin, caches can serve the wrong CORS result to the wrong caller.
5. Assuming same-origin dev behavior matches production
Local development often hides these issues because everything runs on localhost with weird browser exceptions or flags. Test your real deployment shape early.
A stricter pattern for internal services
For internal or local APIs, I like this checklist:
- allow only specific origins
- allow only required methods
- allow only required headers
- send
Access-Control-Allow-Private-Network: trueonly when truly needed - require authentication for sensitive actions
- reject requests with missing or unexpected
Origin - log preflight failures
Example with a tighter method/header policy:
app.options('/config', (req, res) => {
const origin = req.headers.origin;
const reqHeaders = req.headers['access-control-request-headers'] || '';
if (origin !== 'https://app.example.com') {
return res.sendStatus(403);
}
if (reqHeaders.toLowerCase() !== 'content-type') {
return res.sendStatus(400);
}
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Access-Control-Allow-Private-Network', 'true');
res.setHeader('Vary', 'Origin');
res.sendStatus(204);
});
Testing PNA behavior
Use browser DevTools, not just curl.
curl is useful for checking headers:
curl -i -X OPTIONS http://127.0.0.1:43110/status \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Private-Network: true"
But curl won’t enforce browser rules. The browser is the policy engine here.
What I usually verify in DevTools:
- was there an
OPTIONSpreflight? - did it include
Access-Control-Request-Private-Network: true? - did the response include
Access-Control-Allow-Private-Network: true? - did the actual request fire afterward?
When to care
You should care about PNA if your browser-based app talks to:
localhost- loopback IPs
- home/office router UIs
- printers, cameras, NAS devices
- internal dashboards on private IPs
- desktop helper apps exposing HTTP APIs
If your API is just public internet to public internet, classic CORS is usually the whole story.
For the browser-facing security model, the authoritative source is the official docs and specs from browser vendors and standards bodies. For broader header hardening beyond CORS, Content Security Policy and related headers are worth reviewing too, and I usually keep a CSP reference nearby when locking down admin apps.
PNA makes one thing explicit: if your public web app wants to reach into a user’s local or private network, the target service must opt in. That’s a good change. It breaks some old assumptions, but those assumptions were shaky anyway.