Preparing an API for Private Network Preflights
You are shipping the Lumen Bridge, a LAN device that listens on http://192.168.1.50 and is configured from a web portal at https://portal.lumenlabs.co. It worked throughout development. The first Chromium support tickets arrive the week after launch, all quoting the same console output:
Access to fetch at 'http://192.168.1.50/api/state' from origin 'https://portal.lumenlabs.co'
has been blocked by CORS policy: Response to preflight request doesn't pass access control
check: It does not have HTTP ok status.
Root cause
The embedded HTTP server has no OPTIONS handler, so it answers the preflight with 501 — and the browser only accepts a preflight response in the 2xx range. The preflight itself exists because the portal page came from a routable address while the device answers on 192.168.1.50, which is a private address, and Chromium requires an explicit opt-in for that step. Nothing about your development setup exercised this: an engineer browsing to a staging portal from the same subnet is already inside the private address space, so no boundary is crossed and no preflight is generated. The mechanics are laid out in Private Network Access Controls; this guide is the shipping checklist that turns them into firmware you can release.
Prerequisite state
- The portal is served over HTTPS and its origin is stable enough to be named. Chromium will not attempt a private network request from an insecure document at all.
- You can release firmware, or at least push a configuration change, to installed devices.
- The device already binds a known port and answers the routes the portal calls; this guide adds an admission layer in front of them, not a new API.
- You have a way to test from outside the device’s subnet. Everything below is invisible from a laptop on the same LAN.
Step-by-step preparation
Step 1 — Inventory the surface the portal actually touches
The grant is per preflight, and a preflight is keyed by URL, method and header set. Write down the real matrix before you write code, because a route you forget is a route that fails after launch.
| Route | Methods the portal uses | Request headers | Preflight expected |
|---|---|---|---|
/api/state |
GET, PUT |
Content-Type, X-Bridge-Token |
Yes — private target, and PUT is not a simple method |
/api/telemetry |
GET |
none | Yes — the address space transition alone forces it |
/api/firmware |
POST |
Content-Type: application/json |
Yes |
/health |
GET |
none | Yes, unless the portal never calls it cross-origin |
Every row needs the same treatment. There is no such thing as a route that escapes the check because its request looked simple — that exemption does not exist once the address space changes, which is the difference between this and the classification described in Simple vs Preflight Requests.
Step 2 — Put the CORS layer in front of authentication
Browsers never attach credentials to a preflight. An OPTIONS request arrives with no bearer token, no cookie and no device secret, so an auth layer that runs first will reject it — and its 401 carries no CORS headers, which the browser reports as a CORS failure while your device logs an auth failure. The two teams then debug different bugs.
Step 3 — Implement the admission layer
An aiohttp service is a fair stand-in for the embedded Python and MicroPython stacks that ship on this class of hardware. In aiohttp, the first entry in middlewares is the outermost, so the CORS layer belongs at the front of that list.
from aiohttp import web
ALLOWED_ORIGINS = {"https://portal.lumenlabs.co"}
VARY = "Origin, Access-Control-Request-Private-Network"
@web.middleware
async def cors_private_network(request, handler):
origin = request.headers.get("Origin")
allowed = origin in ALLOWED_ORIGINS
is_preflight = (
request.method == "OPTIONS"
and "Access-Control-Request-Method" in request.headers
)
if is_preflight:
if not allowed:
return web.Response(status=403, headers={"Vary": VARY})
headers = {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, PUT, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-Bridge-Token",
"Access-Control-Max-Age": "300",
"Vary": VARY,
}
if request.headers.get("Access-Control-Request-Private-Network") == "true":
headers["Access-Control-Allow-Private-Network"] = "true"
return web.Response(status=204, headers=headers)
response = await handler(request)
response.headers["Vary"] = VARY
if allowed:
response.headers["Access-Control-Allow-Origin"] = origin
return response
@web.middleware
async def require_device_token(request, handler):
if request.headers.get("X-Bridge-Token") != load_paired_token():
raise web.HTTPUnauthorized()
return await handler(request)
async def read_state(request):
return web.json_response({"lamp": "on", "firmware": "2.4.1"})
app = web.Application(middlewares=[cors_private_network, require_device_token])
app.add_routes([web.get("/api/state", read_state), web.put("/api/state", read_state)])
Step 4 — Move the allowlist out of the firmware image
A hardcoded portal origin means a firmware release every time marketing renames the product. Read it from the signed settings record the device already refreshes instead:
{
"settings_version": 7,
"cors": {
"allowed_origins": [
"https://portal.lumenlabs.co",
"https://portal.lumenlabs.dev"
],
"max_age_seconds": 300
}
}
Validate the signature before applying it. An unsigned settings channel on a LAN device lets anyone on the network add their own origin, which converts a careful allowlist into an open door.
Step 5 — Assert the contract in tests, not by hand
The preflight is a header contract, and header contracts rot silently. Two pytest cases cover the pair of outcomes that matter.
import pytest
from bridge import app
PORTAL = "https://portal.lumenlabs.co"
@pytest.fixture
async def client(aiohttp_client):
return await aiohttp_client(app)
async def test_grant_is_emitted_for_the_portal(client):
res = await client.options(
"/api/state",
headers={
"Origin": PORTAL,
"Access-Control-Request-Method": "PUT",
"Access-Control-Request-Private-Network": "true",
},
)
assert res.status == 204
assert res.headers["Access-Control-Allow-Origin"] == PORTAL
assert res.headers["Access-Control-Allow-Private-Network"] == "true"
async def test_grant_is_withheld_from_unknown_origins(client):
res = await client.options(
"/api/state",
headers={
"Origin": "https://not-lumenlabs.example",
"Access-Control-Request-Method": "PUT",
"Access-Control-Request-Private-Network": "true",
},
)
assert res.status == 403
assert "Access-Control-Allow-Private-Network" not in res.headers
Step 6 — Sequence the rollout around the cache
Access-Control-Max-Age is a rollout dial. Ship low, so a mistake clears within minutes across the whole fleet; raise it only once the firmware carrying the grant has reached effectively every install and the portal origin has stopped moving.
Two orderings matter and they pull in opposite directions. The firmware carrying the grant must be widespread before the portal starts relying on it, or early adopters see the failure this page opened with. The portal origin cutover must happen after the allowlist that names the new origin has reached the fleet, or every updated device rejects the portal it is supposed to serve. Holding the cutover to day ten in the plan above buys a margin on both sides, and the short cache lifetime means any device that does get it wrong recovers within a browser session rather than requiring the customer to restart anything.
Keep one escape hatch in the firmware: a locally reachable diagnostic route that reports the effective allowlist and Max-Age the device is currently applying. When a support ticket arrives, that single response tells you whether the device has the settings record you think it has, which is the question that otherwise takes a remote debugging session to answer.
Verification
curl -si -X OPTIONS http://192.168.1.50/api/state \ -H 'Origin: https://portal.lumenlabs.co' \ -H 'Access-Control-Request-Method: PUT' \ -H 'Access-Control-Request-Private-Network: true'
Security boundary note
A LAN device is the one place where a permissive CORS policy is directly exploitable rather than theoretically risky: the attacker’s page runs inside the perimeter by definition, using a customer’s browser as the bridge. Never ship Access-Control-Allow-Origin: * on a device, never accept Origin: null, and keep the device token mandatory on every route that reads or changes state. Bind to the interface you need and no more, reject Host headers you do not recognise so a rebinding attempt cannot reuse the grant, and treat the origin allowlist as a security control with the same review requirements as the authentication code — the reasoning is the same one set out in Wildcard vs Dynamic Origin Reflection: When to Use Each.
Common mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
No OPTIONS route at all on the embedded server |
The preflight gets 501 or 405, which is not an ok status, so every route fails regardless of headers |
Answer OPTIONS in the admission layer before routing |
| Authentication middleware registered ahead of the CORS layer | The credential-free preflight is rejected with 401 and no CORS headers, and the logs blame the wrong subsystem |
Put the CORS layer first in the middleware list |
| Portal origin compiled into the firmware image | A hostname change strands every device that cannot take a firmware update | Read the allowlist from a signed settings record |
Shipping a long Access-Control-Max-Age on day one |
A bad grant persists in every browser for hours after the fix is deployed | Ship at 300 seconds and raise it once the fleet has converged |
FAQ
Where should the allowlist live on a device I cannot easily update?
Put it in a configuration record the device already refreshes, not in the firmware image. A device that hardcodes the portal origin needs a firmware release every time that hostname changes, and firmware releases on consumer hardware take months to reach the slowest installs. Reading the allowlist from a signed settings payload the device already fetches turns an origin change into a configuration push, while the signature keeps an attacker on the same network from injecting their own origin.
Does the middleware order really matter for the preflight?
Yes, and it is the single most common defect in device firmware. Browsers never attach credentials to a preflight, so an authentication layer that runs before the CORS layer sees an OPTIONS request with no token and answers 401 with no CORS headers at all. The browser reports that as a CORS failure, and the device logs it as an auth failure, so the two teams look at different things. Answer the preflight first, then authenticate the real request.
What Access-Control-Max-Age should a shipping device use?
Keep it low while the fleet is still rolling out — 300 seconds is a good default — so a bad build clears itself in minutes rather than persisting for the whole browsing session. Once the firmware carrying the grant has reached effectively every install and the portal origin is stable, raise it so that repeat visitors stop paying for a preflight on every session. Treat the value as a rollout dial, not a constant.