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.

Where the address space mismatch actually comes from The portal page is delivered from the public internet across a network boundary into a browser on the local subnet. The browser then calls the Lumen Bridge on the same subnet, so the request never leaves the LAN even though the document that issued it did not originate there. Portal document served from portal.lumenlabs.co network boundary — nothing below this line is routable from the internet Browser on the LAN 192.168.1.24 document space: public stays on the LAN Lumen Bridge 192.168.1.50 target space: private The packets never cross the boundary, but the document that issued them did. That mismatch — public document, private target — is the entire trigger for the extra preflight.

Prerequisite state

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.

Middleware order decides whether the preflight can ever succeed The left pipeline runs authentication before the CORS layer, so the credential-free OPTIONS request is rejected with a 401 that carries no CORS headers. The right pipeline answers the preflight in the CORS layer and authenticates only the real request that follows. Authentication first CORS layer first OPTIONS /api/state arrives with no token, as browsers always send it Auth layer finds no credential and short-circuits with 401 CORS layer never runs no grant, no allow-origin, no allow-methods Browser reports a CORS failure the device log says authentication OPTIONS /api/state arrives with no token, as browsers always send it CORS layer answers it directly 204 with allow-origin and the grant Browser releases the real PUT this one does carry the device token Auth runs on the real request nothing is weakened, only reordered Answering the preflight before authenticating costs no security: the preflight carries no data and grants no access on its own

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.

Sequencing the firmware ramp against the preflight cache Three lanes share a fourteen-day axis. The firmware ramp moves from canary to half the fleet to every device, the preflight cache stays at a short Max-Age until day eleven before being raised, and the portal origin cutover is held back to day ten. Order the rollout so a bad grant expires faster than it spreads Firmware carrying the grant canary half the fleet every device Preflight cache lifetime Max-Age 300 s while the fleet is still moving raised Portal origin cutover old origin only, still in every allowlist new origin live day 0 4 8 12 The origin cutover waits until the firmware that allowlists it is already everywhere

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

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.