How to check web manifest

Fetch the file the page's link rel="manifest" points at, parse it, and read name, short_name, start_url against scope, display and the icons. Fetch every icon and measure it, because sizes is a claim. Then compare your reading with the one Chrome made.

Why check this

Run this on staging sign-off for anything installable, and after any change to routing, to the icon pipeline, or to the login wall in front of the application. The manifest is read at install time, so a defect in it ships quietly and returns as a ticket about the icon or about the app opening on the wrong screen.

Two defects survive every syntax check. An icon whose sizes value disagrees with the image behind it parses as valid JSON, and a start_url outside scope parses as valid JSON as well. Both change what the installed application does, and neither is visible in the file.

The third failure is not in the manifest at all. The request for it carries no cookies, so an application that answers unauthenticated requests with a login page returns HTML here, and the parse fails with a message about a line and a column.

Prerequisites

// manifest-demo.mjs: a page whose manifest parses and is still wrong, and a
// second manifest that sits behind a login. Logs the Cookie header it received.
import { createServer } from 'node:http';
import { deflateSync } from 'node:zlib';

// a real PNG of the given size, so the bytes on the wire have true dimensions
const png = (w, h, rgb) => {
  const crcTable = [...Array(256)].map((_, n) => {
    let c = n;
    for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
    return c >>> 0;
  });
  const crc = (b) => {
    let c = 0xffffffff;
    for (const byte of b) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
    return (c ^ 0xffffffff) >>> 0;
  };
  const chunk = (type, data) => {
    const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
    const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
    const c = Buffer.alloc(4); c.writeUInt32BE(crc(body));
    return Buffer.concat([len, body, c]);
  };
  const ihdr = Buffer.alloc(13);
  ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
  ihdr[8] = 8; ihdr[9] = 2; // 8-bit, truecolour
  const raw = Buffer.concat([...Array(h)].map(() =>
    Buffer.concat([Buffer.from([0]), Buffer.concat([...Array(w)].map(() => Buffer.from(rgb)))])));
  return Buffer.concat([
    Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
    chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0)),
  ]);
};

const manifest = JSON.stringify({
  name: 'Orders Console',
  short_name: 'Orders',
  start_url: '/dashboard/',
  scope: '/app/',
  display: 'standalone',
  theme_color: '#0f766e',
  background_color: '#ffffff',
  icons: [
    { src: '/icons/logo-192.png', sizes: '192x192', type: 'image/png' },
    { src: '/icons/logo-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
  ],
}, null, 2);

const fixed = JSON.stringify({
  name: 'Orders Console',
  short_name: 'Orders',
  start_url: '/app/dashboard/',
  scope: '/app/',
  display: 'standalone',
  theme_color: '#0f766e',
  background_color: '#ffffff',
  icons: [{ src: '/icons/logo-192.png', sizes: '192x192', type: 'image/png' }],
}, null, 2);

const html = (link) => `<!doctype html><meta charset="utf-8"><title>Orders Console</title>
${link}<h1>Orders Console</h1>`;

const login = `<!doctype html><meta charset="utf-8"><title>Sign in</title>
<h1>Sign in</h1><form method="post" action="/login"><input name="user"></form>`;

const routes = {
  '/': ['text/html; charset=utf-8', html('<link rel="manifest" href="/app.webmanifest">')],
  '/creds/': ['text/html; charset=utf-8',
    html('<link rel="manifest" href="/app.webmanifest" crossorigin="use-credentials">')],
  '/private/': ['text/html; charset=utf-8', html('<link rel="manifest" href="/private/app.webmanifest">')],
  '/app.webmanifest': ['application/manifest+json', manifest],
  '/fixed/': ['text/html; charset=utf-8', html('<link rel="manifest" href="/fixed.webmanifest">')],
  '/fixed.webmanifest': ['application/manifest+json', fixed],
  '/icons/logo-192.png': ['image/png', png(192, 192, [15, 118, 110])],
  '/icons/logo-512.png': ['image/png', png(192, 192, [180, 83, 9])], // declared 512, really 192
};

createServer((req, res) => {
  console.log(`${new Date().toISOString().slice(11, 19)} ${req.method} ${req.url} cookie=${req.headers.cookie ?? '-'}`);
  if (req.url === '/private/app.webmanifest') {           // behind a login, like the app
    if (!/session=/.test(req.headers.cookie ?? '')) {
      res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(login);
      return;
    }
    res.writeHead(200, { 'content-type': 'application/manifest+json' }).end(manifest);
    return;
  }
  const hit = routes[req.url];
  if (!hit) { res.writeHead(404).end('not found'); return; }
  if (hit[0].startsWith('text/html')) res.setHeader('set-cookie', 'session=abc123; Path=/');
  res.setHeader('content-type', hit[0]);
  res.end(hit[1]);
}).listen(8792, () => console.log('serving http://localhost:8792/'));
// manifest-audit.mjs <page-url>: reads the manifest the page declares and checks
// the fields that decide whether it is usable. Icon sizes come from the bytes.
const pageUrl = process.argv[2];
const html = await (await fetch(pageUrl)).text();
const link = /<link[^>]+rel=["']?manifest["']?[^>]*>/i.exec(html);
if (!link) { console.log('no <link rel="manifest"> in ' + pageUrl); process.exit(1); }
const href = /href=["']([^"']+)["']/i.exec(link[0])[1];
const url = new URL(href, pageUrl);
const cors = /crossorigin=["']?([^"'\s>]+)/i.exec(link[0])?.[1] ?? 'absent';
console.log(`manifest ${url.href}  crossorigin=${cors}`);

const res = await fetch(url);
console.log(`status ${res.status}  content-type ${res.headers.get('content-type')}`);
const body = await res.text();
let man;
try { man = JSON.parse(body); } catch (e) {
  console.log(`JSON.parse failed: ${e.message}`);
  console.log(`body starts: ${body.slice(0, 48).replace(/\n/g, ' ')}`);
  process.exit(1);
}

for (const f of ['name', 'short_name', 'start_url', 'scope', 'display', 'theme_color'])
  console.log(`${f}: ${man[f] === undefined ? 'MISSING' : JSON.stringify(man[f])}`);

const scope = new URL(man.scope ?? './', url);
const start = new URL(man.start_url ?? './', url);
console.log(`start_url resolves to ${start.href}`);
console.log(`scope resolves to     ${scope.href}`);
console.log(`start_url inside scope: ${start.href.startsWith(scope.href)}`);

for (const icon of man.icons ?? []) {
  const r = await fetch(new URL(icon.src, url));
  const b = Buffer.from(await r.arrayBuffer());
  const png = b.subarray(1, 4).toString() === 'PNG';
  const real = png ? `${b.readUInt32BE(16)}x${b.readUInt32BE(20)}` : `not PNG (${r.headers.get('content-type')})`;
  console.log(`icon ${icon.src}: sizes says ${icon.sizes}, bytes say ${real}, ${b.length} B, ${real === icon.sizes ? 'match' : 'MISMATCH'}`);
}

Steps

  1. Step 1.

    Start the demo site.

    node manifest-demo.mjs
    
    serving http://localhost:8792/
  2. Step 2.

    Read the manifest off the wire, headers included.

    curl -s -D - http://localhost:8792/app.webmanifest | head -14
    
    HTTP/1.1 200 OK
    content-type: application/manifest+json
    …
    Content-Length: 446
    
    {
    "name": "Orders Console",
    "short_name": "Orders",
    "start_url": "/dashboard/",
    "scope": "/app/",
    "display": "standalone",
    "theme_color": "#0f766e",

    application/manifest+json is the registered type for this file.

  3. Step 3.

    Audit the fields, and measure the icons instead of believing them.

    node manifest-audit.mjs http://localhost:8792/
    
    manifest http://localhost:8792/app.webmanifest  crossorigin=absent
    status 200  content-type application/manifest+json
    name: "Orders Console"
    short_name: "Orders"
    start_url: "/dashboard/"
    scope: "/app/"
    display: "standalone"
    theme_color: "#0f766e"
    start_url resolves to http://localhost:8792/dashboard/
    scope resolves to     http://localhost:8792/app/
    start_url inside scope: false
    icon /icons/logo-192.png: sizes says 192x192, bytes say 192x192, 546 B, match
    icon /icons/logo-512.png: sizes says 512x512, bytes say 192x192, 546 B, MISMATCH

    Two findings in a file that parsed without complaint: the start URL sits outside the scope, and the large icon is the small icon under another name.

  4. Step 4.

    Ask Chrome what it made of the same file, then of the corrected one.

    node manifest-run.mjs
    
    --- Page.getAppManifest on /
    {
    "url": "http://localhost:8792/app.webmanifest",
    "errors": [
      {
        "message": "property 'scope' ignored. Start url should be within scope of scope URL.",
        "critical": 0,
        "line": 0,
        "column": 0
      }
    ],
    "parsed": {
      "scope": "http://localhost:8792/dashboard/"
    },
    …
    }
    --- Page.getAppManifest on /fixed/
    {
    "url": "http://localhost:8792/fixed.webmanifest",
    "errors": [],
    "parsed": {
      "scope": "http://localhost:8792/app/"
    },
    …
    }

    Chrome resolved the conflict by dropping the declared scope and keeping start_url, so the installed scope became /dashboard/, a path nobody wrote down. The error is not critical, so the manifest is still used. The icon produced no error at all, because Chrome does not open the images while parsing.

  5. Step 5.

    Point the same audit at a page whose manifest sits behind the login.

    node manifest-audit.mjs http://localhost:8792/private/
    
    manifest http://localhost:8792/private/app.webmanifest  crossorigin=absent
    status 200  content-type text/html; charset=utf-8
    JSON.parse failed: Unexpected token '<', "<!doctype "... is not valid JSON
    body starts: <!doctype html><meta charset="utf-8"><title>Sign

    A 200 with a content type of text/html is the tell: the gateway answered with the sign-in page.

  6. Step 6.

    Read what Chrome reports for that same page.

    node manifest-run.mjs
    
    --- Page.getAppManifest on /private/
    {
    "url": "http://localhost:8792/private/app.webmanifest",
    "errors": [
      {
        "message": "Line: 1, column: 1, Syntax error.",
        "critical": 1,
        "line": 1,
        "column": 1
      }
    ],
    …
    "data": null
    }

    One critical error naming a line and a column, nothing about HTML or about the login, and data empty. Step 5 is what turns that message into a cause.

  7. Step 7.

    Read the server log to see which requests carried the session cookie.

    cat manifest-server.log
    
    21:05:57 GET /fixed/ cookie=session=abc123
    21:05:57 GET /fixed.webmanifest cookie=-
    21:05:57 GET /icons/logo-192.png cookie=session=abc123
    …
    21:05:59 GET /creds/ cookie=session=abc123
    21:05:59 GET /app.webmanifest cookie=session=abc123
    21:05:59 GET /icons/logo-192.png cookie=session=abc123

    The document and the icons carried the cookie, the manifest did not. On the page that declares crossorigin="use-credentials" the same manifest request carried it. That single attribute is the difference between a manifest a logged-in browser can read and one it cannot.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | errors: [] from Page.getAppManifest | Chrome parsed every member it understood | Still measure the icons, which Chrome never fetched | | property 'scope' ignored | The start URL is outside the declared scope | Fix one of the two, then confirm the parsed scope | | sizes says 512x512, bytes say 192x192 | The declaration is wrong, the file is fine | Regenerate the icon or correct sizes | | content-type: text/html on the manifest | The response is a login page or an SPA fallback | Serve the manifest unauthenticated, or add crossorigin="use-credentials" | | Line: 1, column: 1, Syntax error. | The body is not JSON at all | Fetch the URL yourself and read the first line | | short_name missing | The launcher falls back to name and truncates it | Add a short name of 12 characters or fewer |

Common mistakes

Sign: The manifest passes a validator and the installed app opens on the wrong screen.Cause: A start_url outside scope is valid JSON. Chrome resolves it by discarding the declared scope and keeping the start URL, so the app runs with a scope nobody wrote: in this capture /dashboard/ instead of /app/.
Sign: An icon is declared 512x512 and looks wrong wherever the system uses it.Cause: The sizes attribute is free text and no parser checks it against the image. Chrome reported no error for an icon declared 512x512 whose bytes are 192x192, because it does not fetch icons while parsing the manifest.
Sign: curl returns the manifest and the browser reports a syntax error.Cause: The manifest is requested without the page's cookies unless the link carries crossorigin with the value use-credentials. curl with your session sees JSON; the browser gets the login page and fails to parse HTML as JSON.
Sign: Chrome reports Line 1, column 1, Syntax error and the file is valid JSON.Cause: The message describes the body that arrived, not the file on disk. A redirect to a sign-in page, an SPA fallback route or a 404 page all produce HTML with a valid status code.

What to check next

FAQ

How to validate a web app manifest?

Parse it, resolve start_url and scope as URLs against the manifest location, and fetch every icon to compare its real size with sizes. A schema check misses both of the defects above, because both are valid JSON with the right value types.

How to inspect the web app manifest in devtools?

Open DevTools, Application panel, Manifest pane. It lists the parsed members, the icons it resolved and the error list. The capture in step 4 comes from Page.getAppManifest, the protocol method that pane calls.

Why does my manifest come back as HTML?

The browser requests it without cookies, so an authenticated route answers with the sign-in page. Serve the manifest to unauthenticated requests, or add crossorigin="use-credentials" to the link, which made the request carry the session cookie in step 7.

Does start_url have to be inside scope?

Yes, and Chrome enforces it by dropping the scope. The result is an application whose scope is the start URL's directory, so links outside it open in a browser tab instead of the installed window.

Verified

Verified by Maks Vernynode 22.23.2curl 8.21.0Chrome 152.0.7977.76

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate7 minpublished updated Maks Verny