Capability Files

Understand Tauri v2 capability files and how they enforce the least-privilege security model by controlling which permissions each window receives

A Tauri application’s frontend runs inside a system WebView. By itself, that WebView has no access to the operating system. Every file dialog, filesystem read, notification, or shell command the frontend triggers must pass through Tauri’s Rust backend. In Tauri v2, capability files are the single mechanism that decides which of those backend features a window is allowed to use. They live in src-tauri/capabilities/.

If a permission is not listed in a capability file assigned to a window, the frontend code running in that window simply cannot invoke it — the call is blocked before it ever reaches your Rust commands.

What Capabilities Are

A capability is a named collection of permissions, tied to one or more windows or webviews. Each capability file answers three questions:

  • Which windows get these permissions?
  • Which permissions (core APIs, plugin features, individual commands) are allowed?
  • Under what conditions — such as only on certain platforms or only from certain URLs — do these permissions apply?

Capability files live as JSON or TOML documents inside the src-tauri/capabilities directory of your project. Tauri reads every file in that directory automatically when you build, unless you explicitly pick a subset in tauri.conf.json.

Why not just use tauri.conf.json?:

You can define capabilities directly inside tauri.conf.json, but keeping them in separate files makes permission boundaries easier to audit and harder to accidentally broaden. A reviewer can look at one file and understand exactly what the settings window is allowed to do, without scanning a monolithic config.

How Capability Files Work

The mechanics are straightforward:

  1. You create a file like src-tauri/capabilities/default.json with an identifier, a list of windows, and a permissions array.
  2. At build time, Tauri collects all capability files from that directory.
  3. During runtime, before any IPC call from the frontend executes, Tauri checks whether the calling window is covered by at least one capability that includes the required permission.
  4. If not, the call is rejected — the frontend receives an error, and no Rust code runs.

By default, every file in src-tauri/capabilities is active. If you set the app.security.capabilities array in tauri.conf.json, only the identifiers listed there are used — all other files in the directory are ignored. This gives you a toggle: automatic inclusion for rapid development, explicit selection for tight production control.

The Mental Model for Beginners

Think of capability files as visitor badges at a secure building. Each badge (capability file) lists which rooms (permissions) the holder (window) can enter. A window can carry multiple badges, and a single badge can be issued to many windows. If a window shows up at a door without the right badge, security turns it away.

This replaces Tauri v1’s allowlist system, where all frontend code had access to everything unless you manually blocked it. In v2, the default is nothing — you grant only what you need.

Anatomy of a Capability File

Every capability file shares the same structure. Here is the most common form, showing every field a realistic file might use:

src-tauri/capabilities/default.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "main-capability",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "dialog:allow-ask",
    "fs:allow-home-read"
  ],
  "platforms": ["linux", "macOS", "windows"],
  "remote": {
    "urls": ["http://localhost:*/**"]
  }
}

Each field:

  • $schema — links to the JSON schema Tauri generates for your project. This gives you autocompletion and validation in editors like VS Code. The schema path is relative to the capability file. Use desktop-schema.json or mobile-schema.json, or a platform-specific one.
  • identifier — a unique name for this capability. You reference it when enabling capabilities explicitly in tauri.conf.json.
  • description — optional but useful; explains why this capability exists.
  • windows — an array of window labels. "main" means the window created with label main. You can use "*" to mean all windows.
  • permissions — the granted permission identifiers. These are strings like core:default, dialog:allow-ask, or fs:allow-home-read. The naming convention is <plugin-or-domain>:<scope>.
  • platforms — restricts the capability to specific operating systems. If omitted, the capability applies everywhere.
  • remote — an object with a urls array. By default, only bundled (local) code can use Tauri APIs. Adding URLs here extends that access to remote pages — essential during development with Vite’s dev server.
  • local — a boolean (defaults to true) that indicates this capability applies to the locally bundled application. You rarely need to change it.

The Default Capability File

When you create a new Tauri v2 project with create-tauri-app, it generates a file at src-tauri/capabilities/default.json that looks similar to this:

src-tauri/capabilities/default.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default"
  ]
}

The single permission core:default is the baseline. Without it, the frontend cannot invoke any IPC commands at all — no invoke, no event listening, no window management. It is the equivalent of “the frontend can talk to the Rust backend.”

Never remove core:default without a deliberate plan:

Removing core:default from every capability that covers a window will break all communication between that window and your Rust code. Even custom commands you defined via tauri::generate_handler! will be unreachable. Only omit it if you want a completely isolated window that runs purely as a static page.

As you add plugins, you append their default permission identifiers. For instance, adding the dialog plugin means adding "dialog:default". Without that addition, calls to open() or save() from @tauri-apps/plugin-dialog will fail silently.

Assigning Capabilities to Specific Windows

Tauri applications often have more than one window: a main interface, a settings panel, a splash screen. Capability files let you give each window a different set of permissions.

The windows field accepts an array of window labels. For example, to give filesystem access only to the main window and dialog access to both main and settings windows, you can create separate files:

src-tauri/capabilities/filesystem.json
{
  "identifier": "fs-read-home",
  "description": "Allow reading files from the home directory",
  "windows": ["main"],
  "permissions": ["fs:allow-home-read"]
}
src-tauri/capabilities/dialog.json
{
  "identifier": "dialog-access",
  "description": "Allow opening native dialogs",
  "windows": ["main", "settings"],
  "permissions": ["dialog:allow-ask"]
}

A single capability file can also list multiple windows directly. Use "*" to cover every window in the application — but think twice before doing that, because it breaks the principle of giving each window only what it actually needs.

'*' is convenient but risky:

Using "windows": ["*"] means any window you add in the future automatically inherits those permissions. If you later create a popup that loads third-party content, that window can now invoke those privileged APIs. Explicit window labels are safer.

Working with Multiple Capability Files

There is no limit on the number of capability files. A common pattern is to group permissions by feature domain:

src-tauri/capabilities/
├── core.json         # core:default for all windows
├── filesystem.json   # fs permissions for main window
├── dialog.json       # dialog for main and settings
├── notifications.json # notification permissions for main

At build time, Tauri merges the capabilities for each window. If the main window is covered by core.json, filesystem.json, and dialog.json, it receives the union of their permissions — everything from all three files. You do not need to duplicate core:default in every file if a separate core capability already covers that window.

This modularity makes audits straightforward. If you want to remove all filesystem access, you delete or disable filesystem.json. The rest of the application is untouched.

Platform-Specific Capabilities

Not every permission makes sense on every operating system. The shell plugin, for instance, cannot spawn processes on iOS. A capability file’s platforms array restricts the entire file to the listed targets.

Here is a capability for desktop-only global shortcuts:

src-tauri/capabilities/desktop.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "desktop-shortcuts",
  "description": "Global shortcuts for desktop platforms",
  "windows": ["main"],
  "platforms": ["linux", "macOS", "windows"],
  "permissions": ["global-shortcut:allow-register"]
}

And its counterpart for mobile-only NFC scanning:

src-tauri/capabilities/mobile.json
{
  "$schema": "../gen/schemas/mobile-schema.json",
  "identifier": "mobile-nfc",
  "description": "NFC scanning on mobile",
  "windows": ["main"],
  "platforms": ["iOS", "android"],
  "permissions": ["nfc:allow-scan"]
}

On desktop, mobile-nfc is silently ignored. On iOS, desktop-shortcuts has no effect. The same codebase can ship with a different set of active permissions depending on the target, all driven by these JSON files.

Platform boundaries keep builds clean:

Defining platform-specific capabilities means you do not need conditional logic in your frontend to avoid calling unavailable APIs. On a platform where the capability is absent, the IPC call is blocked at the permission layer, so the frontend receives a clear error rather than an undefined native crash.

Remote API Access During Development

When you run npm run dev, Vite serves your frontend from a localhost URL — not from the bundled tauri:// protocol. By default, Tauri only allows IPC from bundled code, so your dev server would be completely cut off from the Rust backend.

The remote field solves this. Adding the typical development URL pattern lets the WebView communicate with Tauri during development:

src-tauri/capabilities/default.json
{
  "identifier": "default",
  "windows": ["main"],
  "remote": {
    "urls": ["http://localhost:*/**"]
  },
  "permissions": ["core:default"]
}

The pattern http://localhost:*/** matches any port and any path, which covers the varying ports Vite might assign.

Do not ship this in production without review:

Remote URL access is meant for development. In production, your frontend loads from tauri:// or https://tauri.localhost, which is automatically trusted. Leaving wildcard localhost rules in production is not inherently dangerous if no remote content is loaded, but it lowers the security posture. Remove or restrict the remote block for release builds if possible.

You can also use remote for legitimate production scenarios, like embedding a web app from your own domain. For example:

src-tauri/capabilities/remote-tags.json
{
  "identifier": "remote-tag-capability",
  "windows": ["main"],
  "remote": {
    "urls": ["https://*.myapp.com"]
  },
  "platforms": ["iOS", "android"],
  "permissions": ["nfc:allow-scan", "barcode-scanner:allow-scan"]
}

This would let pages served from any subdomain of myapp.com invoke NFC and barcode scanner APIs — useful if part of your app’s UI lives on the web.

Inline Capabilities in tauri.conf.json

Capability files are the recommended approach, but you can also define capabilities directly inside tauri.conf.json under app.security.capabilities. This is sometimes convenient for small projects or for capabilities that are tightly coupled to the app’s identity.

src-tauri/tauri.conf.json
{
  "app": {
    "security": {
      "capabilities": [
        {
          "identifier": "inline-capability",
          "description": "Defined directly in tauri.conf.json",
          "windows": ["*"],
          "permissions": ["fs:default", "allow-home-read-extended"]
        },
        "main-capability"
      ]
    }
  }
}

In the array, you can mix inline objects with string references to file-based capability identifiers. The same merging logic applies: Tauri collects everything from the explicit list and ignores the automatic directory scan.

Understanding Permission Identifiers

Every entry in the permissions array follows a naming convention that tells you exactly what is being granted.

  • core:default — the baseline set of core Tauri APIs: invoke, event system, window management essentials.
  • <plugin>:default — all default commands from a plugin. For example, dialog:default enables file open, save, and message dialogs. shell:default enables opening URLs and executing shell commands.
  • <plugin>:allow-<command> — a single, narrowly scoped command. dialog:allow-ask grants only the ability to show a yes/no dialog, without file picker access.
  • <plugin>:deny-<command> — explicitly denies a specific command, useful if you want most of a plugin’s default set but need to block one dangerous operation.

Plugin authors publish these identifiers as part of their plugin’s permissions directory. When you add a plugin via Cargo, Tauri’s build script generates the complete list of available permissions, which also drives the JSON schemas for autocompletion.

Custom command permissions:

Commands you register with tauri::generate_handler! are, by default, accessible to all windows that have core:default. If you need finer control, you can define custom permission files in src-tauri/permissions and reference them in your capabilities. The Tauri build system can even auto-generate these for you using the tauri_build::Attributes::app_manifest API.

Common Mistakes and How to Avoid Them

Adding a plugin to Cargo.toml but forgetting the permission

This is the most frequent error. You install tauri-plugin-dialog, register it in main.rs with .plugin(tauri_plugin_dialog::init()), and then call open() from the frontend — and nothing happens. No error, no dialog.

The frontend call fails at the capability layer, and unless you are logging IPC errors, it is silent. After adding any plugin, always add its default permission to at least one capability file covering the relevant window.

Silent failures are the hardest to debug:

A missing permission does not throw a visible exception in the WebView console by default. Use the Tauri inspector or enable verbose logging in development to catch these blocks early.

Using overly broad permissions

Granting fs:default to a window that only needs to read a single known directory is a larger risk than necessary. Tauri’s scope system can restrict filesystem access to specific paths, and you can define custom scoped permissions that reference those paths. Start with the narrowest permission that works, then widen only if required.

Forgetting to add windows to new capability files

A capability file with "windows": ["settings"] has no effect on the main window. If you create a capability for a plugin but do not list the correct window label, the frontend in that window still cannot call the API. Check your window labels — they are case-sensitive and must match the labels defined in tauri.conf.json or created programmatically.

Leaving remote access wide open

A remote.urls value of ["*"] would allow any website loaded in the WebView to access Tauri’s entire IPC bridge. This is almost never what you want. Restrict remote URLs to the specific origins you control.

Security Boundaries — What Capabilities Do and Do Not Protect

The capability system is a runtime permission gate between the frontend and the Rust backend. It assumes your Rust code is trustworthy.

What it protects against:

  • A compromised frontend dependency that tries to call privileged APIs
  • Accidental use of dangerous APIs in windows that do not need them
  • Privilege escalation from one window to another — each window has its own capability set

What it does not protect against:

  • Malicious or vulnerable Rust code. If your own command implementation has a bug, capabilities will not stop an attacker who has already convinced the frontend to call it.
  • Supply chain attacks on your Rust dependencies.
  • WebView zero-day exploits.

Capabilities are one layer in a defense-in-depth strategy. They work alongside Content Security Policy (CSP), code signing, and careful Rust-side validation.

Schema Files for IDE Support

Tauri automatically generates JSON schemas for your project’s available permissions and places them in src-tauri/gen/schemas/. Setting the $schema property to the appropriate path gives you:

  • Autocompletion for permission identifiers
  • Validation that the identifiers you typed actually exist
  • Inline documentation from the schema in supported editors

Always use the schema that matches your capability’s target. For a desktop-only capability, point to desktop-schema.json. For mobile, mobile-schema.json. The generated schemas know which permissions exist on which platforms, so they will flag an iOS-only permission used in a desktop capability.

Practical Example — Filesystem Access for the Main Window

Imagine your app needs to let the user pick a file from their home directory. You have already added tauri-plugin-dialog and tauri-plugin-fs to Cargo.toml and registered them in main.rs.

First, create a focused capability file:

src-tauri/capabilities/file-access.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "file-access",
  "description": "Allows the main window to pick and read files from home",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "dialog:allow-open",
    "fs:allow-home-read"
  ]
}

The Rust side needs both plugins active:

src-tauri/src/main.rs
fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_fs::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Now, from your React frontend, you can use the dialog to pick a file and the fs plugin to read it:

src/App.tsx
import { open } from "@tauri-apps/plugin-dialog";
import { readTextFile } from "@tauri-apps/plugin-fs";
function FileReader() {
  const handlePickFile = async () => {
    const selected = await open({ multiple: false });
    if (selected) {
      const content = await readTextFile(selected.path);
      console.log(content);
    }
  };
  return <button onClick={handlePickFile}>Open File</button>;
}
export default FileReader;

If you forget fs:allow-home-read, the readTextFile call will be blocked. If you forget dialog:allow-open, the open() call will return nothing. The capability file is the single place that controls both.

Everything works when permissions match:

When you see the file dialog appear and the content logged to the console, you know the capability file, the Rust plugins, and the frontend code are all aligned. This tight coupling makes debugging permission issues straightforward: if a feature does not work, check the capability file first.

Defense in Depth — Capabilities Plus Rust-Side Compilation Guards

Capabilities block frontend-initiated calls. But what about Rust code that should not even be compiled on a given platform? The two layers reinforce each other.

Take the shell plugin. On iOS, spawning processes is unsupported. You can omit shell:default from the iOS capability file, but you can also exclude the plugin from the iOS build entirely:

src-tauri/src/main.rs
let mut builder = tauri::Builder::default()
    .plugin(tauri_plugin_dialog::init())
    .plugin(tauri_plugin_notification::init());
#[cfg(not(target_os = "ios"))]
{
    builder = builder.plugin(tauri_plugin_shell::init());
}
builder
    .run(tauri::generate_context!())
    .expect("error while running tauri application");

Now, if someone accidentally adds shell:default to the iOS capability, there is still no shell plugin in the binary to call. The call fails harmlessly. Both layers must agree, and the combination covers mistakes in either direction.

The same #[cfg] approach works inside generate_handler! to drop individual commands per platform.

Best Practices for Capability Files

Use separate files by feature domain. filesystem.json, notifications.json, network.json — this mirrors how you organize plugins and makes it obvious which file to edit when requirements change.

Assign capabilities to the narrowest set of windows. If the settings window does not need filesystem access, do not list it in the filesystem capability.

Start with default permissions, then tighten. The default set is well-scoped for most use cases. Only when you identify a specific security concern should you break it down to individual allow-* commands.

Review capabilities before every release. A quick audit of src-tauri/capabilities/ against your app’s actual feature usage can catch permissions that were added during development and never removed.

Enable schemas. The $schema field is free documentation and real-time validation. Use it.


Summary

Capability files translate Tauri’s security model into explicit, auditable documents. They force you to answer the question “what should this window be able to do?” before any code runs, and they make the answer visible to anyone reading the project source.

The key insight is that permissions are additive per window. A window’s effective permission set is the union of all capabilities that list its label. This means you can build up complex permission profiles by composing small, single-purpose files — and you can remove a feature entirely by deleting or disabling one file.