Security Recommendations
Essential security practices for developing and using Tauri v2 plugins including capability-based permissions, CSP, input validation, and secure IPC
Tauri’s architecture separates an application into two trust domains: the Rust backend, which has unrestricted access to the operating system, and the WebView frontend, which runs JavaScript and HTML. Communication between them travels through a defined IPC (Inter-Process Communication) layer. Every security recommendation for plugin development starts from this separation. If a plugin exposes a command without considering what the frontend should actually be allowed to do, it creates a path for untrusted code to reach system resources it was never meant to touch. See Understanding the Security Model for the broader picture.
Understanding Tauri’s Trust Boundaries
The Rust side—your plugin’s native code—can read any file, spawn any process, or call any system API. The WebView side can only do what Tauri explicitly permits through commands, events, and capabilities. A trust boundary sits at every IPC call: the frontend asks, the backend decides.
A plugin author’s job is to build that decision logic carefully. Commands should validate input, check permissions, and return only the data the frontend needs. Capabilities define which windows can call which commands. Together, these mechanisms create a sandbox around the WebView even though the underlying engine could theoretically reach much further.
Not Just Official Plugins:
This model applies equally to community and private plugins. Any command you register through a plugin’s invoke_handler becomes a new IPC endpoint that needs its own access control.
Principle of Least Privilege with Capabilities
Capabilities in Tauri v2 are JSON files that list exactly which permissions a window gets. A window without the right capability entry cannot execute the corresponding command, regardless of what the JavaScript code asks for. Files live under src-tauri/capabilities/.
Always grant the minimum set of permissions a window actually needs. If a window only displays data and never opens files, it should not receive any filesystem permission. If a plugin offers multiple commands, allow only the ones the window uses.
A capability file lives under src-tauri/capabilities/ and is referenced by the app.security.capabilities array in tauri.conf.json. Below is a minimal example that allows only the core defaults and a single opener:allow-open-url command scoped to a specific domain.
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-capability",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
{
"identifier": "opener:allow-open-url",
"allow": [
{ "url": "https://docs.myapp.com/*" }
]
}
]
}
This pattern scales to every plugin. The allow array uses glob syntax to restrict the scope further. Without it, opener:allow-open-url would let the frontend open any URL—a risk if user-controlled strings reach that call.
Wildcard Permissions Are Dangerous:
Using a blanket "fs:default" or "shell:allow-open" without a scope gives the frontend broad access. A single XSS vulnerability could then read arbitrary files or launch arbitrary programs. Always scope permissions to specific paths or commands.
Defining Granular Permissions for Plugins
Official Tauri plugins provide granular permission identifiers like fs:allow-read-text-file, shell:allow-execute, and dialog:allow-open. Prefer these over the :default shorthand when the window only needs a subset of the plugin’s functionality.
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-capability",
"description": "Main window with scoped file read access",
"windows": ["main"],
"permissions": [
"core:default",
{
"identifier": "fs:allow-read-text-file",
"allow": [
{ "path": "$APPDATA/config/*.json" }
]
}
]
}
A window that tries to read a file outside $APPDATA/config/ will receive a permission denied error. This protection is enforced by Tauri at runtime before your command handler even runs.
Permissions for Custom Plugins
When you build a custom plugin, you must define the permissions it exposes. Tauri provides a build-time code generator for this purpose. Your plugin’s build.rs script calls tauri_plugin::Builder to create the permission files automatically.
fn main() {
tauri_plugin::Builder::new("myplugin")
.commands(&["do_something", "read_config"])
.build();
}
After a build, the plugin will have a permissions/ directory with files like default.toml, allow-do_something.toml, and deny-do_something.toml. Applications that use your plugin can then reference "myplugin:allow-do_something" in their capability JSON.
Inside the plugin’s Rust code, you can further check permissions using app.verify_permission(), but the basic allow/deny gating happens automatically if you’ve set up the permissions correctly.
Verifying Custom Permissions:
Run cargo build in your plugin crate and check the generated permissions/ folder. When the app starts with a capability that includes "myplugin:allow-do_something", your command will be callable. If you intentionally omit it, the frontend will see a permission denied error—confirmation that the system works.
Content Security Policy
Content Security Policy (CSP) is an HTTP header that tells the WebView which sources of scripts, styles, images, and connections are allowed. Tauri lets you define the CSP directly in tauri.conf.json under app.security.csp.
A strict CSP prevents injected scripts (the result of an XSS attack) from executing or exfiltrating data. For a Tauri app that loads all assets from the local bundle and communicates only with the Tauri IPC bridge, you can start with this:
{
"app": {
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost"
}
}
}
default-src 'self'blocks all external content by default.script-src 'self'means only scripts bundled with your app can run.style-src 'self' 'unsafe-inline'is often needed because many frontend frameworks inject inline styles at runtime. Remove'unsafe-inline'if your build tool generates separate CSS files and avoids runtime style injection.connect-src 'self' ipc: http://ipc.localhostallows the frontend to reach the Tauri IPC bridge. If your app calls an external API, you must add that domain here.
Never Add 'unsafe-eval':
'unsafe-eval' allows JavaScript’s eval(), new Function(), and similar constructs. This effectively disables a major part of the CSP’s protection against code injection. Avoid it. If a library requires it, look for an alternative.
Every time you add a new external resource—a CDN for fonts, an analytics endpoint, an API server—update the CSP to reflect only that new source. Never widen the policy to * just to get things working quickly.
Input Validation and Sanitization
Commands receive data from the frontend, which runs in a less trusted environment. Treat every argument as potentially malicious. Validate types, lengths, allowed characters, and value ranges before using the input in filesystem operations, shell commands, or database queries.
Here is an example of a custom plugin command that reads a configuration file after validating the supplied path:
#[tauri::command]
fn read_config(path: String) -> Result<String, String> {
// Reject empty paths and paths containing '..'
if path.is_empty() || path.contains("..") {
return Err("Invalid path".into());
}
// Only allow files inside a specific directory
let base = std::path::Path::new("/etc/myapp");
let resolved = base.join(&path);
if !resolved.starts_with(base) {
return Err("Path traversal denied".into());
}
std::fs::read_to_string(&resolved).map_err(|e| format!("Read failed: {}", e))
}
This validation prevents path traversal attacks even if a frontend bug or an attacker-controlled input tries to supply ../../../etc/shadow. The command also returns a generic error message to avoid leaking filesystem structure details.
Validate on the Rust Side Always:
Frontend-side validation is a user experience convenience, not a security boundary. A determined attacker can bypass it by calling commands directly through the developer console or a modified WebView. Only Rust-side checks matter for security.
Error Handling and Information Leakage
Error messages returned to the frontend should tell the user what went wrong without exposing internal paths, stack traces, or database schemas. A Result<String, String> with a short description is usually sufficient. Log the full error on the Rust side for debugging but keep the IPC response opaque.
#[tauri::command]
fn secure_operation() -> Result<(), String> {
some_fallible_function().map_err(|e| {
log::error!("secure_operation failed: {:?}", e);
"Operation failed".to_string()
})
}
This pattern prevents an attacker from learning the internal structure of your backend through deliberate error triggers.
Protecting Sensitive Data
Never store secrets—API keys, tokens, passwords—in the frontend’s JavaScript scope, localStorage, or sessionStorage. The frontend can be inspected and modified at runtime. Instead, keep sensitive data in Rust-managed state and expose only controlled operations.
If a plugin needs an API key to call an external service, hold the key in a Rust struct managed by Tauri’s state system, and provide a command that uses it internally.
use std::sync::Mutex;
use tauri::Manager;
struct ApiKey(String);
fn main() {
tauri::Builder::default()
.setup(|app| {
app.manage(ApiKey(std::env::var("API_KEY").unwrap()));
Ok(())
})
.invoke_handler(tauri::generate_handler![make_authenticated_request])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[tauri::command]
fn make_authenticated_request(state: tauri::State<ApiKey>, url: String) -> Result<String, String> {
let client = reqwest::blocking::Client::new();
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", state.0))
.send()
.map_err(|e| e.to_string())?;
response.text().map_err(|e| e.to_string())
}
The frontend never sees the API key; it only requests the data and receives the result.
Handling Remote Content and URLs
If a plugin opens files or URLs (like the opener or shell plugins), scope the allowed targets aggressively. An opener:allow-open-url permission with no allow list means the frontend can open any URL, including file:/// paths or malicious websites. Always provide an explicit allow list.
{
"identifier": "opener:allow-open-url",
"allow": [
{ "url": "https://help.myapp.com/*" },
{ "url": "mailto:*" }
]
}
On Linux and Android, Tauri cannot reliably distinguish between top-level window navigation and iframe requests for remote URL access. If you enable remote URLs in a capability, be aware that an embedded iframe from a third-party source could potentially trigger those commands. Limit remote URL access only to windows and sources you control.
Auditing Plugin Dependencies
A plugin’s security depends not only on its own code but also on its Cargo dependencies. Run cargo audit in your plugin project (and your application’s src-tauri directory) regularly to detect known vulnerabilities. Add it to your CI pipeline.
cargo install cargo-audit
cargo audit
Keep all dependencies updated, and prefer crates that are actively maintained and widely reviewed. The official Tauri plugin workspace (tauri-apps/plugins-workspace) is a good source of reference for dependency choices.
Verifying Your Security Setup
After configuring capabilities, CSP, and validation, verify that the protections actually work:
- Remove a permission from a capability file and confirm the frontend command call fails with a permission denial.
- Inject a
<script>alert(1)</script>string through a UI field and confirm it does not execute (CSP should block inline scripts). - Attempt to call a command with a path traversal string like
../../etc/passwdand verify it returns an error, not file contents.
These manual tests confirm that your security layers are active and correctly configured. Automate them where possible as part of your end-to-end testing.
Summary
Tauri v2 gives you the primitives—capabilities, scoped permissions, CSP, and a strong IPC boundary—to build plugins that are secure by default. The responsibility is to use them deliberately.
The two most impactful practices are: (1) grant every window the minimum capability set it truly needs, with explicit scopes, and (2) validate all command inputs on the Rust side as if they came from an adversary. Combine these with a strict CSP, careful error messages, and secrets kept in the backend, and your plugin will resist a wide range of attacks even if the frontend is compromised.