Tauri v2 Permissions & Security

Understand how Tauri v2 locks down frontend-to-backend communication using permissions and capabilities, and how to configure secure access to native APIs.

Every native API you will use from the React frontend — reading a file, showing a dialog, sending a notification — crosses a boundary. On one side is the webview, running JavaScript that could be compromised by a malicious dependency or an XSS bug. On the other side is Rust code with full access to the operating system. Tauri v2 does not trust that boundary by default. It makes you define exactly what the frontend is allowed to reach, how, and under which conditions.

That is the job of two concepts working together: permissions describe the privileges of individual commands, and capabilities bundle those permissions and hand them to specific windows. This page covers both, from the security model they enforce to the practical steps of setting them up in a real project. The capabilities Directory page shows where these files live in a typical project.

Understanding the Security Model

When you install an npm package that turns out to be malicious, the code runs inside your frontend — it has access to window, to fetch, and to any Tauri APIs you have exposed. The Understanding the Security Model page expands on these trust boundaries. If you gave the frontend permission to read arbitrary files, that malicious package can now steal documents from your hard drive. This is not hypothetical; it is the entire reason Tauri v2's permission system exists.

The core idea is deny-by-default. When a new Tauri project boots, the frontend can call no native commands at all — not even basic ones like window.setTitle. You must grant each privilege explicitly. This limits the blast radius of a frontend compromise: an attacker who gains control of the webview can only reach the commands you deliberately enabled and scoped.

Trust boundaries and what the system protects

The IPC (Inter-Process Communication) bridge between the webview and the Rust backend is the only way frontend JavaScript can talk to native code. Tauri v2's security layer sits on that bridge and checks every incoming request against the capability configuration.

The system protects against:

  • A compromised frontend calling commands it was never granted.
  • A window accessing resources that belong to a different window.
  • Remote content (e.g., a loaded phishing page) invoking Tauri APIs when the development server is misconfigured.
  • A command being used with paths or arguments outside its allowed scope — for instance, a file read command that is scoped to $HOME cannot read /etc/passwd.

Not a silver bullet:

The permission system cannot stop malicious Rust code you write yourself, nor can it protect against a supply-chain attack that compromises a Rust dependency or the developer's machine. It also cannot defend against zero-day exploits in the system WebView. It is one layer in a defense-in-depth strategy, not a standalone guarantee.

How CSP fits into the picture

Content Security Policy (CSP) is a separate but related mechanism. It controls which scripts, styles, and resources the webview is allowed to load. A strict CSP prevents an attacker from injecting a <script> tag that loads a remote payload. If that payload could call Tauri APIs, a lax CSP would give it a direct line to your backend commands. Tauri v2 allows configuring CSP in tauri.conf.json under app.security.csp. The Security & Capabilities configuration chapter covers CSP in more depth.

A development setup often needs a relaxed CSP because Vite's hot-reload injects inline scripts. In production, you should lock it down.

Understanding Capabilities

A capability is a set of permissions granted to one or more windows. You define capabilities in JSON or TOML files inside the src-tauri/capabilities/ directory, or inline them directly in tauri.conf.json. Every window that needs to invoke a Tauri command must be covered by at least one capability that grants the required permission.

Anatomy of a capability file

A capability file has four key fields:

  • identifier — a unique name for this capability (e.g., "main-capability").
  • windows — an array of window labels that receive these permissions. Use "*" to grant them to all windows, though this should be done sparingly.
  • permissions — an array of permission identifiers. Each identifier can be a plugin default (like "fs:default"), a specific command ("fs:allow-read-file"), or a custom permission set you defined.
  • remote (optional) — controls which remote URLs can access the Tauri IPC bridge. This is almost always needed during development.

Other optional fields include platforms for scoping to specific operating systems, and description for documentation.

Here is the capability file that a newly scaffolded Tauri v2 project typically starts with:

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

The $schema line (in JSON) is optional but highly recommended — it enables autocompletion in editors like VS Code so you can see exactly which permissions are available. Tauri generates these schema files automatically during the build.

What core:default actually grants

The core:default permission is a baseline bundle. It enables fundamental IPC (so the frontend can call commands registered via tauri::generate_handler!), the event system (emit, listen), basic window management, and a few other essentials. Without it, even custom Rust commands you defined yourself will be unreachable from the frontend.

Forgetting core:default:

A common mistake when creating a capability file for a secondary window is to omit core:default. The window will appear, but every invoke() call from that window will fail silently or throw a permission error. Always include core:default for any window that needs to talk to the backend.

Remote access during development

When you run npm run tauri dev, the frontend is served from a http://localhost URL by Vite. By default, Tauri's IPC bridge is only accessible to content loaded from tauri:// URLs (the production bundle). You must explicitly allow the dev server to call commands by adding a remote section:

"remote": {
  "urls": ["http://localhost:*/**"]
}

The pattern http://localhost:*/** covers any port and any path on localhost. In production, you should remove this or restrict it to trusted origins. A production build loads the frontend from tauri:// and does not need the remote key at all.

Platform-scoped capabilities

If your app runs on both desktop and mobile, you will likely need different permissions per platform. A capability with a platforms array will only activate on the listed operating systems:

// src-tauri/capabilities/desktop.json
{
  "identifier": "desktop-only",
  "windows": ["main"],
  "platforms": ["linux", "macOS", "windows"],
  "permissions": [
    "global-shortcut:allow-register",
    "shell:default"
  ]
}
// src-tauri/capabilities/mobile.json
{
  "identifier": "mobile-only",
  "windows": ["main"],
  "platforms": ["iOS", "android"],
  "permissions": [
    "nfc:allow-scan",
    "barcode-scanner:allow-scan"
  ]
}

The desktop capability grants permissions to plugins that do not exist on mobile (global shortcuts, shell). The mobile capability grants NFC and barcode scanner access, which are mobile-only. You can create as many platform-specific capability files as you need, and Tauri will automatically include the ones matching the current build target.

Defense in depth:

Platform scoping at the capability level is a second lock. You should also exclude the Rust plugin from compilation on unsupported platforms using #[cfg(not(target_os = "ios"))] in main.rs. If one layer fails, the other still blocks the call.

Permission Configuration

Permissions are the actual units of privilege. A single permission can:

  • Allow one or more commands.
  • Attach a scope that restricts the arguments those commands accept (e.g., limiting file reads to $HOME).
  • Deny specific scopes even when a broader scope is allowed.

Permissions live in TOML or JSON files inside src-tauri/permissions/ for app-defined permissions, or inside a plugin's permissions/ directory for plugin authors. Tauri ships with a growing set of autogenerated granular permissions for every command exposed by core and plugins.

Permission identifiers

Every permission has an identifier that follows a naming convention:

  • plugin-name:default — the bundled default permission for a plugin (e.g., fs:default, dialog:default).
  • plugin-name:allow-command-name — a granular permission for one specific command (e.g., fs:allow-read-file, window:allow-set-title).
  • plugin-name:deny-command-name — explicitly denies a command, useful for subtracting from a broader set.
  • Custom identifiers — app-defined permission sets or extensions (e.g., allow-home-read-extended).

The plugin prefix tauri-plugin- is automatically prepended at compile time, so you never write it manually.

Scope: controlling what a command can touch

Some commands, like file system operations, are dangerous even if enabled — they need to be told which paths are acceptable. A scope is a list of allowed and denied patterns attached to a permission. When the command runs, Tauri validates the actual arguments against the scope before the Rust code executes.

Here is a permission that allows reading any file under $HOME, but explicitly denies the $HOME/secret directory:

[[permission]]
identifier = "scope-home-with-exceptions"
description = "Read access to $HOME except the secret folder"
commands.allow = ["read_file"]
[[scope.allow]]
path = "$HOME/*"
[[scope.deny]]
path = "$HOME/secret"

Path variables:

$HOME is a Tauri scope variable that resolves to the user's home directory. Other variables include $APPDATA, $RESOURCE, $TEMP, and $CWD. You can find the full list in the Tauri scope documentation.

Extending plugin permissions for your app

Plugins ship with pre-built permission files. As an application developer, you can combine them or extend them into new permission sets without touching the plugin's files. Suppose you want to let the frontend read files, list home directory contents, and create directories — but only inside $HOME. Instead of listing every granular command in the capability, you define a reusable permission set:

# src-tauri/permissions/home-read-extended.toml
[[set]]
identifier = "allow-home-read-extended"
description = "Read files and create directories in $HOME"
permissions = [
  "fs:read-files",
  "fs:scope-home",
  "fs:allow-mkdir"
]

Then reference that set in the capability file:

"permissions": [
  "core:default",
  "allow-home-read-extended"
]

fs:read-files is a plugin permission that enables all file-reading commands. fs:scope-home is another plugin permission that pre-configures the $HOME/* scope. fs:allow-mkdir enables the mkdir command. Your custom set bundles them under one identifier, keeping the capability file clean.

The wildcard scope trap

A frequent point of confusion is the file system scope pattern. To allow access to any file anywhere, you might try "*" — but that only matches top-level entries. The correct pattern for recursive matching is "**/*" (two asterisks, a slash, and a single asterisk). This matches all files at any depth.

{
  "identifier": "fs:scope",
  "allow": [{ "path": "**/*" }]
}

This is powerful and dangerous. It lets the frontend read (or write, if the corresponding command is allowed) every file the operating system permits the process to access. Only use it when your application genuinely needs unrestricted file access, and even then, consider whether a more constrained scope would suffice.

The **/* scope grants broad access:

A scope of "**/*" combined with write permissions means a malicious dependency in your frontend can overwrite or exfiltrate any user-accessible file. Pair it with the narrowest possible command set and consider using the Dialog API to let the user explicitly pick files instead.

Configuring Capability Files

Capability configuration is not a single-file affair. You will typically touch three places: the capabilities/ directory, tauri.conf.json, and occasionally build.rs for custom commands.

Where capability files live

Every .json or .toml file inside src-tauri/capabilities/ is automatically discovered and enabled by the build system — unless you explicitly list capability identifiers in tauri.conf.json. In that case, only the listed ones are active. This opt-out model means dropping a new capability file into the directory is enough to activate it during the next build.

The directory structure for a typical project looks like this:

src-tauri/
├── capabilities/
│   ├── default.json       # main window
│   ├── settings.json      # settings window (restricted)
│   └── mobile.json        # mobile-only permissions
├── permissions/
│   └── custom-set.toml    # your custom permission sets
├── src/
│   └── main.rs
└── tauri.conf.json

Inline capabilities

For small projects, you might prefer to keep everything in tauri.conf.json instead of separate files. Capabilities can be defined inline as objects inside the app.security.capabilities array:

// tauri.conf.json (partial)
{
  "app": {
    "security": {
      "capabilities": [
        {
          "identifier": "inline-cap",
          "description": "Inline capability for all windows",
          "windows": ["*"],
          "permissions": ["core:default", "dialog:default"]
        }
      ]
    }
  }
}

You can mix inline objects with string references to capability files. However, once the capabilities array exists in tauri.conf.json, Tauri ignores the automatic discovery of the capabilities/ directory. Only the explicitly listed identifiers and inline objects take effect.

Registering custom Rust commands

By default, every command you register with tauri::generate_handler! or the #[tauri::command] macro is callable from any window that has core:default. To restrict custom commands so they require a dedicated permission, you must tell the build system about them.

First, list the command names in build.rs:

// src-tauri/build.rs
fn main() {
    tauri_build::try_build(
        tauri_build::Attributes::new()
            .app_manifest(
                tauri_build::AppManifest::new()
                    .commands(&["my_sensitive_command"]),
            ),
    )
    .unwrap();
}

Now Tauri will generate permission identifiers like app:allow-my-sensitive-command and app:deny-my-sensitive-command. You can use those in capability files to grant or deny access to that specific command, separate from core:default.

A complete example: file reading with a scoped permission

Let's build the full picture for an app that lets the frontend read text files the user selects. The Rust side registers a command, the permission file scopes it to a safe directory, and the capability hands it to the main window.

Rust — the command and plugin registration:

// src-tauri/src/main.rs
#[tauri::command]
fn read_file_content(path: String) -> Result<String, String> {
    std::fs::read_to_string(&path).map_err(|e| e.to_string())
}
fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        .invoke_handler(tauri::generate_handler![read_file_content])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Permission — defining the scope and allowed commands:

# src-tauri/permissions/app-read-files.toml
[[permission]]
identifier = "allow-app-read-files"
description = "Allow reading text files from the user's Documents folder"
commands.allow = ["read_file_content"]
[[scope.allow]]
path = "$HOME/Documents/*"

Capability — granting the permission to the main window:

// src-tauri/capabilities/default.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "windows": ["main"],
  "remote": {
    "urls": ["http://localhost:*/**"]
  },
  "permissions": [
    "core:default",
    "allow-app-read-files"
  ]
}

React — calling the command from the frontend:

// src/App.tsx
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
function App() {
  const [content, setContent] = useState("");
  const readFile = async () => {
    try {
      const result = await invoke<string>("read_file_content", {
        path: "/home/user/Documents/notes.txt",
      });
      setContent(result);
    } catch (err) {
      console.error("Read failed:", err);
    }
  };
  return (
    <div>
      <button onClick={readFile}>Read File</button>
      <pre>{content}</pre>
    </div>
  );
}
export default App;

The read_file_content command will only succeed for paths inside $HOME/Documents. Any attempt to pass a path like /etc/hosts will be blocked before the Rust code even runs.

Everything is wired correctly:

If you see the file content on screen, all three layers are cooperating: the command is registered in Rust, the permission scopes it, and the capability makes it available to the window.

Permission Best Practices

The configuration surface is flexible, which means it is also easy to get wrong in ways that silently weaken security. These permission best practices will keep your app's attack surface small.

Grant the fewest permissions each window needs

A settings window probably does not need access to the file system or the shell. Create a separate capability file with only core:default for that window, and keep the richer permissions in the main window's capability. Do not use "windows": ["*"] unless every single window genuinely requires the same set.

Avoid wildcard scopes unless necessary

A scope of "**/*" is a single line that grants access to the entire filesystem. It is tempting during development, but it makes your app as vulnerable as a Tauri v1 app with dangerousRemoteDomainIpcAccess and no scope restrictions. Start with a narrow scope (like $HOME/Documents/* or a user-chosen path from the dialog API), and widen it only when you can explain exactly why.

Keep CSP strict in production

During development, you might set "csp": null in tauri.conf.json to make Vite's hot reload work without CSP errors. Before shipping, replace that with a real policy that at minimum restricts script-src and connect-src. At the very least:

"csp": "default-src 'self'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost"

This prevents the webview from loading external scripts and limits network requests to the app's own origin and the IPC bridge.

Null CSP is a development-only convenience:

Setting "csp": null tells the webview to accept any script, style, or connection from any origin. An app shipping with null CSP effectively trusts all content loaded in its webview — including content injected by a compromised dependency or a cross-site scripting vulnerability.

Use autogenerated granular permissions when possible

Instead of granting fs:default (which enables all file system commands), list the specific fs:allow-read-file, fs:allow-write-file, etc. that your app actually uses. This makes the capability self-documenting and reduces the chance that a future feature accidentally relies on a command you did not intend to expose.

Test on every platform you target

A capability that works on desktop might fail silently on mobile because the underlying plugin is not compiled. Use #[cfg(target_os = "...")] guards in Rust and matching "platforms" arrays in capabilities. Then test that commands are callable and that invoking an unavailable command produces a clear error rather than a silent no-op.

Beware of platform-specific permission layers

On macOS, certain operations require system-level entitlements beyond Tauri's permission system. For example, accessing the full disk or using accessibility APIs needs entitlements set in Info.plist or requested through plugins like tauri-plugin-macos-permissions. A perfectly configured Tauri capability will not override the OS sandbox. The error will look like a Rust PermissionDenied, not a Tauri IPC error — recognizing the difference saves hours of debugging.

Summary

The permission and capability system in Tauri v2 is not a set of hoops to jump through so commands work. It is the primary mechanism that defines your app's security posture. Every capability file is a statement of trust: this window, on this platform, may call these commands with these scoped arguments. Write that statement deliberately.

The mental model to carry forward is one of concentric rings. The outermost ring is the CSP, which controls what code can even run in the webview. Inside that, capabilities decide which windows can talk to the backend at all. Inside that, permissions decide which specific commands are callable. Inside that, scopes constrain the arguments. Each ring narrows what a compromised frontend can do. A gap in any ring shifts the burden to the next layer inward — so all of them matter.

With permissions configured correctly, you are ready to build features that touch the user's data without putting it at risk.

Understanding the Security Model in Tauri v2

A deep look at Tauri’s core security architecture, trust boundaries, IPC guardrails, deny-by-default design, CSP, and the isolation pattern.

Understanding Capabilities

Learn how Tauri v2 uses capabilities to control which commands and resources each window can access from the frontend.

Permission Configuration

How to define granular permissions for Tauri v2 commands, control API access, and set scopes for plugins and custom Rust commands

Configuring Capability Files

Learn how to create, configure, and manage Tauri v2 capability files to control which permissions and commands your frontend can access

Permission Best Practices

How to configure Tauri v2 permissions securely and effectively using the principle of least privilege, scoped capabilities, and organized configuration files