diff --git a/cspell.yml b/cspell.yml
index 55febfc9..ddfc0a40 100644
--- a/cspell.yml
+++ b/cspell.yml
@@ -41,6 +41,7 @@ words:
- esbuild
- esque
- extglob
+ - favicons
- fieldtrials
- firefoxdeveloperedition
- Florian
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 6b2e9041..fb2818ca 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -224,6 +224,7 @@ export default defineConfig({
menuItem('Storage', 'storage.md'),
menuItem('Messaging', 'messaging.md'),
menuItem('I18n', 'i18n.md'),
+ menuItem('Favicons', 'favicons.md'),
menuItem('Scripting', 'scripting.md'),
menuItem('WXT Modules', 'wxt-modules.md'),
menuItem('Frontend Frameworks', 'frontend-frameworks.md'),
diff --git a/docs/guide/essentials/favicons.md b/docs/guide/essentials/favicons.md
new file mode 100644
index 00000000..524d86f5
--- /dev/null
+++ b/docs/guide/essentials/favicons.md
@@ -0,0 +1,51 @@
+# Favicons
+
+[Chrome Docs](https://developer.chrome.com/docs/extensions/how-to/ui/favicons)
+
+Chromium-based browsers expose cached favicons through the `_favicon/` URL served from your extension. To use it, declare the `favicon` permission in your manifest and call `browser.runtime.getURL` with a `_favicon/` path:
+
+```ts
+// wxt.config.ts
+import { defineConfig } from 'wxt';
+
+export default defineConfig({
+ manifest: {
+ permissions: ['favicon'],
+ },
+});
+```
+
+```ts
+// Any entrypoint
+function getFaviconUrl(pageUrl: string, size = 16) {
+ const url = new URL(browser.runtime.getURL('/_favicon/'));
+ url.searchParams.set('pageUrl', pageUrl);
+ url.searchParams.set('size', String(size));
+ return url.toString();
+}
+```
+
+::: warning Chromium only
+The favicon API is only available on Chromium-based browsers. Firefox has no equivalent, so `/_favicon/` URLs will not resolve at runtime there. If your extension supports both browsers, gate favicon usage behind `import.meta.env.CHROME` (or similar) and declare the permission per-browser.
+:::
+
+## Usage from a content script
+
+WXT does **not** add a `web_accessible_resources` entry for `_favicon/*` — not every extension needs one, and adding it unconditionally would expose internals to sites that don't need them. If you want to load a favicon inside a content script (for example, as an `
`), add your own entry in `wxt.config.ts`:
+
+```ts
+// wxt.config.ts
+import { defineConfig } from 'wxt';
+
+export default defineConfig({
+ manifest: {
+ permissions: ['favicon'],
+ web_accessible_resources: [
+ {
+ resources: ['_favicon/*'],
+ matches: [''],
+ },
+ ],
+ },
+});
+```
diff --git a/packages/wxt/e2e/tests/typescript-project.test.ts b/packages/wxt/e2e/tests/typescript-project.test.ts
index 9b89ee8e..15de84b8 100644
--- a/packages/wxt/e2e/tests/typescript-project.test.ts
+++ b/packages/wxt/e2e/tests/typescript-project.test.ts
@@ -420,6 +420,48 @@ describe('TypeScript Project', () => {
expect(output).toContain('./example.ts');
});
+ it('should type `/_favicon/` paths when the `favicon` permission is declared', async () => {
+ const project = new TestProject();
+ project.addFile('entrypoints/popup.html', '');
+
+ await project.build({
+ manifest: { permissions: ['favicon'] },
+ });
+
+ const pathsOutput = await project.serializeFile('.wxt/types/paths.d.ts');
+ const manifest = await project.getOutputManifest();
+
+ // The generated type must be a template-literal (backticks), not a
+ // string-literal — otherwise query params (`?pageUrl=...&size=...`)
+ // would not type-check.
+ expect(pathsOutput).toMatchInlineSnapshot(`
+ ".wxt/types/paths.d.ts
+ ----------------------------------------
+ // Generated by wxt
+ import "wxt/browser";
+
+ declare module "wxt/browser" {
+ export type PublicPath =
+ | ""
+ | "/"
+ | "/popup.html"
+ | \`/_favicon/\${string}\`
+ type HtmlPublicPath = Extract
+ export interface WxtRuntime {
+ getURL(path: PublicPath): string;
+ getURL(path: \`\${HtmlPublicPath}\${string}\`): string;
+ }
+ }
+ "
+ `);
+
+ // Per the review on #1570, WXT intentionally does NOT add a
+ // `web_accessible_resources` entry for `_favicon/*`: that's only
+ // needed for content-script usage, and users who need it can add
+ // it themselves.
+ expect(manifest.web_accessible_resources).toBeUndefined();
+ });
+
it('should set correct import.meta.env.BROWSER type based on targetBrowsers', async () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html', '');
diff --git a/packages/wxt/src/builtin-modules/favicon-permission.ts b/packages/wxt/src/builtin-modules/favicon-permission.ts
new file mode 100644
index 00000000..bb7b07e8
--- /dev/null
+++ b/packages/wxt/src/builtin-modules/favicon-permission.ts
@@ -0,0 +1,41 @@
+import { defineWxtModule } from '../modules';
+import type { WxtDirFileEntry } from '../types';
+
+/**
+ * Adds a template-literal type for the `_favicon/` paths served by Chrome's
+ * favicon API when the `favicon` permission is declared. With this module,
+ * `browser.runtime.getURL('/_favicon/?pageUrl=...')` type-checks without
+ * needing a `@ts-expect-error`.
+ *
+ * Extensions that load favicons from a content script must still add their own
+ * `web_accessible_resources` entry — this module intentionally does not touch
+ * the manifest. See the review thread on #1570 for context.
+ *
+ * @see https://developer.chrome.com/docs/extensions/how-to/ui/favicons
+ */
+export default defineWxtModule({
+ name: 'wxt:built-in:favicon-permission',
+ setup(wxt) {
+ wxt.hooks.hook('prepare:types', (_, entries) => {
+ if (!wxt.config.manifest.permissions?.includes('favicon')) return;
+
+ const pathsEntry = entries.find(
+ (entry): entry is WxtDirFileEntry =>
+ 'path' in entry && entry.path === 'types/paths.d.ts',
+ );
+ if (!pathsEntry) return;
+
+ // The base generator wraps every public path in double quotes,
+ // producing string-literal union members. Favicon URLs need a
+ // template-literal type so arbitrary query strings type-check, so
+ // we splice a `\`/_favicon/\${string}\`` member into the union
+ // directly. The `HtmlPublicPath` line is a stable anchor emitted
+ // by generate-wxt-dir.ts:getPathsDeclarationEntry.
+ const anchor = ' type HtmlPublicPath';
+ pathsEntry.text = pathsEntry.text.replace(
+ anchor,
+ ' | `/_favicon/${string}`\n' + anchor,
+ );
+ });
+ },
+});
diff --git a/packages/wxt/src/builtin-modules/index.ts b/packages/wxt/src/builtin-modules/index.ts
index d958b95b..540f72fd 100644
--- a/packages/wxt/src/builtin-modules/index.ts
+++ b/packages/wxt/src/builtin-modules/index.ts
@@ -1,4 +1,5 @@
import { WxtModule } from '../types';
+import faviconPermission from './favicon-permission';
import unimport from './unimport';
-export const builtinModules: WxtModule[] = [unimport];
+export const builtinModules: WxtModule[] = [unimport, faviconPermission];