Sik.limited Logo

Tauri v2 permission design: Configuring capability/permission/scope with minimal permissions

In Tauri v2, we divide the trust boundary between WebView, Rust, and operating system API into capability/permission/scope, and summarize how to minimize file, window, and remote content permissions.

Sik ·

Permission settings are a map of trust boundaries, not a list of features

Setting permissions in Tauri v2 initially looks like adding strings to a JSON file. If you want to open a file, put fs, if you want to manipulate a window, put window, if you want to open an external link, put shell, and so on. The smaller the app, the more natural it becomes to choose “let’s put in the defaults and clean it up later.”

In a desktop app, however, this configuration is not merely an option: it defines how far frontend code running in a WebView can reach into operating-system functionality. In other words, capability·permission·scope is not a capability checklist but a map between a low-trust UI and a higher-privilege system.

Tauri's basic composition is clear. Rust core and plugins have access to system resources, and WebView requests its functionality only through exposed IPC channels. If your frontend is affected by XSS, weak dependencies, untrusted HTML, or invalid external content, you need to narrow down what's possible from the start to reduce your losses.

This article breaks down the three words of Tauri v2 into working language. Capability describes “which window,” permission describes “what command,” and scope describes “what argument range.” While the previous SvelteKit + Tauri v2 Getting Started Guide dealt with how to launch an app, here we design how far the app can move on the user's computer. The difference in security models between Tauri and Electron can be seen in SvelteKit desktop app comparison article, and the boundary between mobile and desktop can be seen in Capacitor and Tauri selection criteria.


Separate capability, permission, and scope into one sentence

The reason these three concepts are confusing is because they appear together in the actual configuration file. But if you ask different questions, the roles become clearer.

  • capability: Can this window or WebView have this set of capabilities?
  • permission: Can the frontend call this command?
  • scope: Even if the command is allowed, is it possible only on what path/URL/value?

Let's take a document editing app as an example. The main editing window should read and save Markdown files in the app data folder. You only need to change the theme in the settings window, and the login window only shows the external authentication screen. As soon as you give these three windows the same permissions, the product requirement that “the login window does not need to write files” disappears from the security settings.

At this time, capability is a layer that allows only the main window to have file permissions. Permission is a layer that allows commands such as read_text_file and write_text_file. scope is a layer that truncates the scope so that the command only works under $APPDATA/notes/*.md.

WebView(메인 편집 창)
  └─ capability: main-editor
       ├─ permission: fs:allow-read-text-file
       ├─ permission: fs:allow-write-text-file
       └─ scope: $APPDATA/notes/*.md

WebView(로그인 창)
  └─ capability: auth-window
       └─ permission: 필요한 창 제어만

The important thing about this structure is that the capabilities are linked to the window label, not the window title visible to the user. The title can be translated or changed, but the label must be a fixed ID that identifies the security boundary. It is better to choose a label that reveals the role, such as main, settings, or auth, and not create a label based on user input.

Why you should start from the smallest functional unit

Rather than broadly granting permissions and then reducing them, it is much better to check and add necessary functions one by one. The reason is simple. Broad permissions make normal behavior faster, but later make it difficult to answer the question “Why are these permissions here?” As your app grows, the person who created that file leaves, and no one is sure what a single line of default contains.

First, write the user actions as sentences.

  1. Save new notes only in the main window.
  2. The preferences window does not handle local files.
  3. Import occurs only when the user explicitly selects a file.
  4. External links are opened in the operating system browser, not in the WebView within the app.
  5. Secret values ​​for authentication and payment are verified on the server rather than being added to the app.

Then attach an API and path to each action. “Handle files” is far too broad a requirement. If the real need is “save Markdown in the Notes folder,” you need one write command and one Notes-folder location—not the entire file system. The quality of permission design comes not from the number of configuration lines, but from how precisely product requirements are translated into security questions.

Capabilities are divided into each window, and duplicate application is done consciously.

Tauri v2 capability files are usually placed in src-tauri/capabilities. Capabilities within a directory are activated by default, so dividing a file does not automatically isolate it. If the same window or WebView is included in more than one capability, the capabilities are combined.

This is especially important when creating a “common permissions file”. If you put all windows in base.json and the main window back in main-editor.json, the main window will receive the permissions of both files. Although the names are separated, the actual boundaries are combined. It is safer to place only the read-only functions that are truly necessary for all windows in common files, and leave native access to role-specific capabilities.

// src-tauri/capabilities/main-editor.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "main-editor",
  "description": "메인 편집 창에서 노트 파일을 제한적으로 다루는 권한",
  "windows": ["main"],
  "permissions": [
    "core:app:default",
    "core:event:default",
    "core:window:default",
    "dialog:allow-open",
    {
      "identifier": "fs:allow-read-text-file",
      "allow": [{ "path": "$APPDATA/notes/*.md" }]
    },
    {
      "identifier": "fs:allow-write-text-file",
      "allow": [{ "path": "$APPDATA/notes/*.md" }]
    }
  ]
}
// src-tauri/capabilities/settings.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "settings-window",
  "description": "환경설정 창의 최소 창 제어 권한",
  "windows": ["settings"],
  "permissions": [
    "core:window:default",
    "core:app:default"
  ]
}

The example restricts reads and writes to the same path. The reason I didn't include fs:default here is to avoid broadening the permissions without checking what the default permission set actually contains. When you add a plugin using Tauri CLI, the default permission of the plugin may be reflected in the configuration, so you need to make a habit of looking at the capability file and the created schema again after adding a new plugin.

Path variables such as $APPDATA are interpreted as app data directories depending on the operating system. It is more portable than writing the user's home folder directly as a string, and is also better for distinguishing between app-specific data and personal files. However, glob is only a convenience feature and not a product policy. If you only need “Markdown files managed by the Notes app” rather than “all Markdown files,” it is safer to have the app own the folder itself.


permission is read as the scope of influence, not the name of the command

Permission allows the frontend to call specific IPC commands. Even within the same plugin, reading, writing, deleting, and directory enumeration have different effects. So, you should choose only what you need.

A common mistake with Files apps is the idea that “you have to open the whole fs to be able to read and save.” In reality, the flow for reading files for the user to open is also different from the flow for saving drafts inside the app. The former requires dialog and read permission, and the latter only requires write permission under app data. If there is no delete function, delete permission is not provided. If you only need to export, separately review the functions for overwriting existing files and creating new files.

import { open } from '@tauri-apps/plugin-dialog';
import { readTextFile } from '@tauri-apps/plugin-fs';

export async function selectMarkdownForImport(): Promise<string | null> {
  const selected = await open({
    directory: false,
    multiple: false,
    filters: [{ name: 'Markdown', extensions: ['md'] }]
  });

  if (typeof selected !== 'string') return null;
  return readTextFile(selected);
}

Before adding fs:default this code doesn't work, check two things: First, is the path returned by the file selection dialog included in the current scope? Second, does the product really need to read files directly from the user's arbitrary location? In the case of a simple import, it is advantageous for both the permission model and backup policy to verify the selected file and then copy it to the app data directory, and then edit it only in the app-owned folder.

The same principle applies to external links. shell:allow-open is convenient for opening documents in the browser, but if the UI passes arbitrary strings as URLs, it creates UX problems bordering on phishing and unintended protocol execution. It is best to limit URLs to a list of constants or server-verified HTTPS URLs, and not pass user-entered values ​​directly to native calls.

scope is the second question after permission

Scope limits the actual arguments even after permission is turned on. In file system plugins, the path glob is the most familiar example. When allow and deny are used together, deny takes precedence, so if you really need a wide allow, you can specify sensitive subpaths as deny.

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "export-report",
  "windows": ["main"],
  "permissions": [
    {
      "identifier": "fs:allow-write-text-file",
      "allow": [{ "path": "$APPDATA/reports/*.csv" }],
      "deny": [{ "path": "$APPDATA/reports/private/*" }]
    }
  ]
}

However, this example should not be read as a prescription that “it is safe to use a lot of allow and deny.” The best scope is usually a small, simple owning directory rather than a complex exception list. Rather than continuing to add reports/private as an exception later, it's easier to review if you put your export target and sensitive data in different roots from the beginning.

Nor does scope automatically solve all problems. A plugin or application command must interpret and enforce scope. In particular, when creating your own Rust commands, you should not assume, “I received a string from the frontend, so the scope will block it on its own.” Inputs such as file names, URLs, and record IDs must also be validated against domain rules on the Rust side.

Homemade Rust commands are treated as separate product APIs

Tauri commands are an IPC API between the frontend and Rust. Just because it is called only in the UI, it would be difficult to treat it like an internal function. Argument types, maximum length, file name rules, permission checks, and error messages should be designed like a public API.

For example, when saving a note, it is better to send only the note name and contents and have Rust create the destination path within the app data folder, rather than letting the frontend send an arbitrary path for Rust to use as-is.

use std::fs;
use tauri::Manager;

#[tauri::command]
fn save_note(
    app: tauri::AppHandle,
    note_id: String,
    markdown: String,
) -> Result<(), String> {
    if note_id.is_empty()
        || note_id.len() > 80
        || !note_id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
    {
        return Err("유효하지 않은 노트 식별자입니다.".into());
    }

    if markdown.len() > 2_000_000 {
        return Err("노트 크기가 허용 범위를 넘었습니다.".into());
    }

    let notes_dir = app
        .path()
        .app_data_dir()
        .map_err(|error| error.to_string())?
        .join("notes");

    fs::create_dir_all(&notes_dir).map_err(|error| error.to_string())?;
    fs::write(notes_dir.join(format!("{note_id}.md")), markdown)
        .map_err(|error| error.to_string())
}

Since this command does not receive a path as an argument, it reduces the risk of treating path-traversal strings such as ../ as file paths. Of course, this is only a pattern. If your editor handles large files or binary attachments, or must save to the original location, its needs will differ. Even then, the principle is the same: do not pass arguments received from the frontend directly to an operating-system API merely for convenience. Narrow them to product rules on the Rust side.

If you want to limit the application's own commands to capabilities, you can also specify the command manifest at the build stage. Registered app commands can be used by all windows and WebViews by default, so it is worth considering for sensitive commands.

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

And declare permissions for the application in src-tauri/permissions.

# src-tauri/permissions/notes.toml
[[permission]]
identifier = "save-note"
description = "앱 데이터 디렉터리에 검증된 노트를 저장한다."
commands.allow = ["save_note"]
// src-tauri/capabilities/main-editor.json의 permissions 일부
[
  "save-note"
]

Here, the permission name and command name do not need to be the same string. permission is the name of the human-reviewable permission policy, and command is the actual IPC function. It's better to give it a name that reads “which window can perform which product behavior.”


Local permissions are not given by default to remote content.

There is a different level of trust between app code inside a bundle and content coming from the Internet. Tauri's remote API access is not open by default, and you must separately configure capabilities to expose native commands to remote URLs. Rather than just turning it on for convenience, this is a feature that asks whether the remote page really needs to call the native function.

You can display web-based help, OAuth screens, and remote dashboards within the app WebView. However, in most cases, the screen does not require permissions such as file access, clipboard writing, local database, or window creation. If your app requires remote content, choose one of the following first:

  • The remote screen opens in the system browser and is separated from app permissions.
  • Displays the remote screen in a WebView with a separate label and does not attach local capabilities.
  • Only expose one or two really necessary commands to the correct HTTPS URL pattern, and separately test differences by iframe, redirection, and platform.

In particular, the documentation also states a limitation that Linux and Android may not be able to distinguish between iframe requests and requests for the window itself. This is why you should not end with the conclusion, “The domain is allowlisted, so it is safe.” Features that include remote URLs should be reviewed together with their functional design, CSP, authentication redirect, WebView behavior, and permissions design.

If you are considering mobile, separate platforms first

Tauri v2 is aimed at mobile as well as desktop. However, applying the same capabilities to all platforms does not mean code reuse. The capabilities of plug-ins that exist only on the desktop, such as global shortcuts, and functions that are close to mobile, such as NFC and biometric authentication, must be divided by platform.

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

This separation isn't just a way to avoid build errors. The same product feature can be a keyboard shortcut on desktop or a completely different operating system behavior like biometric authentication or system share sheets on mobile. When comparing the options for running a mobile WebView, including Capacitor, the more important question is how differently permissions and user expectations should be designed than how much front-end code is shared.

The order in which permission errors are resolved determines permission quality

The most common errors encountered in practice are simple. The call was made, but rejection indicated that it was not allowed. At this time, if you follow the steps below, you can narrow down the cause and avoid expanding authority unnecessarily.

  1. Check the creation schema and official documentation to see what permission identifier the called API actually requires.
  2. Check whether the label of the calling window is correctly included in windows of the corresponding capability.
  3. Check whether multiple capabilities are applied to the same window and the permissions are not combined unexpectedly.
  4. Try to separate the API’s own permission and argument scope. Check whether the command is blocked or the path/URL is out of scope.
  5. Check both Rust and the front end to ensure that the file path, URL, and command arguments pass the product rules.
  6. Check not only in development mode but also in the actual bundle. In particular, remote URLs and platform-specific WebViews should be viewed in the package results.

This process may seem frustrating, but it also allows you to break down your app's requests into smaller sentences each time you encounter a permission error. Setting up and testing becomes much clearer if the question is changed from "Should I open the file system" to "Should I save a draft of app data only in the main window?"

Pre-deployment least privilege checklist

Before release, take the time to read capability files as if they were code review subjects. If you can answer “yes” to the following questions, you’re a good starting point.

  • Are the windows of all capabilities associated with a fixed label rather than a role visible to the user?
  • Can you explain the reason for attaching multiple capabilities to one window and the permissions that are combined?
  • Have you checked the inclusion range for each line where you added the default permission set?
  • Are reading, writing, deleting, executing, and opening an external URL treated as different impacts?
  • Is the file scope limited to the app-owned directory or an absolutely necessary user-selected path rather than the entire home?
  • Are the scope’s deny exceptions continuing to increase? So, is it possible to change the directory structure to something smaller?
  • Doesn't the Rust command verify the path, URL, identifier, and size, and pass the UI input as is to the system call?
  • Have you opened the local native API on a remote page or iframe?
  • Have you separated desktop-only permissions and mobile-only permissions into platforms?
  • Have you tested per-window permissions in both development mode and release bundle?

Security is not complete with permission files alone

Although capability·permission·scope is powerful, it does not magically make an entire app secure. If the Rust core trusts the wrong input, the scope is too broad, or there are vulnerabilities and supply chain issues in the WebView itself, it cannot be resolved through configuration alone. So, it is more accurate to view this model not as “one security feature,” but as a basic structure that reduces the scope of impact in the event of an incident.

A good Tauri app is not one that has a lot of permissions, but one that has a clear reason for permissions compared to user functionality. The main editing window can save notes, but the login window cannot. Files can be read, but only in folders owned by the app, and requests from the WebView are verified again by Rust. If you keep this much in mind, the border between the UI and the operating system becomes much more tractable.

Tauri v2's least-privilege design is not a process that slows down development. It is a product design that makes it clear “which window, which command, and for which data” each time a feature is added. Settings that can answer that question can be maintained over time, while settings that cannot answer that question will one day become a debt named default.

Sources

Latest posts