Understanding Capabilities
Learn how Tauri v2 uses capabilities to control which commands and resources each window can access from the frontend.
What Are Capabilities?
In Tauri v2, a capability is a named set of permissions assigned to one or more windows or webviews. It acts as a gate that decides which Tauri commands and plugin APIs the frontend code in a particular window can call.
When you build a Tauri application, the frontend runs inside a system WebView. Without any restrictions, malicious or buggy code in the frontend could access powerful system APIs like the file system, shell commands, or notifications. The capabilities system ensures that each window only receives the exact permissions it needs, and nothing more.
This is different from Tauri v1, where all registered commands were available to all windows unless you implemented manual filters. In v2, the default stance is deny-all — you must explicitly grant permissions through capabilities.
Why Tauri v2 Uses a Capabilities System
The shift to explicit capability grants solves several security and maintenance problems:
- Minimized attack surface: If a dependency in your frontend gets compromised, it can only call commands that the window's capability permits. A settings window with no shell permission cannot open a URL or execute a program, even if the shell plugin is installed.
- Clearer audit trail: By storing permissions in capability files, you can see at a glance what each window is allowed to do. This makes security reviews simpler.
- Multi-window isolation: You can run an untrusted webview (for example, a third-party plugin) with a restricted capability that only grants access to a safe subset of APIs.
Capabilities are enforced both at the IPC bridge level and in the Rust backend. If a frontend tries to invoke a command for which its window lacks permission, the request is rejected before it reaches any handler.
How Capabilities Link Permissions to Windows
A capability ties three pieces together:
- A list of permission identifiers (like
core:default,fs:read-files) - A set of window labels that receive these permissions
- Optional filters for platform targets or remote URLs
When Tauri launches, it reads all capability files and builds a permission map for each window label. When a command is invoked, the runtime checks whether the window's label has a capability that contains the required permission. If not, the call is blocked.
You can have multiple capability files that reference the same window — permissions stack. For example, a base.json could grant core:default, and a file-access.json could add fs:read-files to the same main window. The effective permission set for that window is the union of all matching capabilities.
Capability File Structure
Capability files live in src-tauri/capabilities/ and can be written in JSON or TOML. The filename doesn't matter beyond organization, but the identifier field inside the file is what the system uses to reference it.
A minimal capability file contains:
{
"identifier": "main-capability",
"description": "Core permissions for the main window",
"windows": ["main"],
"permissions": [
"core:default"
]
}
The key fields are:
| Field | Description |
|---|---|
identifier | A unique name for this capability. Used when referencing it from tauri.conf.json. |
description | Human-readable explanation of what this capability covers. |
windows | An array of window labels. Use "main" for the default window, or "*" for all windows. |
permissions | An array of permission identifiers from plugins or core. |
Optionally, you can add platforms to restrict the capability to specific operating systems, or remote to allow external URLs access to the IPC bridge.
Capabilities can also be inlined directly inside tauri.conf.json under app.security.capabilities. This is convenient for simple setups but can make the configuration harder to read as your app grows.
Window Targeting and Labels
Every Tauri window has a label — a string that identifies it. The default window created by your app usually has the label "main". When you define a capability, you specify which labels should receive the permissions via the windows array.
["main"]grants permissions only to the primary window.["settings"]would target a secondary window you create for app settings.["*"]grants the permissions to all windows, including any you create dynamically at runtime.
Be Careful with Wildcards:
Using "*" in the windows array gives every window, present and future, the listed permissions. This might accidentally grant a minimal popup window access to file system or shell APIs it doesn't need. Prefer explicit window labels whenever possible.
If you create a new window at runtime via the window plugin or the Rust API, it will only receive permissions from capabilities that include its label. If no capability matches the new window's label, that window will have no permissions at all — even core:default must be explicitly granted.
Command Permissions Explained
Permissions are the actual units of access. Each permission identifier represents a specific command or group of commands. They come in two flavors:
- Core permissions prefixed with
core:, such ascore:default(basic IPC),core:window:allow-set-title(allow setting window title). - Plugin permissions prefixed with the plugin name, like
fs:read-files,dialog:default,shell:default, orfs:allow-mkdir.
The :default permission for a plugin is a bundled set that typically enables all common commands. For tighter control, you can use individual :allow-* permissions.
Here's a capability that grants the main window full file system access plus dialog and shell defaults:
{
"identifier": "desktop-abilities",
"description": "Permissions for the main window on desktop",
"windows": ["main"],
"platforms": ["linux", "macOS", "windows"],
"permissions": [
"core:default",
"fs:default",
"dialog:default",
"shell:default"
]
}
Custom Commands are Allowed by Default:
By default, any command you register in your Rust code with tauri::Builder::invoke_handler is accessible to all windows. To restrict these, use AppManifest::commands in your build.rs file to declare which commands are known, then grant them via permissions. This is an advanced topic, but it's worth knowing that the door is open by default for your own commands — capabilities alone won't block them unless you take this extra step.
Platform-Specific Capabilities
Your app may need different permissions on different platforms. For example, you might use NFC scanning on mobile but not on desktop. The platforms array lets you define capabilities that only take effect on certain operating systems.
{
"identifier": "mobile-sensors",
"description": "Mobile-only permissions",
"windows": ["main"],
"platforms": ["iOS", "android"],
"permissions": [
"core:default",
"nfc:allow-scan",
"biometric:allow-authenticate"
]
}
If you omit the platforms field, the capability applies to all targets.
When you generate schema files, you can reference platform-specific schemas (desktop-schema.json or mobile-schema.json) to get IDE autocompletion for the permissions available on that platform. This prevents you from accidentally granting a mobile-only permission on desktop.
Remote API Access
During development, your frontend code is served from a local Vite dev server at http://localhost:1420 (or similar). By default, Tauri's IPC bridge is only accessible from bundled tauri:// URLs, so your dev server cannot call commands. The remote field solves this.
{
"identifier": "dev",
"windows": ["main"],
"platforms": ["linux", "macOS", "windows"],
"remote": {
"urls": ["http://localhost:*/**"]
},
"permissions": [
"core:default"
]
}
This tells Tauri that any page loaded from a localhost URL matching the pattern can access the IPC bridge. The wildcards allow any port and any path.
Restrict Remote URLs in Production:
Never ship a production build with a remote capability that allows arbitrary localhost access. Remote access is meant for development. In production, your app is loaded from tauri:// and the remote field is typically omitted entirely. If you need to load a remote page that should access Tauri APIs, whitelist only the specific domain you control.
The remote field is an array of URL glob patterns. Patterns like https://*.tauri.app allow subdomains. This is similar to the dangerousRemoteDomainIpcAccess setting in Tauri v1, but more granular.
Security Boundaries and What They Protect
Capabilities act as a permission layer between the frontend and the system. They are effective against:
- Frontend compromise: If an attacker exploits an XSS vulnerability, they can only call commands that the compromised window's capabilities permit.
- Accidental misuse: A developer who writes a buggy component that tries to delete files will be blocked if that window lacks the necessary file system permissions.
- Multi-window isolation: A third-party iframe or webview embedded in your app can be given its own capability with zero permissions, preventing it from touching anything.
Capabilities are not a defense against:
- Malicious or buggy Rust code — the backend has full access to the system.
- Overly broad scopes — if you grant
fs:defaultwith a scope of/**, the frontend can read and write anywhere. - Intentional bypasses from the Rust side — commands can choose to ignore scopes.
- Supply chain attacks or compromised developer machines.
Layered Defense:
Think of capabilities as one layer in a defense-in-depth strategy. Combine them with strict scopes in permissions, proper CSP headers, and signing your code to minimize the impact of any single vulnerability.
Real-World Example: Configuring a Capability for Core and Plugin Access
Suppose you're building a note-taking app with a React frontend. You want the main window to:
- Call custom Rust commands (requires
core:default) - Open a file dialog to pick files (
dialog:default) - Read and write files (
fs:default) - Open links in the browser (
shell:default)
First, ensure the required plugins are registered in your main.rs:
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_shell::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Then create a capability file:
{
"identifier": "main-capability",
"description": "Permissions for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"fs:default",
"shell:default"
]
}
Now in your React component, you can call the APIs:
import { open } from "@tauri-apps/plugin-dialog";
import { readTextFile } from "@tauri-apps/plugin-fs";
import { open as openUrl } from "@tauri-apps/plugin-shell";
function App() {
async function pickAndReadFile() {
const selected = await open({ multiple: false });
if (selected) {
const content = await readTextFile(selected.path);
console.log(content);
}
}
return (
<div>
<button onClick={pickAndReadFile}>Open File</button>
<button onClick={() => openUrl("https://example.com")}>
Open Website
</button>
</div>
);
}
export default App;
If you forget to add dialog:default to the capability, the open() call will throw a runtime error. The same applies to every permission.
Verify Your Setup:
After adding a new plugin and its permission to the capability, run your app in development mode. Try the related API — if it works, the capability is correctly configured. If you see an error like "Unhandled Promise Rejection: Permission denied", double‑check that the permission identifier is spelled correctly and is assigned to the correct window.
Common Mistakes and Troubleshooting
Even with a solid understanding of capabilities, a few pitfalls catch newcomers:
Missing core:default:
Omitting core:default from every window's capability is the most common cause of "command not found" errors. The core:default permission is what enables the fundamental IPC bridge. Without it, no Tauri commands work at all — even your own custom ones.
Window Label Mismatch:
If you create a new window with a label like "settings" but your capability only lists "main", that settings window will have no permissions. Always match the label exactly. Use console.log(window.__TAURI_INTERNALS__.metadata.label) in the frontend to verify the label.
Capability Auto-Loading vs. Explicit Listing:
By default, all capability files inside src-tauri/capabilities/ are automatically included in the build. However, if you specify app.security.capabilities in tauri.conf.json, only those listed are used. A common surprise: you add a new file, forget to list it in tauri.conf.json (if you already have an explicit list), and wonder why the permissions aren't applied.
When debugging a permission error, check:
- The permission identifier is correct (use IDE autocompletion with the schema).
- The capability file is in the correct directory and has the right file extension (
.jsonor.toml). - The window label matches the one you're calling from.
- If using explicit capability listing, ensure the identifier is included.
- The plugin is actually registered in Rust (
main.rs).
Summary
Capabilities are the entry point for any Tauri v2 feature that touches the system. They turn a deny-all security model into a precise, window‑by‑window permission map. Once you internalize that a window only has the permissions listed in capabilities that target its label, the rest of the system — permission files, scopes, and command restrictions — becomes a matter of fine‑tuning.