Designing Tauri Capability Permissions: A Minimal-Trust Architecture Note
An architecture note on Tauri 2 capabilities: treat the WebView as untrusted, grant per-window scoped permissions, validate everything crossing IPC, and verify default-deny with negative tests.
01 Aug 2025, 02:52 UTC

If your desktop app renders a web UI in a WebView but needs filesystem, shell, or network access, the dangerous default is to give the frontend everything. Tauri 2's capability system exists precisely to prevent that: the WebView is treated as untrusted code, and every OS-touching API must be explicitly granted through a capability manifest before the Rust core will execute it. This note walks through the requirements, the smallest design that satisfies them, where the trust boundary actually sits, and how to check the design is holding.
Requirements
The assumed setup is a single-window Tauri 2 application (syntax differs from Tauri 1 — verify against your installed version before copying anything here) where:
- The UI is HTML/JS running in a WebView, possibly bundling third-party frontend dependencies you do not fully audit.
- OS access (read a config file, shell out to a tool, call an internal HTTP service) must live in Rust.
- Frontend code should get the minimum privilege needed per window, with everything else denied by default.
The motivating threat is not a malicious user at the keyboard — it is a compromised npm dependency or injected script in the WebView trying to reach fs, shell, or http APIs it was never meant to touch.
The smallest suitable design
The minimal design has three parts:
- One capability file per window privilege level. Capabilities live in
src-tauri/capabilities/as JSON (or TOML) files. Each names the windows it applies to and lists the permissions granted. - Explicit scopes, not wildcard permissions. Prefer
fs:allow-read-filewith a path scope overfs:defaultif the default set is broader than you need. - Rust commands as the only privileged surface. Anything sensitive goes behind a
#[tauri::command]handler; the frontend calls it viainvoke(), and the capability manifest decides whether the call is even delivered.
A concrete capability granting a window labeled main read access to one config directory and nothing else:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-window",
"description": "Least-privilege grants for the main window",
"windows": ["main"],
"permissions": [
"core:default",
{
"identifier": "fs:allow-read-file",
"allow": [{ "path": "$APPCONFIG/**" }]
},
"fs:allow-appconfig-read-recursive"
]
}Run this from your editor, not a terminal — it is a manifest file, not a command. The $APPCONFIG variable is resolved by Tauri to the platform config directory; using it avoids baking absolute paths into the manifest. The risk to watch: a scope like $HOME/** technically "works" and silently defeats the point of the exercise.
On the Rust side, register only the commands you intend to expose:
#[tauri::command]
async fn load_profile(app: tauri::AppHandle) -> Result {
// validate and resolve the path server-side; never trust a raw path from the frontend
todo!()
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![load_profile])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}Trust and data boundaries
The trust boundary is the IPC channel between the WebView and the Rust core. Everything on the WebView side — including your own bundled JS after a supply-chain compromise — is untrusted input. Two consequences:
- Capabilities gate delivery, not correctness. A capability decides whether
invoke('load_profile')reaches Rust at all. It does not validate the arguments. Path strings, URLs, and shell arguments crossing IPC must be validated in the Rust handler before use. - Origins matter. Assets served through Tauri's custom protocol are subject to capability checks. Content loaded from a remote origin behaves differently and can sidestep assumptions you made about which code the manifest constrains. If any window loads remote content, treat that window's capability set as exposed to the remote server and strip it to near zero.
Operational checks
These checks run locally and in CI; none require special permissions beyond a normal dev build.
- Manifest review at build time. Tauri compiles capabilities into the app; a permission typo fails the build rather than silently granting nothing. Treat build warnings about unknown permissions as errors in CI.
- Negative testing. From the frontend devtools console, call an API you did not grant, e.g.
await window.__TAURI__.fs.writeFile('/tmp/x', 'x'), and confirm it is rejected. Do the same for aninvoke()of a command whose permission you removed. A rejection confirms default-deny is actually in effect; a success means your manifest is broader than you think. - Denied-call logging in development. During development, watch the Rust console output for capability denial messages when exercising the UI. Every denial in normal use is either a missing scope (fix the manifest) or unexpected frontend behavior (investigate the frontend).
- Diff the manifest in review. Capability files are small; require that any PR touching them includes a justification. Permission creep happens one innocent line at a time.
Failure modes
- Over-privileged scope leaks access. Granting
fs:allow-read-filewith$HOME/**because a narrower path "didn't work" hands any injected script the user's documents. Diagnose the path resolution instead of widening the scope. - Scope mismatch causes runtime denials. The app works on the developer's machine but fails in production because a path variable resolves differently per platform. Reproduce with a production build, not
tauri dev. - Plugin code bypasses assumptions. Adding a plugin (e.g. shell, http) pulls in its own permission set. Granting a plugin's
defaultpermission may enable far more than the one method you wanted — read what the default set contains before using it. - Version drift. Capability identifiers and scope syntax changed between Tauri 1 and 2. A manifest copied from an older tutorial may fail to compile or, worse, compile against different semantics. Pin the schema reference and check identifiers against your installed version's docs.
Conditions that would change the design
The single-capability, single-window design stops being sufficient when:
- Multiple windows need different privilege levels. Split capabilities per window label rather than unioning everything into one file — a settings window that shells out should not share grants with a window rendering remote content.
- Grants must change at runtime. Static manifests cannot express "the user just approved this one file." That requirement pushes you toward scoped file dialogs or restructuring so the Rust side holds the handle, not the frontend.
- The frontend becomes partially remote. Any remote origin in a privileged window collapses the trust model; isolate remote content into its own minimal-capability window first.
The practical verification loop is short: inspect the capability files, run a production build, attempt one denied call from devtools, and confirm the rejection. If all four pass, least privilege is real rather than aspirational.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.