Permission Best Practices

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

A Tauri v2 application ships with the ability to call native APIs from the frontend, but only if you explicitly grant permission. The system is deny-by-default. Every plugin and custom command must be listed in a capability file that ties it to specific windows. Getting the configuration right keeps the app secure and avoids runtime permission errors that are hard to debug later.

This section covers the practical habits that make permission management predictable, auditable, and hard to get wrong. None of these are enforced by the compiler — Tauri will happily build an app with broken or overly broad permissions — so the practices described here are rules you impose on your own project.

Apply the Principle of Least Privilege from Day One

Every permission you add widens the attack surface available to the frontend. If a dependency in your JavaScript bundle becomes compromised, or if you accidentally load untrusted remote content, the attacker inherits whatever capabilities you granted to that window. The defense is simple: a window should have exactly the permissions it needs to function and nothing more.

This means you never start with "*" permissions or copy an example capability file verbatim. Instead, begin with core:default and add one permission at a time, only when a feature actually requires it. If you later remove a feature, remove its permission. This is not a one-time setup step — it is an ongoing discipline.

Why this matters even during development:

The browser dev tools in your WebView are a full JavaScript console. Anyone who inspects your app can call any enabled command. A broad capability granted to the main window is a public API for anyone with local access.

Favor Granular Permissions Over Wildcard Defaults

Plugin permissions come in three levels of specificity:

  • plugin:default — a curated set of commands and scopes that most apps need.
  • plugin:allow-<command> — enables exactly one named command with no scope.
  • Custom permission identifiers you define in your own permissions/ directory, combining specific commands with specific scopes.

The :default permissions are convenient, but they often grant more access than you expect. For example, fs:default enables every file system command with broad default scopes. In a note-taking app that only reads from a single data directory, that is far more than you need.

A better approach is to use the individual allow-* permissions and attach only the scopes your app uses:

src-tauri/capabilities/main.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "main-capability",
  "description": "Core capability for the main window — minimal fs access",
  "windows": ["main"],
  "permissions": [
    "core:default",
    {
      "identifier": "fs:allow-read-text-file",
      "allow": [{ "path": "$APPDATA/my-app/notes/**" }]
    },
    {
      "identifier": "fs:allow-write-text-file",
      "allow": [{ "path": "$APPDATA/my-app/notes/**" }]
    }
  ]
}

Here the app can read and write text files inside a single directory tree. It cannot list directories, delete files, or touch anything outside $APPDATA/my-app/notes. If the frontend is compromised, the attacker cannot enumerate the user’s home directory or overwrite system configuration files.

When granular permissions are the right call:

Use individual allow-* permissions with explicit scopes whenever the app's interaction with a plugin is limited to a known set of operations on a known set of paths. This covers the majority of real-world applications.

There are legitimate cases where :default is the pragmatic choice — for example, a clipboard plugin where the only operation is reading and writing text, and the platform already sandboxes clipboard access. The key is that you make the decision consciously, not by inertia.

Scope Everything That Touches the File System

File system permissions are the most dangerous category in most desktop apps. Tauri v2 lets you define scopes that constrain which paths a permission applies to. Always use scopes — never grant a file system permission without one.

The wildcard **/* matches recursively from the given base. A scope of "$HOME/**" grants access to every file the user owns. A scope of "$HOME/Documents/*" grants access only to the top-level contents of the Documents folder, not subdirectories. The difference matters enormously.

When users need to open files from arbitrary locations — such as an image editor that opens files from anywhere the user chooses — do not grant a global scope to the file system permission. Instead, use the dialog plugin to let the user pick a file, then pass only the selected path to a command with a constrained scope. Tauri v2 automatically grants temporary access to paths obtained through the dialog plugin, but only if the permission’s scope is configured to cover those paths. The safest pattern is to keep the scope broad enough to include possible user selections but narrow enough to reject programmatic attempts to read outside it.

`**/*` is still a scope — use it deliberately:

Setting "path": "**/*" grants access to every file on the system, including protected OS directories. This should never appear in a production capability file unless the app genuinely needs unfiltered disk access and you have accepted the risk. Even then, prefer restricting it to a specific command so other file operations are not affected.

Organize Capability Files by Window, Then by Concern

A flat capabilities/default.json file that contains every permission for every window becomes unreadable quickly. Instead, create one capability file per window or per security boundary, and name it after the window it serves:

src-tauri/capabilities/
├── main.json          # Main application window
├── settings.json      # Settings/Preferences window
├── updater.json       # Auto-update window (minimal access)
└── dev.json           # Development-only capabilities (remote URLs)

Each file defines its "windows" field explicitly and includes only the permissions that window needs. The settings window probably does not need clipboard access. The updater window does not need file system access. Splitting them means that if one window is compromised, the attacker’s reach is limited to that window’s capability set.

Do not use `"windows": ["*"]` in production:

A capability with "windows": ["*"] applies to every window and webview in the application, including any future windows you might add. A permission granted to "*" is effectively global. Restrict capabilities to named windows so you always know where they apply.

You can also group permissions into permission sets in the permissions/ directory and reference them by identifier from capability files. This is useful when the same combination of commands and scopes appears in multiple windows.

Separate Development and Production Capabilities

During development, the frontend is served from a local dev server (Vite, for example) on http://localhost:1420. By default, Tauri v2 blocks IPC access from remote URLs — only bundled code loaded from tauri:// URLs can call commands. To make development work, you must add a remote block to a capability file.

The mistake is to include that remote block in the same capability file used in production. If you ship an app with "remote": { "urls": ["http://localhost:*/**"] }, any local process that can connect to your app’s DevTools port can call Tauri commands.

Create a separate capability file for development only:

src-tauri/capabilities/dev.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "dev-capability",
  "description": "Development capability — enables remote IPC from Vite dev server",
  "windows": ["main"],
  "remote": {
    "urls": ["http://localhost:*/**"]
  },
  "permissions": [
    "core:default"
  ]
}

Then, conditionally include it in tauri.conf.json only when building for development. A common approach is to put all production capabilities in the capabilities directory (which are auto-enabled by default) and keep the dev capability in a separate location, enabling it only via the app.security.capabilities array in a dev-specific config override.

Remote IPC on Linux and Android is permissive by origin:

On Linux and Android, Tauri cannot distinguish between top-level window requests and iframe requests when checking the remote origin. Any page loaded from a matching URL — even in an iframe — can access the IPC bridge. Be especially careful with wildcards in remote URL patterns on these platforms.

Version Your Permission Files with the Schema

Every capability file should include a $schema key pointing to the generated JSON schema:

"$schema": "../gen/schemas/desktop-schema.json"

This gives you autocompletion and validation in VS Code and other editors that support JSON Schema. It catches typos in permission identifiers before you run the app. The schema is generated by tauri-build during compilation, so it always reflects the exact permissions available from your installed plugins.

If you skip the schema, a misspelled permission identifier (like "core:window:allow-set-titel") will silently fail at runtime — the frontend call will throw an error, and you will spend time debugging why setTitle does not work.

Platform-Scope Capabilities When Your App Runs on Mobile

Not every plugin works on every platform. The shell plugin has no equivalent on iOS because iOS does not allow process spawning. The notification plugin works on desktop and mobile, but the permission syntax is the same. If you grant shell:default in a capability that applies to all platforms, the permission is meaningless on iOS but also harmless — Tauri simply ignores it at runtime because the plugin is not compiled.

The better practice is to use the "platforms" field to scope capabilities to the platforms where they apply. This keeps the permission manifest truthful — it describes what the app actually does on each platform.

src-tauri/capabilities/desktop.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "desktop-capability",
  "description": "Desktop-only capabilities (shell, global shortcuts)",
  "platforms": ["linux", "macOS", "windows"],
  "windows": ["main"],
  "permissions": [
    "core:default",
    "shell:default",
    "global-shortcut:allow-register"
  ]
}
src-tauri/capabilities/mobile.json
{
  "$schema": "../gen/schemas/mobile-schema.json",
  "identifier": "mobile-capability",
  "description": "Mobile-only capabilities (biometrics, NFC)",
  "platforms": ["iOS", "android"],
  "windows": ["main"],
  "permissions": [
    "core:default",
    "barcode-scanner:allow-scan"
  ]
}

A cross-platform app with these two files loads only the permissions relevant to the target during the build. The desktop binary never sees barcode-scanner permissions; the mobile binary never sees shell.

Common Permission Mistakes and How to Catch Them

Several mistakes appear repeatedly in Tauri v2 projects. Knowing them in advance saves hours of debugging.

Forgetting to Add a Permission for a New Plugin

You install a plugin with cargo add, call its API from the frontend, and nothing happens. No error in the console, or a generic "permission denied" message. The cause is that the plugin’s permission identifier was never added to any capability file.

No compile-time warning for missing permissions:

Tauri does not check at build time whether your frontend imports a plugin whose permission is missing. The failure is entirely runtime. After adding any plugin, immediately add its permission to the relevant capability file, even if you have not written the frontend code yet.

Using the Wrong Scope for Absolute Paths

The file system plugin’s scope patterns are relative to a base directory unless you use an absolute path prefix like / or a variable like $HOME. A scope of "Documents/*" without a base will not match what you expect. Always verify with actual file paths during development.

Granting a Permission to the Wrong Window

A capability with "windows": ["settings"] and a permission fs:allow-read-text-file does not make that permission available in the main window. If the frontend code in the main window calls the file read API, it will fail. Match windows by their label exactly — case-sensitive — as defined in tauri.conf.json.

Accidentally Inheriting Broad Default Permissions

Some plugins ship with a :default permission that includes scopes you might not want. For example, fs:default includes a scope allowing access to the app’s data directory and the home directory. If your app only needs the app data directory, you have inadvertently granted access to the user’s entire home directory. Review what each :default actually grants before using it.

Copying Capability Files Between Projects Without Review

Every project has different needs. A capability file from a photo editor that grants fs:scope-home makes sense there, but in a calculator app, it is a security hole. Write your capability files for the current project, not from a template.

A quick audit you can do right now:

Open every .json file in src-tauri/capabilities/. For each permission, ask: "Does this window actually call this API?" If the answer is no, remove it. The app will still build. If it breaks at runtime, you will learn what actually needed it — and you can add it back, but only after the failure proves it is necessary.

Test Your Permissions by Denying First

The fastest way to verify that your capability configuration is minimal is to start with an empty permissions array and add back only what is needed to make the app function. This is the reverse of the typical workflow and is tedious, but it surfaces every single implicit dependency.

A more practical version of this is to create a separate "restricted" capability that applies to a test window. Run that window through your CI pipeline or a manual test checklist. If any feature breaks because a permission is missing, add it, but document why it is there. Over time, this process builds a capability file that is provably as small as it can be.

Maintain a Written List of Your App's Permissions and Their Justifications

For small personal projects this is overkill. For an app that handles user data, integrates with the file system, or will be maintained by multiple people, a short document in the project repository pays for itself. It can be as simple as a markdown table:

PermissionWindowReason
core:defaultmain, settingsIPC, events, basic window control
fs:allow-read-text-file (scoped to $APPDATA/my-app/notes)mainLoad user notes on startup
dialog:allow-openmainLet user pick a file to import

When someone audits the code or reviews a PR that touches permissions, the table tells them immediately whether the change is intentional or a drift.

Summary

The permission system in Tauri v2 is a precise instrument. Used well, it limits the blast radius of a frontend compromise to exactly the set of commands and paths you intended. The difference between a secure configuration and a dangerous one is rarely the presence or absence of a single permission; it is the accumulation of small decisions — using a wildcard instead of a scope, granting a permission to all windows because it was easier, leaving a dev-only remote URL in the production build.

One practice above all others prevents the majority of permission problems: after every change to a capability file, ask why each line is there. If you cannot produce a concrete, feature-driven reason, remove it. The app will tell you if it was actually needed, and you can add it back with the justification now attached.