Read OSS

Tauri's Permission System: Capabilities, ACL, and the Security Boundary

Advanced

Prerequisites

  • Articles 1-3: Architecture, Lifecycle, and IPC
  • Understanding of access control concepts (ACL, capabilities)
  • Content Security Policy (CSP) basics
  • Familiarity with web security concepts (XSS, prototype pollution)

Tauri's Permission System: Capabilities, ACL, and the Security Boundary

Tauri embeds a webview — an environment where untrusted content can execute. This means every IPC call is a potential attack surface. Tauri's answer is a three-tier permission system that enforces least-privilege access at both compile time and runtime. This article explains how it works from the ground up.

The Three-Tier Security Model

Tauri's security model has three distinct layers:

flowchart TB
    subgraph "Tier 1: Capabilities"
        CAP["Capability<br/>'main-user-files'<br/>windows: ['main']"]
    end
    subgraph "Tier 2: Permissions"
        PERM1["fs:allow-read-file"]
        PERM2["dialog:open"]
        PERM3["fs:allow-write-text-file"]
    end
    subgraph "Tier 3: Scopes"
        SCOPE["allow: [{path: '$HOME/docs'}]<br/>deny: [{path: '$HOME/docs/.secret'}]"]
    end

    CAP --> PERM1
    CAP --> PERM2
    CAP --> PERM3
    PERM3 --> SCOPE

Capabilities assign permissions to specific windows or webviews. A capability has an identifier, a list of window/webview label patterns, and a list of permissions. Only windows matching the patterns get the listed permissions. The Capability struct also supports platform targeting — you can have capabilities that only apply on macOS or Windows.

Permissions gate individual commands. Each permission maps to one or more IPC commands that it allows or denies. Plugins define their own permissions (e.g., fs:allow-read-file), and permission sets group multiple permissions under a single name (e.g., fs:default might include common read operations).

Scopes provide fine-grained constraints on what a permitted command can access. For example, the fs:allow-read-file permission might be scoped to only files under $HOME/Documents. Scopes are defined as allow/deny value lists that plugins interpret according to their own logic.

The ACL module root at crates/tauri-utils/src/acl/mod.rs sets up the foundation with important constants like APP_ACL_KEY (used for app-defined permissions) and ACL_MANIFESTS_FILE_NAME (the build artifact containing compiled manifests).

Tip: Think of capabilities as "who gets access," permissions as "access to what commands," and scopes as "constrained to which resources." A window with no matching capability has zero IPC access — it can't even call core commands.

Build-Time Resolution: From Capabilities to Resolved Commands

The security model is primarily resolved at compile time. During the build.rs phase, tauri-build's ACL module performs a multi-step resolution:

flowchart LR
    CAP_FILES["Capability files<br/>(JSON/TOML)"] --> PARSE["Parse capabilities"]
    MANIFESTS["Plugin ACL manifests"] --> MERGE["Merge permissions"]
    PARSE --> MERGE
    MERGE --> RESOLVE["Resolved::resolve()"]
    RESOLVE --> BTREE["BTreeMap<String, Vec<ResolvedCommand>>"]
    BTREE --> EMBED["Embedded in binary<br/>via generate_context!"]
  1. Discover capabilities — scans the src-tauri/capabilities/ directory and inline capability entries in tauri.conf.json
  2. Load plugin manifests — reads the ACL manifests that each plugin generated during its own build
  3. Resolve permissions — expands permission set references, merges scope values, and maps each command to its resolved access rules
  4. Build BTree lookup structures — the final output is a Resolved struct containing allowed_commands and denied_commands as BTreeMap<String, Vec<ResolvedCommand>>:
pub struct Resolved {
    pub has_app_acl: bool,
    pub allowed_commands: BTreeMap<String, Vec<ResolvedCommand>>,
    pub denied_commands: BTreeMap<String, Vec<ResolvedCommand>>,
    pub command_scope: BTreeMap<ScopeKey, ResolvedScope>,
    pub global_scope: BTreeMap<String, ResolvedScope>,
}

Each ResolvedCommand carries the execution context (local or remote), window/webview label patterns as glob patterns, and an optional scope reference. The BTree structure gives O(log n) command lookups at runtime — fast enough for IPC hot paths.

Runtime Enforcement: RuntimeAuthority

The RuntimeAuthority is the runtime gatekeeper. It's constructed from the build-time Resolved struct and lives inside the AppManager behind a Mutex:

pub struct RuntimeAuthority {
    has_app_acl: bool,
    allowed_commands: BTreeMap<String, Vec<ResolvedCommand>>,
    denied_commands: BTreeMap<String, Vec<ResolvedCommand>>,
    pub(crate) scope_manager: ScopeManager,
}

On every IPC invocation, resolve_access() performs the check:

  1. Denied commands first — if the command is in denied_commands and any entry matches the window/webview labels and origin, access is denied immediately
  2. Allowed commands — looks up the command in allowed_commands, iterates through resolved entries, checks label patterns (using glob matching), and verifies the execution context (Local for app content, Remote { url } for external URLs)
  3. Returns Option<Vec<ResolvedCommand>>Some with the matching resolved commands (including scope info) if allowed, None if denied

The Origin enum distinguishes between local app content and remote URLs, enabling fine-grained control — you might allow a local webview to read files but restrict remote content to only calling a whitelist of safe commands.

When the dynamic-acl feature is enabled, capabilities can also be added at runtime via Manager::add_capability(), which re-resolves the affected command permissions on the fly.

Scopes: Fine-Grained Access Control

Scopes attach structured data to permissions, enabling constraints beyond just "allow or deny this command." The ResolvedScope carries allow and deny value lists:

pub struct ResolvedScope {
    pub allow: Vec<Value>,
    pub deny: Vec<Value>,
}
classDiagram
    class ResolvedCommand {
        +context: ExecutionContext
        +windows: Vec~Pattern~
        +webviews: Vec~Pattern~
        +scope_id: Option~ScopeKey~
    }
    class ResolvedScope {
        +allow: Vec~Value~
        +deny: Vec~Value~
    }
    class ScopeManager {
        +command_scope: BTreeMap
        +global_scope: BTreeMap
        +get_command_scope(resolved) ScopeValue
    }

    ResolvedCommand --> ScopeManager : scope_id references
    ScopeManager --> ResolvedScope : contains

Plugins are responsible for interpreting scope values. For example, the file system plugin checks whether a requested path matches the allow patterns and doesn't match any deny patterns. The scope values are serde_json::Value objects, so each plugin defines its own schema — paths for the FS plugin, URL patterns for HTTP, etc.

Scopes merge across capabilities: if two capabilities both grant fs:allow-read-file with different path scopes, the effective scope is the union of both allow lists. Deny lists are also merged, and deny always takes precedence over allow.

The Isolation Pattern and Invoke Key

Tauri provides two additional security mechanisms beyond the ACL:

The Invoke Key is a random token generated in Builder::new() and injected into every webview via the initialization script. As we saw in Article 3, every IPC request carries this key in the Tauri-Invoke-Key header, and the on_message() handler silently drops requests with an incorrect key. This prevents unauthorized IPC from scripts that somehow bypass CSP — they can't invoke commands without the key.

The Isolation Pattern adds an additional layer of sandboxing. When enabled, Tauri creates a hidden <iframe> loaded from an isolation:// custom protocol. The main webview sends IPC messages to this iframe via postMessage, and the iframe's JavaScript (from scripts/ipc.js) validates and encrypts them before forwarding to the Rust backend. This means even if an attacker achieves XSS in your main webview, they can't directly call IPC — they'd need to also compromise the isolation iframe, which runs code you control.

flowchart LR
    MAIN["Main Webview"] -->|"postMessage"| IFRAME["Isolation iframe<br/>(isolation:// protocol)"]
    IFRAME -->|"validated + encrypted"| IPC["ipc:// protocol"]
    IPC --> RUST["Rust Backend"]
    ATTACKER["XSS Attacker"] -.->|"❌ Can't bypass"| IFRAME

CSP Management and Prototype Pollution Protection

Tauri manages Content Security Policy headers for all content served through the tauri:// protocol. CSP headers are configured in tauri.conf.json and injected into HTML responses. During development, Tauri may modify the CSP to allow connections to the dev server.

For script integrity, Tauri generates nonces for inline <script> tags and adds them to the CSP script-src directive. This allows Tauri's own initialization scripts to execute while blocking injection attacks.

The most surprising security measure is scripts/freeze_prototype.js — a one-liner that runs before any other JavaScript:

Object.freeze(Object.prototype)

This prevents prototype pollution attacks, where an attacker modifies Object.prototype to inject properties into all objects — potentially intercepting IPC callbacks or tampering with the __TAURI_INTERNALS__ bridge. By freezing the prototype before any untrusted code runs, Tauri ensures the IPC infrastructure remains intact.

Tip: If you're building a Tauri app that loads remote content, enable the isolation pattern and define narrow capabilities. The combination of invoke key verification, ACL enforcement, isolation sandboxing, CSP, and prototype freezing creates multiple layers of defense — an attacker would need to break all of them to reach your Rust backend.

In the next article, we'll explore how Tauri's plugin system builds on top of this security infrastructure — and how Tauri itself uses plugins to implement its core functionality.