Web Development

Your Helper on 127.0.0.1 Now Needs Permission: Four Browser-to-Device Architectures, Compared

A printer, a scale, a POS terminal, a local agent on loopback — the integration that has worked for years now depends on a permission prompt the user can dismiss, and the browser reports the dismissal as a failed fetch. Four ways to reach local hardware, ranked by what each does when the answer is no.

KAKabir AnandLead Developer
11 min read
Flat vector illustration of a delivery trolley stopped at a closed inner gate inside a building while the outer gate behind it stands wide open

The ticket never says "permission". It says the label printer has stopped working, or the weighing scale shows zero, or the POS terminal cannot find the cash drawer any more — on some machines, not all of them, and only in the browser. The hardware is fine. The network is fine. The only diagnosis the page itself can produce is that a fetch failed, which is the same thing it would say if the agent were not running, or the port had changed, or DNS had gone sideways.

What actually changed is a permission. Since Chrome 142, a request that crosses from a less private address space into a more private one — a public page reaching a local IP, or a public or local page reaching loopback — is gated behind a user prompt. That prompt is the one asking whether the site may "look for and connect to any device on your local network", and it is the one a lot of people reflexively dismiss. The purpose is sound: without it, any page you open can quietly probe your router, fingerprint your LAN, and fire cross-site requests at devices that were never built to defend themselves.

We ship these integrations for clients — print controllers in warehouses, scanners in document-heavy back offices, kiosk companions, store hardware behind a web POS — so we have been living inside this change rather than reading about it. The part that caught us was not Chrome 142. It was Chrome 147, which extended the same restriction to WebSocket and WebTransport connections, and to WindowClient.navigate() inside service workers. The companion-agent-on-loopback pattern, where an installed helper opens ws://127.0.0.1 and the page talks to it, had been quietly exempt. It is not exempt now. Firefox 154 took its own local-network protection to WebSockets in August, including from workers, so this stopped being a Chromium-only concern at the same time.

This post is the comparison we wish we had had in front of us a year ago: the architectures available for reaching a device on the user's side of the connection, and what each one actually does when the user says no.

The boundary is the address space, not the device

The model is a privacy lattice over IP address spaces: public, then local, then loopback, each more private than the last. Nothing is checked while you stay inside one space or move outward. The permission check fires only on a crossing inward — public to local, public to loopback, local to loopback. That framing matters, because it tells you immediately which parts of your system are affected and which are not. Your own API is not affected. A device that dials out to your API is not affected. A page that reaches into the network the user is sitting on is.

Isometric illustration of three nested enclosures — an open outer yard, a fenced inner yard with a single gated intercom, and a small locked structure at the centre
Moving outward costs nothing. Every step inward needs someone to answer the intercom, and the innermost door has its own separate key.

Chrome and Edge 145 split the original single permission into two: local-network and loopback-network, with the old local-network-access name kept only as a legacy alias. If you are feature-detecting or querying permission state, that split is the first thing that will bite you, because querying the alias is no longer a reliable read of what the browser will actually do with your next request.

Four more details decide whether your existing code works at all. Both permissions are Permissions-Policy gated with a default allowlist of 'self', so an integration living inside a cross-origin iframe — a widget, an embedded plugin, a vendor panel — needs allow="local-network; loopback-network" on the iframe and a matching header on the top-level document, or it is denied before any prompt appears. The permissions are denied automatically in an insecure http:// top-level context, so a shop-floor page still served over plain HTTP cannot even ask. Once granted, the permission relaxes mixed-content blocking for those requests, which is the concession that makes this workable at all, since local devices largely cannot hold publicly trusted certificates. And the browser only knows to apply that relaxation before resolution if the target is obviously local — a literal local IP, a .local hostname, or a fetch carrying an explicit targetAddressSpace.

That last option is also a trap worth knowing about. If you declare targetAddressSpace: 'local' on a hostname that turns out to resolve to a public address, the request is aborted immediately with a TypeError rather than being sent anywhere. That is the correct behaviour — you asserted something about the destination and the assertion was false — but it produces a "Failed to fetch" that looks exactly like every other failure in this family.

src/device/probe.ts
1const AGENT = "http://127.0.0.1:9100/status";
2 
3// query the granular name — 'local-network-access' is only a legacy alias now
4export async function deviceState(): Promise<DeviceState> {
5  const perm = await navigator.permissions
6    .query({ name: "loopback-network" } as PermissionDescriptor)
7    .catch(() => null);
8 
9  if (perm?.state === "denied") return { kind: "blocked-in-site-settings" };
10 
11  try {
12    const res = await fetch(AGENT, {
13      targetAddressSpace: "loopback",
14      signal: AbortSignal.timeout(2000),
15    });
16    return res.ok ? { kind: "ready" } : { kind: "agent-error", status: res.status };
17  } catch {
18    // one TypeError covers all of: dismissed, denied, agent down, public DNS
19    return perm?.state === "prompt"
20      ? { kind: "needs-permission" }
21      : { kind: "agent-unreachable" };
22  }
23}

Twenty lines, and most of the value is in the last five. The browser gives you one undifferentiated failure for four completely different situations, each with a different fix and a different support script. Asking the Permissions API first is the only cheap way to tell "the user has blocked us" apart from "the helper is not installed", and those two tickets should never reach the same queue.

Four ways in, compared by what happens on a no

Pick the architecture by its denial behaviour, not by how clean the happy path looks. All of these work when permission is granted. They diverge completely when it is not, and in consumer or bring-your-own-device contexts a meaningful share of users will dismiss any prompt on reflex.

ArchitectureWhat gates itWhat a denial doesWorth it when
Page fetches the device directly, by LAN IP or .local namelocal-network permission, per origin, HTTPS top-level onlyEvery call fails as a generic network error; retrying does not re-promptInternal tools on a managed fleet where policy can pre-grant
Installed companion agent on loopback (HTTP or WebSocket)loopback-network permission; WebSocket and WebTransport included since Chrome 147Same failure, and your installer cannot fix it — the grant belongs to the web origin, not the machineHardware with no usable network stack, where you already ship an installer
Device or agent dials out to your own backendNothing in the browser; no inward address-space crossing happensNot applicable — the page never touches the local networkAnything you can still design from scratch, and all unmanaged traffic
WebSerial, WebHID or WebUSB to directly attached hardwarePer-device chooser on a user gesture, plus Permissions-Policy serial, hid or usbScoped to one device, re-triable on the next gesture, and legible to the userScales, badge readers, card readers, anything on a cable
Enterprise policy pre-grantLocalNetworkAccessAllowedForUrls on a managed browserNo prompt at all — and no coverage one metre outside the managed fleetKiosks and back-office fleets you control end to end

1. The page talks to the device

This is the pattern almost everybody has, because for fifteen years it was simply how you did it: the dashboard knows the printer is at a LAN address, and it posts to it. It is the cheapest thing to build and the most exposed thing to run, because every single call sits behind a permission the user granted once, in a modal, possibly months ago, in a flow where they had no idea what the hardware was for.

Keep it only where you can also set policy. On a managed fleet, LocalNetworkAccessAllowedForUrls makes this a non-issue, and the two policies added in Chrome 146 — LocalNetworkAccessIpAddressSpaceOverrides and LocalNetworkAccessPermissionsPolicyDefaultEnabled — let an administrator correct address-space classification and policy defaults for networks that do not look the way the browser assumes. Off a managed fleet, treat every call as if it may be refused, because it may be.

2. The companion agent on loopback

The agent pattern was always the robust answer to the first one: install a small local service, give it a stable port, let it own the vendor SDK and the driver mess, and have the page speak a clean protocol to 127.0.0.1. It is still a good pattern. It just lost the property everyone was unconsciously relying on, which was that loopback felt like part of the application rather than part of the network.

The uncomfortable consequence is that the fix now lives on the wrong side of the boundary from your installer. Your installer runs with the user's blessing, can create services and firewall rules, and can verify its own health — and it cannot grant the web origin loopback-network permission, because that grant is a property of the browser profile and the origin, not of the machine. Shipping a better installer does not help. Only the page can ask, and only in a window.

If you are on this pattern, the Chrome 147 change is the one to act on: a helper reached over ws://127.0.0.1 is now subject to the same permission as a fetch to it, and the WebSocket API gives you even less diagnostic detail on failure than fetch does. Probe over HTTP first, establish the grant and the diagnosis there, and only then open the socket.

3. The device dials out

Invert the direction and the entire problem disappears. The device, or an agent next to it, holds an outbound connection to your backend and polls or subscribes for work. The browser asks your API for status and posts jobs to your API. No request ever crosses inward, so no permission is involved, and nothing on this path can be broken by a browser release or a dismissed modal.

The costs are real and you should name them rather than discover them: you now run a relay, you own job state and idempotency, offline behaviour becomes your problem instead of the LAN's, and a site with no internet loses a function that used to work on a dead link. In exchange you get a path that is uniform across browsers, works on mobile, works on an unmanaged laptop, and survives a user who clicks Block on everything. For anything we are designing today rather than maintaining, this is where we start.

4. Direct-attached device APIs

If the hardware is on a cable, WebSerial, WebHID and WebUSB are a different permission model, not a milder variation on this one. There is no address space being crossed, so Local Network Access never enters the picture. Instead the user picks a specific device from a browser chooser during a real user gesture, and the origin holds permission for that device. Permissions-Policy still applies, through the serial, hid and usb directives, so iframes need the same care as above.

The denial behaviour is the best of any option here, and that is the real argument for it. A cancelled chooser is a scoped, legible, re-triable event: the user cancelled, your UI can say so, and the next button press opens the chooser again. Compare that to a dismissed network prompt, which silently poisons every call to every device for that origin until somebody finds site settings. The ceiling is lower — you are implementing a device protocol yourself, and support is uneven outside Chromium — but where it fits, it fits well. Chrome 147 also shipped a Web Printing API, though only for Isolated Web Apps, which is a narrow enough target that it is worth watching rather than planning around.

Denied is a state, not an error

Almost every bug we have fixed in this area was the same bug wearing different hardware: the application had no representation for "the user said no." It had a success path and a catch block, and the catch block retried. Retrying does not help, because the prompt does not reappear on a second request — once the answer is recorded, every subsequent call fails straight away. A retry loop against a denied permission is a spinner that never stops, and a support call that blames the printer.

Editorial photograph of a small thermal label printer on a shop counter with its green status light lit, next to a tablet on a stand whose screen is completely dark
The device is powered, online and idle. From the application's point of view it does not exist, and nothing in the stack can tell the difference between that and a broken one.
  • Query before you call — read loopback-network or local-network through the Permissions API and branch on it. A denied state deserves instructions, not a request. Do not query the legacy local-network-access alias and assume it reflects reality on current Chrome.
  • Ask at a moment the user can connect to a cause— the prompt is raised by the first qualifying request, so you choose when that happens. A prompt fired on page load, before any hardware is mentioned, is a prompt about nothing. One fired from a “Connect printer” button is a question with context, and gets a much better answer.
  • Write the recovery UI, with the real path— the user has to re-enable it in site settings, and nothing you can call will reopen the prompt for them. That means screenshots and the actual menu names inside your own product, not a line of text saying permission was denied.
  • Give workers a grant to inherit— a service or shared worker cannot raise this prompt on its own. The origin has to obtain the permission in a window first, and a service worker without an associated document may still fail. Background sync of device state is therefore not the place to discover your permission status.
  • Clear UI state in `finally`, always — the failure arrives as an immediate rejection on a path that used to take a second or two, which is exactly the timing that exposes every optimistic update and every spinner you forgot to unset.

There is one more reason to design for denial rather than for policy: the soft landing is temporary by construction. The LocalNetworkAccessRestrictionsTemporaryOptOut policy, which demotes the block to a DevTools warning on managed browsers, has been on a removal track since it shipped. Chrome's own feature tracker lists the removal in Chrome 156, which reached stable on 20 October 2026; the enterprise policy list and the Chromium source say it will be removed after M163, which would be months later. We are not going to pretend to know which is right. That is the point — an escape hatch whose removal milestone has already moved once is a migration window, not an architecture.

What we test now, on real devices

None of this reproduces on a developer machine. Localhost development is routinely exempt, the grant is already sitting in your profile from last week, and your laptop is not on a corporate network with a captive portal and private DNS. The timeline below is what we check a client's integration against, and the matrix after it is deliberately boring — it is the only thing that has caught these for us.

MilestoneWhat changedWhat it means for an existing integration
Chrome 142Permission prompt for local and loopback requests, covering fetch, subresources and subframesDirect-to-device calls start failing for anyone who dismisses a modal
Chrome / Edge 145Permission split into local-network and loopback-network; old name kept as an aliasPermission queries written against the original name need updating
Chrome 146LocalNetworkAccessIpAddressSpaceOverrides and LocalNetworkAccessPermissionsPolicyDefaultEnabled addedManaged fleets can correct address-space classification and policy defaults
Chrome 147Restriction extended to WebSocket and WebTransport, plus WindowClient.navigate() in service workersThe loopback companion-agent pattern loses its de facto exemption
Firefox 154Local network protection extended to WebSocket connections, including from workersFallback design can no longer be scoped to Chromium only
SafariNo Local Network Access implementation; macOS applies its own local network permission at OS levelGrant state can live outside the browser, where your code cannot read it
Chrome 156Chrome's feature tracker lists removal of the temporary opt-out policy; the policy list says after M163Warn-only mode has an expiry date, even if the date is still moving
  • 1First run on a clean profile— the only state in which you will ever see your own prompt. Keep a scripted fresh-profile launch in the repo, because nobody does this by hand twice.
  • 2Explicit deny, then dismiss— two different states, and in the dismissed case the browser may ask again later while your code has already given up. Both need a screen.
  • 3Permission reset mid-session— a user revoking in site settings while your socket is open. This is where long-lived connections and cached capability flags go wrong.
  • 4The embedded case— your integration inside a cross-origin iframe, with and without the allow attribute and the matching header, since the default allowlist is ‘self’ and the failure there precedes any prompt.
  • 5HTTP-only hardware and odd DNS— a device that cannot hold a certificate, a .local name, a hostname that resolves publicly on a guest network. The last one is the targetAddressSpace TypeError, and it reads like an outage.
  • 6A managed browser, both ways— with the allowlist policy applied and without it. If QA only ever runs on the managed image, you have tested the one configuration your customers’ own laptops do not have.

If you only change one thing

The browser has made an architectural decision on your behalf: reaching into the user's network is now a privilege that the user grants and can take back, in the same category as the camera or the microphone. Integrations built when it was ambient connectivity are now built on a permission, and most of them do not know it.

Five changes that took this out of our support queue

  • Classify the failure before you report it. Query the Permissions API first so "blocked by the user", "helper not installed", "helper erroring" and "hostname resolved publicly" become four different messages instead of one failed fetch.
  • Move the prompt onto a user action that mentions the hardware. You control when the first qualifying request fires, and a prompt attached to a Connect button is answered far better than one fired at page load.
  • Give every device feature a designed degraded mode. A queue on your backend, a download, a hand-off to a phone — anything that is not a dead button. Decide what it is before the first denial, not during an incident.
  • Stop treating loopback as inside the application. Since Chrome 147 a WebSocket to 127.0.0.1 is gated like any other local request, and no installer you ship can grant that permission on the web origin's behalf.
  • Invert the direction for anything new. A device or agent that dials out to your backend never crosses an address-space boundary, so it cannot be broken by a browser release or a dismissed modal — at the cost of a relay you now operate.

We have not retired the other patterns, and you should not either. Managed fleets with policy control still make the direct call perfectly reasonable; cabled hardware is better served by a chooser than by a network request; and plenty of installed agents will outlive all of us. What changed is the default assumption. A device integration is no longer a networking problem with a permission attached. It is a permission problem that happens to involve a network, and the quality of the integration is now decided almost entirely by what it does on the day somebody clicks Block.

Keep Reading

More from the blog

Track Record

The Engineering Partner You Can Build On

Reliable software takes an experienced team that owns delivery end to end. Here’s the track record behind ours.

Book a Free Consultation
01

11+

Years Building Custom Software

02

320+

Projects Delivered Across Web, Mobile & AI

03

85%

Repeat Client Rate

04

12+

Countries Served Worldwide

Trusted by startups and enterprises worldwide

Work With Us

Let’s create
with purpose

Share your goals, timeline, and challenges — we’ll respond with clarity and next steps.

Ambitious ideas deserve thoughtful execution. Start the conversation and let’s define what success looks like.

Team

Acetrum

Est. 2015

4.9/5

Trusted by
top brands

Services interested in:

By submitting, I confirm I’ve read and agree with Privacy and Cookie Policies.

Newsletter

Signals worth
paying attention

No recycled headlines — just the patterns we’re seeing across real client work, distilled into one read a month.

A curated digest of practical thinking and real-world brand perspectives monthly.

No spam. Unsubscribe anytime.