Permission Management
How to configure and understand plugin permissions in Tauri v2 applications including built-in and custom plugins
Every plugin command in Tauri v2 that touches the system — reading a file, opening a URL, spawning a process — sits behind a permissions gate. If you call a command from your React frontend without explicitly telling Tauri that your app is allowed to use it, the call fails silently in the background. The error is logged to the console, but your UI sees nothing happen. This is the same model as Tauri v2 Permissions & Security; here the focus is plugins.
The permission system is built on the principle of least privilege. A calendar app should not be able to delete files even if it bundles a plugin that has a command for that. So Tauri requires you to declare exactly which commands each window can invoke and, where applicable, which files or URLs those commands can touch.
This guide covers how permissions work, how to configure them for existing plugins like the filesystem plugin, and how to define them for a plugin you write yourself.
How the Permission System Protects Your App
Think of Tauri’s permission model as a lock on every plugin command. The lock only opens if two conditions are met:
- The plugin itself has defined the permission — the allow and deny rules it ships with.
- Your application has granted that permission to the window that is trying to call the command.
If either piece is missing, Tauri blocks the call. This prevents third-party code that runs inside the webview from abusing native capabilities, and it stops you from accidentally exposing dangerous functionality from a plugin you installed months ago and forgot about.
From a beginner’s perspective, the mental model is a set of keys. The plugin crate comes with a keyring of labelled keys (the permissions it knows about). You decide which keys to hand to each window in your app. A window can only use the keys it was given, and for some keys you can further restrict them by attaching a note that says “only open this specific file” or “only visit URLs that start with https://tauri.app”.
The Pieces of Permission Configuration
Before working through a concrete example, it helps to know the parts.
Plugin manifest — A metadata file generated at compile time that lists every command the plugin offers and the permission identifiers associated with them. For built-in plugins like fs or opener, the manifest is already embedded in the plugin crate. For a custom plugin, you generate the manifest through a build script.
Permissions — A plugin can have many permissions, each named like plugin-name:allow-command-name or plugin-name:deny-command-name. There is also a plugin-name:default permission set that bundles the most common ones the plugin author considers safe enough for immediate use.
Capabilities — JSON files inside src-tauri/capabilities/ (or the older tauri.conf.json capabilities section) where you assign permissions to specific windows. A capability file says: “The window labelled main is allowed to use fs:allow-write-text-file with this restriction on the path.”
Scopes — Constraints that limit what a command can access even after you’ve granted the permission. A scope might restrict file writing to a single directory or URL opening to a particular scheme. Scopes are attached to individual permission entries in a capability.
Capabilities are per-window:
Every window in a multi-window Tauri app gets its own set of permissions via one or more capability files. The window label in the capability determines which window receives the permissions.
Setting Up Permissions for an Existing Plugin
The most direct way to understand the workflow is to add a plugin and grant it just enough access to accomplish one task.
Here you will add the filesystem plugin and let it write a text file to a specific location under the user’s home directory. The frontend is a React component that calls the plugin’s writeTextFile function.
Step 1: Add the filesystem plugin
In your Tauri project root, run the Tauri CLI to install the plugin:
pnpm tauri add fs
This adds tauri-plugin-fs to Cargo.toml, registers the plugin in src-tauri/src/lib.rs, and installs the JavaScript bindings (@tauri-apps/plugin-fs) in your frontend package.json.
After installation, your src-tauri/src/lib.rs should contain something like this:
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
The plugin is initialised but not yet accessible from the frontend because no permissions have been granted.
Plugin commands blocked by default:
At this stage, calling any fs command from the frontend will produce an error similar to fs.write_text_file not allowed. Permissions associated with this command: .... You must explicitly grant the permission in a capability file.
Step 2: Examine the default permissions
Every maintained plugin ships a default permission set. For the fs plugin, the default set includes read‑only access to the application’s own data directory ($APP) and its sub‑directories. It does not include write access.
You can see exactly what the default includes by opening the plugin’s permissions/default.toml inside the plugin crate, or by checking the documentation. For the fs plugin, the default permission set lists:
permissions = ["read-all", "scope-app-recursive", "deny-default"]
Your app’s capability file (src-tauri/capabilities/default.json) already references fs:default because the CLI added it. So your app can already read files inside $APP, but it cannot write anything anywhere yet.
Step 3: Add write permission with a restricted scope
The fs plugin offers many individual permissions. To write a text file, you need fs:allow-write-text-file. Additionally, because you want to write to the user’s home directory (not just $APP), you need a scope that allows a specific path.
Open src-tauri/capabilities/default.json and add the permission together with its scope:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"path:default",
"event:default",
"window:default",
"app:default",
"image:default",
"resources:default",
"menu:default",
"tray:default",
"shell:allow-open",
"fs:default",
{
"identifier": "fs:allow-write-text-file",
"allow": [{ "path": "$HOME/test.txt" }]
}
]
}
The scope { "path": "$HOME/test.txt" } means the command can only write to that exact file. The $HOME variable is resolved at runtime to the user’s home directory. You could also use a glob pattern like $HOME/docs/** to allow any file under the docs folder.
Always prefer the narrowest scope:
Avoid wildcards like **/* unless you genuinely need the entire filesystem. A narrow scope limits the damage if another part of your application behaves unexpectedly.
Step 4: Test from the React frontend
With the permission in place, write a small React component that calls the plugin.
import { useState } from "react";
import { writeTextFile, BaseDirectory } from "@tauri-apps/plugin-fs";
function App() {
const [message, setMessage] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim()) return;
await writeTextFile("test.txt", message, {
baseDir: BaseDirectory.Home,
});
setMessage("");
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Enter text to save..."
/>
<button type="submit">Save to file</button>
</form>
);
}
export default App;
Start the development server with pnpm tauri dev. Type some text into the input and submit. The file test.txt appears in your home directory containing whatever you typed.
Everything is working:
If you see the file created with the correct content, the permission system is set up correctly. The command was allowed, the scope was respected, and the frontend had no idea any of this machinery was involved.
If instead the console shows fs.write_text_file not allowed. Permissions associated with this command: fs:allow-app-write, fs:allow-app-write-recursive, ..., double‑check the capability file. A missing scope or a mistyped permission identifier is the usual cause.
Creating Permissions for Your Own Plugin
When you write a plugin, you are responsible for two things: telling Tauri which commands exist, and deciding which permissions to expose. Skipping either step leaves the commands invisible to the permission system, and every invocation will fail with “Plugin did not define its manifest” or “command not allowed”.
This section shows a minimal inline plugin named demo with a single command greet. You will register the plugin through the app’s build script and add the necessary permission to the capability file.
Registering the plugin manifest in build.rs
The build script generates the manifest that lets Tauri discover your plugin’s commands at compile time. Without it, the permission identifiers you reference in capabilities will not be recognised.
Create or modify src-tauri/build.rs:
fn main() {
tauri_build::try_build(
tauri_build::Attributes::new()
.plugin(
"demo",
tauri_build::InlinedPlugin::new().commands(&["greet"]),
),
)
.expect("failed to run tauri-build");
}
The InlinedPlugin approach works when your plugin logic lives inside the same crate (the src-tauri directory). The string "demo" becomes the plugin identifier that prefixes all permissions, so the commands will be available as demo:allow-greet and demo:deny-greet.
If you instead have a separate plugin crate in a workspace, that crate’s own build.rs should use tauri_plugin::Builder to generate the permissions directory. The result is the same: the app’s capability files can reference the generated identifiers.
Adding the command and the plugin builder
The Rust side of the plugin is an ordinary Tauri command wrapped in a plugin initialiser.
use tauri::command;
use tauri::plugin::{Builder, TauriPlugin};
use tauri::Runtime;
#[command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("demo")
.invoke_handler(tauri::generate_handler![greet])
.build()
}
Then register the plugin in the main run function:
mod plugins;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(plugins::demo::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
After a rebuild, the demo plugin is loaded, but the greet command is still blocked because no capability grants it yet.
Missing build.rs causes manifest errors:
If you forget the build.rs step, calling greet from the frontend will produce an error: demo.greet not allowed. Plugin did not define its manifest. The plugin is loaded, but Tauri does not know what permissions to associate with it. The build script is what bridges that gap.
Granting the permission in capabilities
Now add the demo:allow-greet permission to your capability file. You can either reference demo:default (if you had defined a default permission set) or add the specific identifier directly.
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"path:default",
"event:default",
"window:default",
"app:default",
"image:default",
"resources:default",
"menu:default",
"tray:default",
"demo:allow-greet"
]
}
Now the frontend can call the command safely.
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
function App() {
const [greeting, setGreeting] = useState("");
const handleGreet = async () => {
const result: string = await invoke("plugin:demo|greet", {
name: "Tauri",
});
setGreeting(result);
};
return (
<div>
<button onClick={handleGreet}>Greet</button>
<p>{greeting}</p>
</div>
);
}
export default App;
The invocation uses the fully qualified command name plugin:demo|greet. This is the format for plugin commands in Tauri v2 when using invoke. If your plugin exposes JavaScript bindings through an NPM package, the call would look different, but the underlying permission check remains identical.
Choosing how to supply default permissions
A plugin can bundle a default permission set so that users can add a single line demo:default to their capabilities instead of listing every command. Whether to rely on default or explicitly list individual permissions is a choice you make.
Create a permissions/default.toml in your plugin’s crate:
[default]
description = "Default permissions for the demo plugin"
permissions = ["allow-greet"]
Then in the app’s capability, include demo:default. This automatically enables all permissions listed inside that default set.
This approach keeps the permissions definition near the plugin code and works well when you publish the plugin for others to use.
Common Permission Mistakes
Several pitfalls surface repeatedly when developers start working with the permission system.
Forgetting the build script for a custom plugin. This is the number one cause of “Plugin did not define its manifest” errors. Any plugin that is not part of the official Tauri plugin workspace needs a build.rs that registers its commands, or a dynamic capability registration. The error message is cryptic because it says nothing about the build script — it only complains about the manifest.
Naming mismatches due to double underscores. Rust command names with double underscores (do_something__else) become double hyphens in permission identifiers (do-something--else). A command my__cmd becomes allow-my--cmd, not allow-my_cmd. If you list the permission with single underscores, it will never match, and the command will be blocked. The CLI may warn you about unknown permissions if you use a name that doesn’t correspond to any generated identifier.
Assuming default covers everything. The default permission set is curated by the plugin author and deliberately excludes dangerous commands. For the fs plugin, fs:default only covers read operations and access to the app’s own data directory. Write commands, access to $HOME, and other privileged operations require explicit additional permissions.
Applying scopes incorrectly. A common misconception is that a scope like { "path": "$HOME/*" } grants recursive access. The * glob matches a single directory level; **/* matches recursively. Using * when you intend to allow an entire subtree will cause permission‑denied errors on deeper files.
Mixing up the capability file location. Capabilities belong in src-tauri/capabilities/*.json (or the tauri.conf.json inline section). Placing a permission entry in the wrong JSON file or misspelling the folder name leads to permissions being silently ignored.
Double‑underscore commands produce double‑hyphen permissions:
Always check the generated permission identifiers after a build. The Tauri build process logs them, or you can look inside the gen/schemas directory. If you rename a command, the permission identifier changes too, so update your capability files accordingly.
Scopes: Restricting File Paths and URLs
Several plugins that interact with external resources — fs, opener, http — support scopes. A scope is a runtime filter attached to a permission that further limits what a command can do.
For file‑system operations, scopes use glob patterns. A scope entry looks like:
{
"identifier": "fs:allow-read-text-file",
"allow": [{ "path": "$APPDATA/logs/*.txt" }]
}
The $APPDATA variable is resolved at runtime. Common variables include $HOME, $APPDATA, $DESKTOP, and $RESOURCE (the app’s resource directory). If you need to allow any file on the system, you can use **/*, but this effectively disables the protection the scope was meant to provide.
For the opener plugin, URL scopes follow a similar pattern:
{
"identifier": "opener:allow-open-url",
"allow": [{ "url": "https://tauri.app" }, { "url": "custom:*" }]
}
Here custom:* matches any URL whose scheme is custom. The opener plugin’s default permission set only allows mailto:, tel:, https://, and http:// URLs. If your app needs to open a different scheme, you must add a scope for it.
Scopes are evaluated at runtime, and if a command attempts to operate on a resource outside the allowed set, Tauri blocks the call even though the permission was granted. The error message will indicate that the path or URL was not within the allowed scope.
Summary
Permission management in Tauri v2 is a contract between your app’s windows and the plugins they use. Every command that reaches beyond the webview is guarded, and the guard only lifts when you explicitly grant the right permission with the right scope.
The workflow for built‑in plugins is to install the plugin, inspect its default permissions, then add specific allow permissions and scopes to a capability file. For plugins you write yourself, the extra step is the build script that generates the manifest — without it, the plugin’s commands remain invisible to the permission engine.
Once you understand that permissions are per‑window and scopes are per‑permission, the configuration becomes a straightforward declaration of intent. What you allow is exactly what runs; nothing more.
If you have not already, continue to Security Recommendations to understand how permission management fits into the broader security model of a Tauri application. For the errors you are likely to hit while developing plugins, see Common Plugin Errors.