Skip to content

Isolated Web Apps

Isolated Web Apps (IWAs) are Chromium's model for web applications that need more trust than a website can offer. Instead of being served live over HTTPS, an IWA is packaged into a Signed Web Bundle, identified by the public key that signed it (an isolated-app:// origin rather than a domain name), locked down by a strict Content Security Policy and cross-origin isolation, and installed rather than visited. In exchange, it can use APIs that Chromium won't expose to the open web, such as raw TCP and UDP sockets (Direct Sockets) and a <controlledframe> that embeds and scripts any site. This page covers the threat model, the bundle and signature format, the manifest and security requirements, the IWA-only APIs, the build tooling, how enterprises distribute and update IWAs, and their availability as of September 2026.

Experimental

As of September 2026, Isolated Web Apps run only on ChromeOS. Google's Admin Help lists them as installed by administrators through policy (ChromeOS 128 and later), and since Chrome 143 policy installs are limited to apps on Google's allowlist. Since ChromeOS 150, users can also install IWAs themselves where the administrator allows it. Anyone can test them locally in developer mode. Windows support in enterprise-managed Chrome has been announced but moved: an earlier edition of Google's enterprise release notes targeted Chrome 150, and the current "upcoming changes" list names Chrome 161 on Windows. Check the current enterprise release notes before planning a Windows deployment.

Key takeaways

  • IWAs trade reach for trust. They're signed, versioned, offline packages whose code can't change without a new signature, which is what justifies giving them powerful APIs.
  • Identity is a key, not a domain. The origin is isolated-app://<web-bundle-id>/, where the ID encodes an Ed25519 or ECDSA P-256 public key. Losing the key means losing the app's identity unless Google rotates it for an allowlisted app.
  • The security policy is fixed. Scripts only from the bundle (plus WebAssembly), Trusted Types required, and COOP, COEP and CORP applied so the app is cross-origin isolated. Server-rendered pages don't work.
  • IWA-only APIs: Direct Sockets (Chrome 131), Controlled Frame, Unrestricted WebUSB and the Web Smart Card API, each gated by a permissions_policy declaration in the manifest.
  • Distribution is managed. Administrators force-install by Web Bundle ID and update manifest URL, and can pin a version, allow rollbacks or pick a release channel. Chrome polls the update manifest every 4 to 6 hours. ChromeOS 150 added user installs that admins can allow. Developer mode (chrome://flags/#enable-isolated-web-app-dev-mode) is for testing only.
  • Build with standard tools. wbn and wbn-sign from the WICG webpackage repository, or the Rollup/Vite and webpack plugins, produce the .swbn file.

Why Isolated Web Apps exist

A normal web app is only as trustworthy as the server that delivers it. Every page load fetches whatever code the server returns right now. If an attacker compromises the server, the CDN, a build pipeline or a DNS record, every user runs the attacker's code with every permission the site has been granted. For most APIs, the web's permission model accepts that risk because the damage is bounded: a camera permission prompt, a file picker, a same-origin sandbox.

Some capabilities can't be bounded that way. A raw TCP socket can talk to any device on the user's network, including printers, routers and internal servers that assume the local network is trusted. An embedding API that ignores X-Frame-Options can read content from other sites. Chromium's explainer for IWAs calls this the gap between the "drive-by web" and the trust native apps get. Native apps close the gap with signing and review: the code the user installed is the code that runs. IWAs bring that to the web platform with three properties that ChromeOS.dev summarizes as packaged, isolated and locked down:

  • Packaged. Every resource is in a Signed Web Bundle, verified by its signature rather than by TLS. The code can be audited, it works offline, and it can't change without a new, signed version.
  • Isolated. The app has its own scheme, origin and storage, separate from any website and from other IWAs. The same-origin policy keeps it apart from the browser's normal browsing.
  • Locked down. A strict CSP forbids code from outside the bundle, and all permissions are denied by default unless the manifest declares them.

The Chromium project's policy for IWA-only APIs is explicit that exclusivity is a last resort. APIs should come to the whole web where they safely can; an API is made IWA-only when it would violate the same-origin policy or present a threat model users can't reasonably evaluate, and the "packaged, auditable" nature of IWAs mitigates it. Each such API still needs a specification and tests, and ships through the normal Blink launch process with IWA owner approval.

IWAs also serve as a migration path for Chrome Apps, which had privileged APIs (sockets, <webview>) that the web never got. Direct Sockets and Controlled Frame are, in effect, the web-standards-track successors of those Chrome App features.

Status and timeline

Date or version Milestone
ChromeOS 128 IWAs installable by administrators through policy for managed users and browsers on ChromeOS
Chrome 127 Unrestricted WebUSB for IWAs on desktop, per its intent to ship
Chrome 131 Direct Sockets shipped for IWAs ("desktop only", with no origin trial)
2025 Controlled Frame shipped for IWAs; its ChromeStatus entry recorded milestone 135 in February 2025, while the intent-to-ship review continued on blink-dev
ChromeOS 134 IWAs supported in kiosk mode
ChromeOS 140 IWAs supported in managed guest sessions
Chrome 143 The IWA allowlist starts applying on ChromeOS; the Web Smart Card API's intent to ship targeted desktop 143, ChromeOS first
Chrome 146 Unframed display mode enters developer trial behind chrome://flags/#enable-unframed-iwa
Chrome 150 / ChromeOS 150 Users can install IWAs themselves where the admin setting allows it; unmanaged installs choose an update channel at install time
ChromeOS 151 Admins can set web capability defaults for IWAs in managed guest sessions
Chrome 152 Unframed display mode ships on ChromeOS (gradual rollout); sub apps for IWAs; Window Shape API for allowlisted IWAs on ChromeOS
Announced Enterprise-managed Chrome on Windows: first targeted for Chrome 150, now listed for Chrome 161

Support data as of September 2026. For current state, see Chrome Platform Status and the IWA documentation on developer.chrome.com. The Admin Help article on installing IWAs still states "IWAs are supported only on ChromeOS". The developer introduction (last updated February 2026) says the "initial release" is limited to "Chrome Enterprise administered ChromeOS devices and select development partners", with expansion to unmanaged and cross-platform devices planned. The ChromeOS 150 user-install option and the Chrome 150 channel selection for unmanaged IWAs are the first steps in that direction. No other browser engine has announced plans: the Web Smart Card intent notes that "no other browser engine has yet displayed interest in implementing Isolated Web Apps".

Anatomy of an Isolated Web App

flowchart LR
    K["Developer's signing key (Ed25519 or ECDSA P-256)"] --> ID["Web Bundle ID (base32 of public key + type suffix)"]
    SRC["Built app: HTML, JS, CSS, Wasm, /.well-known/manifest.webmanifest"] --> WBN["Web Bundle (.wbn)"]
    WBN --> SIGN["Integrity block + signatures"]
    K --> SIGN
    SIGN --> SWBN["Signed Web Bundle (.swbn)"]
    ID --> ORIGIN["isolated-app://ID/"]
    SWBN --> UM["Update manifest (JSON on HTTPS)"]
    UM --> CHROME["Chrome installs, verifies, serves from isolated-app://ID/"]

The isolated-app: scheme and the Web Bundle ID

An IWA's origin is isolated-app://<web-bundle-id>/. The WICG scheme explainer defines the host:

  • Take the identifier (for a signed bundle, the public key) and append a type suffix. The suffix's last byte says how many bytes of type information precede it. 0x00 0x01 0x02 marks an Ed25519 public key; 0x00 0x00 0x02 marks a user-agent-specific development identifier used for unsigned or proxied development installs.
  • Base32-encode the concatenation, drop padding, and lowercase the result.

An Ed25519 key is 32 bytes, so the ID is 35 bytes, or 56 base32 characters. ECDSA P-256 keys (33 bytes compressed) produce 58 characters, which matches Google's Admin Help description of the ID as "a base32 [a-z2-7] string of 56 or 58 characters". An example ID from ChromeOS.dev is ggx2sheak3vpmm7vmjqnjwuzx3xwot3vdayrlgnvbkq2mp5lg4daaaic.

Consequences of this design:

  • The origin can't be spoofed or hijacked through DNS. Only the holder of the private key can produce a bundle that installs at that origin.
  • isolated-app: URLs are secure contexts. Service workers and every secure-context API work. The URLs have no username, password or port.
  • Different keys mean different apps. ChromeOS.dev warns that each IWA needs its own key pair, and that reusing a key across apps makes them one logical application.
  • Key loss is identity loss. Since the v2 integrity block, the Web Bundle ID is a separate attribute in the bundle rather than derived from the first signature, which is what makes key rotation possible (see Key rotation).

Signed Web Bundles

A Web Bundle (.wbn, application/webbundle) is a CBOR file containing HTTP responses indexed by URL. An IWA's bundle holds every file the app serves, with response headers. Signing prepends an integrity block. From the WICG integrity block explainer:

integrity block (CBOR)
integrity-block = [
  magic: h'F0 9F 96 8B F0 9F 93 A6',   ; "🖋📦" in UTF-8
  version: bstr .size 4,               ; 32 00 00 00 for the current format
  attributes: { "webBundleId": tstr },
  signature-list: [ +integrity-signature ],
]
  • Each signature entry has an attributes map with the public key (ed25519PublicKey, 32 bytes, or ecdsaP256SHA256PublicKey, 33 bytes) and the signature itself (64 bytes for Ed25519).
  • The signed payload is built from a SHA-512 hash of the Web Bundle plus the serialized integrity block and signature attributes, each prefixed with its length as a 64-bit integer. Changing any byte of any resource invalidates every signature.
  • Verification checks the magic and version, requires at least one valid signature, and checks that the webBundleId matches the expected ID for the app.
  • Multiple signatures are allowed. wbn-sign has add-signature, remove-signature and replace-signature commands for exactly that.

The manifest

The Web App Manifest must be inside the bundle at /.well-known/manifest.webmanifest. On top of the usual members, IWAs need or use:

Member Required Purpose
version Yes A string of dot-separated numbers matching ^(\d+.?)*\d$, such as "1", "0.1.0" or "5.3.0". Chrome's version-management guide allows at most four components. Must match the version in the update manifest.
update_manifest_url Recommended HTTPS URL (or localhost for testing) of the update manifest. ChromeOS.dev calls it optional but recommended; user-installed IWAs need it to receive background updates, and policy installs take the URL from the policy entry.
permissions_policy For any gated API The Permissions Policy features the app may use, as an allowlist per feature. Undeclared features are blocked.
display No browser and minimal-ui are forced to minimal-ui; fullscreen and standalone are forced to standalone
start_url, protocol_handlers, share_target, launch_handler No IWAs restrict entry points to these, so arbitrary deep links into the app are disabled to prevent sequence-breaking attacks
public/.well-known/manifest.webmanifest
{
  "name": "Fleet Console",
  "short_name": "Fleet",
  "version": "2.4.0",
  "update_manifest_url": "https://updates.example.com/fleet/update.json",
  "start_url": "/",
  "display": "standalone",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ],
  "permissions_policy": {
    "cross-origin-isolated": ["self"],
    "direct-sockets": ["self"],
    "local-network": ["self"],
    "controlled-frame": ["self"],
    "usb-unrestricted": ["self"]
  }
}

local-network is needed here because the app's Direct Sockets code talks to printers on the local network (see Direct Sockets below for the naming history of that feature).

Permission requests are denied by default: ChromeOS.dev says "Isolated Web Apps block all permission requests by default". The manifest declaration makes a feature possible; the user or the administrator still grants it. Administrators can pre-grant capabilities for IWAs in the Admin console under Devices > Chrome > Web capabilities.

The security policy: CSP, isolation and Trusted Types

Chromium applies a fixed Content Security Policy to every IWA response:

IWA Content-Security-Policy
base-uri 'none'; default-src 'self'; object-src 'none';
frame-src 'self' https: blob: data:; connect-src 'self' https: wss: blob: data:;
script-src 'self' 'wasm-unsafe-eval'; img-src 'self' https: blob: data:;
media-src 'self' https: blob: data:; font-src 'self' blob: data:;
style-src 'self' 'unsafe-inline'; require-trusted-types-for 'script';

and these headers:

IWA cross-origin headers
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin

What that means for your code:

  • All JavaScript comes from the bundle. No CDN scripts, no remote module imports, no eval() or new Function(). WebAssembly compilation is allowed ('wasm-unsafe-eval').
  • Network access stays open. Cross-origin images, media, iframes, fetch() over HTTPS and WebSockets (wss:) are allowed. Your app can call your APIs; it just can't load code from them.
  • Trusted Types are mandatory. Every assignment to an injection sink (innerHTML, outerHTML, insertAdjacentHTML, script.src and so on) needs a TrustedHTML or TrustedScriptURL value, or it throws. Frameworks that render through DOM APIs are usually fine; libraries that build HTML strings need a policy.
  • The app is cross-origin isolated. crossOriginIsolated is true, so SharedArrayBuffer and high-resolution timers are available. Embedded cross-origin resources must be CORS-enabled or send Cross-Origin-Resource-Policy: cross-origin.
  • Server rendering is impossible. There's no server at the IWA origin. Build a static or client-rendered app.

A minimal Trusted Types setup for code that must render HTML strings:

src/trusted-html.js
// One named policy that sanitizes HTML before it reaches innerHTML.
// Requires a sanitizer bundled into the app (remote scripts are blocked by CSP).
import DOMPurify from "dompurify";

const policy = window.trustedTypes.createPolicy("app-html", {
  createHTML: (input) => DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: false }),
});

export function setHTML(element, untrustedHTML) {
  // Assigning a plain string here would throw a TypeError under require-trusted-types-for.
  element.innerHTML = policy.createHTML(untrustedHTML);
}

Content Security Policy covers Trusted Types and CSP in general. The IWA developer policy adds behavioral rules on top of the technical ones: no interpreters for remote code, no service worker that loads external code into the IWA origin, no broad network scanning, no injecting scripts into embedded content to capture credentials, no surreptitious screen recording, and no focus stealing.

Capabilities only IWAs get

API What it does permissions_policy feature Status (September 2026)
Direct Sockets TCPSocket, UDPSocket, TCPServerSocket for raw TCP and UDP, including listening direct-sockets, plus local-network / loopback-network for private and loopback addresses (formerly direct-sockets-private) and direct-sockets-multicast for multicast Shipped, Chrome 131 desktop
Controlled Frame <controlledframe> embeds any content, even pages that forbid framing, with script injection, CSS injection and request interception controlled-frame Shipped for IWAs (see timeline)
Unrestricted WebUSB WebUSB without the blocklist and protected interface classes usb-unrestricted Shipped, Chrome 127 desktop
Web Smart Card API PC/SC access to smart card readers smart-card Intent to ship for desktop 143, ChromeOS first
Unframed display mode A window with no frame; the app draws everything. Opt in with "display_override": ["unframed"] Window management permission (admins can pre-grant it with WindowManagementAllowedForUrls) Shipped on ChromeOS in Chrome 152, rolling out gradually; ChromeOS only

The gated APIs also need cross-origin-isolated in permissions_policy. The developer policy restricts how they may be used: Direct Sockets for custom protocols and local hardware, not network probing; Controlled Frame for streamlining UI and mediating communication, not harvesting credentials; Unrestricted WebUSB only where no dedicated API (Smart Card, File System Access) exists.

Direct Sockets

src/printer.js
// Sends raw bytes to a network printer on TCP port 9100 (common for raw printing).
// Requires "direct-sockets" and "cross-origin-isolated" in permissions_policy, plus
// "local-network" because printers live on private addresses.

export async function printRaw(host, bytes) {
  const socket = new TCPSocket(host, 9100, {
    noDelay: true,          // disable Nagle's algorithm for small writes
    keepAliveDelay: 10_000, // setting it enables TCP keep-alive; must be >= 1000 ms
  });

  let writer;
  try {
    // opened resolves with the streams once the connection is established,
    // and rejects (for example NetworkError) if it can't be.
    const { writable, remoteAddress, remotePort } = await socket.opened;
    console.info(`Connected to ${remoteAddress}:${remotePort}`);

    writer = writable.getWriter();
    await writer.write(bytes); // BufferSource: Uint8Array, ArrayBuffer, DataView
    await writer.close();
  } catch (err) {
    // NotAllowedError: policy not declared or permission denied.
    console.error("Printing failed:", err.name, err.message);
    throw err;
  } finally {
    writer?.releaseLock();
    try {
      await socket.close(); // rejects if a stream is still locked or the socket already failed
    } catch {
      /* nothing left to clean up */
    }
  }
}

Reads yield Uint8Array chunks from readable, and BYOB readers (readable.getReader({ mode: "byob" })) are supported for zero-copy parsing. UDPSocket has a connected mode (remoteAddress, remotePort) and a bound mode (localAddress, localPort) in which every write is a { data, remoteAddress, remotePort } message; multicast options include multicastTimeToLive, multicastLoopback and multicastAllowAddressSharing. Constructors throw NotAllowedError when direct-sockets isn't allowed. Chrome DevTools shows Direct Sockets traffic in the Network panel from Chrome 138, with a hex viewer for binary payloads.

TCPSocketOptions has exactly five members in the spec and in Chromium's IDL: sendBufferSize, receiveBufferSize, noDelay (default false), keepAliveDelay and dnsQueryType ("ipv4" or "ipv6"). There's no separate keepAlive boolean: passing keepAliveDelay turns on SO_KEEPALIVE, and Chromium rejects values under 1,000 ms. close() rejects with InvalidStateError if the socket never opened, and it fails while either stream is still locked, which is why the example releases the writer first.

Private and loopback addresses need an extra policy. Connecting to an address in the private (local network) or loopback address space goes through Local Network Access. The current spec and Chromium's source gate these with the local-network and loopback-network Permissions Policy features, and Chrome requests the matching permission before it opens the socket. Earlier Chrome releases and Chrome's Direct Sockets guide (last updated December 2025) use the older direct-sockets-private feature name, which the guide also lists as a requirement for multicast. Unknown feature names in permissions_policy are ignored, so an app that must run on several Chrome versions can declare both spellings and test on each version it supports.

Controlled Frame

index.html
<!-- Embeds a third-party admin console that sends X-Frame-Options: DENY.
     partition="persist:admin" keeps its cookies across restarts; a name without
     "persist:" gives an in-memory partition that is cleared when the app closes. -->
<controlledframe id="console"
  src="https://admin.example.net/"
  partition="persist:admin"></controlledframe>
src/console-frame.js
const frame = document.getElementById("console");

frame.addEventListener("loadstop", async () => {
  // Hide the embedded site's own navigation; this app provides it.
  await frame.insertCSS({ code: "header.site-nav { display: none !important; }" });
});

frame.addEventListener("newwindow", (event) => {
  // Keep popups from escaping into untrusted windows; decide per URL.
  console.info("Popup requested:", event.targetUrl);
});

document.getElementById("back").addEventListener("click", () => frame.back());
document.getElementById("reload").addEventListener("click", () => frame.reload());

The element's API is modeled on the Chrome Apps <webview>: navigation methods (back(), forward(), reload()), executeScript(), insertCSS(), clearData(), createWebRequestInterceptor() for request interception, context menu integration, and events such as loadstart, loadcommit, contentload, loadstop, loadabort, loadredirect, consolemessage, dialog, permissionrequest, newwindow, sizechanged and zoomchange. Embedded content runs in the partition the app chooses and never shares storage with the same site in a browser tab.

Building and signing an IWA

Generate a signing key

keys.sh
# Ed25519 (recommended by the wbn-sign documentation), then encrypt it with a passphrase.
openssl genpkey -algorithm Ed25519 -out private_key.pem
openssl pkcs8 -in private_key.pem -topk8 -out encrypted_key.pem
rm private_key.pem

# Print the Web Bundle ID and origin for the key (wbn-dump-id ships with wbn-sign).
npx wbn-dump-id -k encrypted_key.pem -s

Store the encrypted key and passphrase in your secrets manager. For CI, wbn-sign reads the passphrase from the WEB_BUNDLE_SIGNING_PASSPHRASE environment variable instead of prompting. Custom signing strategies (an ISigningStrategy with sign() and getPublicKey()) let you keep the key in a hardware security module or cloud KMS.

Bundle and sign from the command line

package.sh
#!/usr/bin/env bash
set -euo pipefail
npm run build                                      # static output in dist/

# Every resource URL in the bundle lives under the app's isolated-app:// origin.
# Reads WEB_BUNDLE_SIGNING_PASSPHRASE if set, otherwise prompts.
BASE_URL="$(npx wbn-dump-id -k encrypted_key.pem -s)"

npx wbn --dir dist --baseURL "$BASE_URL" --output fleet.wbn
npx wbn-sign sign fleet.wbn encrypted_key.pem -o fleet-2.4.0.swbn
npx wbn-sign info fleet-2.4.0.swbn                 # shows the integrity block and validates signatures

wbn comes from the wbn npm package and wbn-sign / wbn-dump-id from wbn-sign. The wbn CLI covers only a subset of the Go gen-bundle tool; for per-response header overrides, use the Rollup/Vite plugin below. To rotate or add keys later, wbn-sign add-signature signed.swbn new_key.pem --in-place appends a signature without rebuilding.

Bundle and sign from Vite

rollup-plugin-webbundle works as a Vite build plugin. With integrityBlockSign.isIwa left at its default, it enforces IWA checks and adds the default IWA headers to responses that have none:

vite.config.js
import { defineConfig } from "vite";
import webbundle from "rollup-plugin-webbundle";
import * as wbnSign from "wbn-sign";

export default defineConfig(async ({ command }) => {
  const plugins = [];

  if (command === "build") {
    // The encrypted PEM comes from CI secrets; the passphrase from
    // WEB_BUNDLE_SIGNING_PASSPHRASE or an interactive prompt.
    const key = wbnSign.parsePemKey(
      process.env.IWA_SIGNING_KEY,
      await wbnSign.readPassphrase(),
    );

    plugins.push({
      ...webbundle({
        // Every URL in the bundle is prefixed with the app's isolated-app:// origin.
        baseURL: new wbnSign.WebBundleId(key).serializeWithIsolatedWebAppOrigin(),
        static: { dir: "public" }, // includes public/.well-known/manifest.webmanifest
        output: "app.swbn",
        integrityBlockSign: {
          strategy: new wbnSign.NodeCryptoSigningStrategy(key),
        },
      }),
      enforce: "post", // run after Vite has produced the final assets
    });
  }

  return {
    plugins,
    build: { target: "esnext" }, // IWAs run on recent Chromium only
  };
});

The same package family includes a webpack plugin, and the WICG webpackage repository also has Go tools for bundling and signing.

The update manifest

Chrome discovers new versions through a JSON file at your update_manifest_url:

update.json
{
  "versions": [
    { "version": "2.4.0", "src": "https://updates.example.com/fleet/2.4.0/fleet.swbn", "channels": ["default"] },
    { "version": "2.5.0", "src": "2.5.0/fleet.swbn", "channels": ["beta"] }
  ],
  "channels": {
    "default": { "name": "Stable" },
    "beta": { "name": "Beta" }
  }
}

Each entry needs a version (matching the bundle's manifest) and a src (absolute, or relative to the update manifest). channels is optional: Chrome's version-management guide says an entry without it belongs only to the default channel. Channel names are lowercase ASCII alphanumerics, hyphens and underscores. The default channel is what installations follow unless configured otherwise; the optional top-level channels object gives channels human-readable names, which Chrome shows when a user picks a channel. Chrome checks the update manifest every 4 to 6 hours. Version strings contain only numbers and dots (at most four components): there are no pre-release suffixes, and channels take their place. In the example, installations on the beta channel get 2.5.0 while default stays on 2.4.0. Host the file and bundles on any HTTPS server; they're verified by signature, not by the server's certificate.

Version pinning, rollback and channels

The IsolatedWebAppInstallForceList policy entry can do more than name the app. Each item requires update_manifest_url and web_bundle_id, and Chromium's policy definition and Chrome's version-management guide (updated July 2026) document three optional fields:

IsolatedWebAppInstallForceList policy value
[
  {
    "update_manifest_url": "https://updates.example.com/fleet/update.json",
    "web_bundle_id": "ggx2sheak3vpmm7vmjqnjwuzx3xwot3vdayrlgnvbkq2mp5lg4daaaic",
    "update_channel": "beta",
    "pinned_version": "2.4.0",
    "allow_downgrades": true
  }
]
Policy field Effect
update_channel Chrome only considers versions assigned to that channel in the update manifest. Defaults to "default"
pinned_version Installs that version if the channel offers it, then stops background updates. If the version doesn't exist, the app stays stuck on the installed version. Remove the field to unpin
allow_downgrades Lets Chrome install an older version. Chromium's policy text says it needs pinned_version to name the target; Chrome's guide adds that while it's true, forward updates are blocked even without pinned_version

The policy definition lists ChromeOS 128 and later as supported and other desktop Chrome platforms as "future".

A downgrade is a full reinstall from the older bundle, and it wipes the app's storage partitions: IndexedDB, localStorage, Cache Storage and cookies. Keep server-side state authoritative, or give users an export, before you ask administrators to roll back. For user-installed IWAs (Chrome 150 and later), the user chooses a channel from the update manifest's channels names at install time, and later updates come only from that channel.

Distributing an IWA

Administrator installation

In the Google Admin console, administrators go to Devices > Chrome > Apps & extensions > Users & browsers, choose Add an Isolated Web App, and enter the Web Bundle ID and the update manifest URL. Installation options are force install, force install and pin, and force install and pin to the ChromeOS taskbar; users can't remove force-installed IWAs. The underlying policy is IsolatedWebAppInstallForceList. Per Admin Help, IWAs are supported for users and browsers on ChromeOS 128 and later, kiosks on ChromeOS 134 and later, and managed guest sessions on ChromeOS 140 and later. Web capability defaults for IWAs (for example window management or local fonts) are set under Devices > Chrome > Web capabilities.

User installation on ChromeOS

Google's ChromeOS 150 enterprise release notes add user-initiated installation: users can install an IWA themselves, and administrators control whether that's allowed under Devices > Chrome > Apps & extensions > User app settings > Isolated Web App Installation. The app still arrives as a signed bundle with its update manifest, and the installed copy updates in the background from the channel the user chose. Whether a user-installed app must also be on Google's allowlist isn't stated in the public documentation, so test with your own app before promising this path to customers.

The allowlist

From Chrome 143 on ChromeOS, Chrome enforces an IWA allowlist. Google's allowlist documentation gives three reasons: stability while the platform is young, a trusted channel to developers for processes such as key rotation, and ensuring developers accept the usage policies. Its effect:

App state Behavior
On the allowlist Installs, updates and runs
Installed but not on the allowlist Stays installed and launchable, receives no updates
Not installed and not on the allowlist Can't be installed through the Admin console; developer mode still works

Developers request allowlisting through their Google partner contact; Google says it reviews requests within two business weeks. The core criterion is that the use case "must be unachievable through existing open web solutions or APIs". The allowlist will apply to other operating systems when they gain IWA support.

Key rotation

If a signing key leaks or is lost, an allowlisted developer can ask Google to rotate it, from the email address registered during allowlisting (Google responds within about one business week). After rotation, versions without a valid signature from an accepted key are blocked, and you deliver new versions signed with the new key. If you still hold the old key, sign the transition version with both keys; the v2 integrity block's separate webBundleId attribute is what lets the app keep its ID and origin across the change.

Developing and testing

  1. Enable chrome://flags/#enable-isolated-web-app-dev-mode (Chrome or ChromeOS 120 or later) and restart.
  2. Open chrome://web-app-internals. Its IWA section offers two install methods:
    • Dev Mode Proxy: enter a dev server URL such as http://localhost:4321. Chrome creates a random isolated-app:// origin and proxies requests to your server, so you get hot reload without bundling. The app still has to meet the IWA requirements (CSP, headers, manifest).
    • Signed Web Bundle: select a .swbn file to test the real package, including its signature and update manifest.
  3. Launch the app from the launcher and debug it with DevTools as usual.

For scripted setups, Chrome accepts --install-isolated-web-app-from-url=<dev server URL> or --install-isolated-web-app-from-file=<path to .swbn> together with --enable-features=IsolatedWebApps,IsolatedWebAppDevMode. The switches only work when the browser first starts, and should be removed after the app is installed. Google's telnet client sample uses exactly this setup.

Your dev server must send the same headers Chrome enforces in production, or code that works in development fails when bundled:

vite.config.js (dev server excerpt)
export const iwaHeaders = {
  "Content-Security-Policy":
    "base-uri 'none'; default-src 'self'; object-src 'none'; frame-src 'self' https: blob: data:; " +
    "connect-src 'self' https: wss: blob: data:; script-src 'self' 'wasm-unsafe-eval'; " +
    "img-src 'self' https: blob: data:; media-src 'self' https: blob: data:; " +
    "font-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; require-trusted-types-for 'script';",
  "Cross-Origin-Opener-Policy": "same-origin",
  "Cross-Origin-Embedder-Policy": "require-corp",
  "Cross-Origin-Resource-Policy": "same-origin",
};

// In defineConfig: server: { headers: iwaHeaders }

Some development tooling injects inline scripts (for example framework hot-reload preambles) or loads code from other origins; if the strict policy blocks it, test the proxy install against vite preview (the production build) and use a plain browser tab for day-to-day UI work.

IWAs compared with PWAs

Progressive Web App Isolated Web App
Delivery Live from your HTTPS server Signed Web Bundle, installed
Identity Origin (domain) + manifest id isolated-app:// + public key
Code integrity TLS; whatever the server serves Signature over every byte; offline-verifiable
Updates Every deploy, via service worker New signed version via update manifest
Content Security Policy Yours to choose Fixed strict policy, Trusted Types required
Cross-origin isolation Optional Always
Server-side rendering Yes No
Storage Shared with the site in the browser Own isolated storage
Installation Anyone, from the browser Administrator policy, admin-allowed user install (ChromeOS 150+), or developer mode
Engines All (with varying features) Chromium only
Special APIs Web platform APIs Plus Direct Sockets, Controlled Frame, Unrestricted WebUSB, Smart Card

Choose a PWA unless you need one of the IWA-only APIs and your users are on managed Chromium devices. Google's own guidance says the default recommendation remains a Progressive Web App. Where you need native capabilities for unmanaged users, compare Electron and Tauri.

Common pitfalls

  • Loading anything executable from a CDN. Analytics snippets, font loaders that inject scripts, and remote module imports are all blocked by CSP. Bundle them.
  • Forgetting Trusted Types. Code that works in a browser tab throws TypeError on its first innerHTML assignment in the IWA.
  • Losing the private key. Without allowlist-based rotation, a lost key strands every installed copy. Back it up like a code-signing certificate.
  • Mismatched versions. The version in the bundled manifest must match the entry in the update manifest, or updates don't install.
  • Assuming availability on unmanaged devices or other operating systems. Outside developer mode, IWAs install through enterprise policy (allowlisted apps only) or, from ChromeOS 150, through user installs the admin allows. Windows, macOS and Linux have no production IWA support yet.
  • Omitting cross-origin-isolated from permissions_policy. The gated APIs require it in addition to their own feature.
  • Designing only for unframed windows. The unframed display mode shipped in Chrome 152 on ChromeOS only, rolls out gradually and needs the window management permission. List a fallback after it in display_override and make the app usable in a normal standalone window.
  • Relying on a downgrade to fix a bad release. Rollbacks wipe the app's local storage. Ship a fixed forward version when you can.

Further reading

On this site

External references