Security Recommendations
Best practices for hardening your Tauri v2 application configuration to protect users and data
Security in Tauri v2 rests on a simple, powerful idea: the frontend code running in the WebView is not trusted. It might come from any source—your own carefully written React components, a dependency you pulled in six months ago, or, in a worst-case scenario, a script injected by an attacker. The Rust code in the application core, on the other hand, has full access to the operating system. The configuration you write defines exactly what crosses that boundary, and how. Getting it right is the difference between an app that is safe by design and one that hands attackers the keys.
The Core Idea – Least Privilege by Default
Tauri v2’s security model is built around a trust boundary between the WebView frontend and the Rust backend. All communication crosses that boundary through the Inter-Process Communication (IPC) layer, and nothing gets through unless you explicitly allow it through capabilities and permissions. The Native APIs security chapter covers the same boundary from the API side.
Think of it like a reception desk in a secure building. The frontend (a visitor) can only ask for things that are on the approved list. The backend (the building) will only fulfill requests that match the permissions you’ve granted. If the frontend code is ever compromised, an attacker still can’t access the filesystem, run shell commands, or read environment variables—unless you made the mistake of adding those permissions.
This chapter covers the configuration decisions that either strengthen or weaken that boundary. Each section addresses a specific layer of the security model, with concrete examples you can apply directly to your Tauri v2 project that uses React and Vite.
Start with Safe Defaults – What Tauri Gives You
A freshly scaffolded Tauri v2 project already applies several security measures out of the box. The API is only accessible to code bundled with your application; remote URLs cannot call Tauri commands by default. The Content Security Policy, while initially permissive enough for development, establishes a foundation you should tighten before shipping.
However, safe defaults are a starting point, not a guarantee. A default CSP still allows inline styles, which might be necessary for your CSS-in-JS solution but weakens protection. Capabilities are not magically locked down—you must create and maintain them deliberately.
What you should do immediately after creating a project:
- Review the
app.security.cspfield intauri.conf.json. Remove any source that isn’t strictly required. - Inspect every capability file under
src-tauri/capabilities/. Remove example permissions you don’t need. - Never ship with
"permissions": ["core:default"]alone—add only the specific permissions your app actually uses.
Starting from a Good Place:
When you run cargo tauri dev and your app launches without errors, and you haven't yet added any capability files beyond the scaffolded ones, Tauri is operating with minimal surface area. This is exactly the right baseline to build from.
Crafting a Strict Content Security Policy
A Content Security Policy (CSP) is a set of rules the browser (or WebView) uses to decide what resources a page can load and from where. In a Tauri app, the CSP acts as a last line of defense: even if an attacker manages to inject a malicious script through a cross-site scripting vulnerability in your React code, a properly configured CSP will refuse to execute it.
The CSP is configured in tauri.conf.json under app.security.csp. If the field is absent, Tauri falls back to a built-in default, but relying on that default is risky—it may change between versions and may not fit your application’s needs.
A hardened CSP for a typical Tauri v2 app that only loads local assets and makes API calls to a known backend looks like this:
{
"app": {
"security": {
"csp": "default-src 'self'; connect-src 'self' https://api.yourapp.com; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;"
}
}
}
What each directive does here:
default-src 'self'– blocks all resources not served from the app’s own origin (the bundled frontend).connect-src 'self' https://api.yourapp.com– allows network requests only to your own origin and a specific API domain.script-src 'self'– only JavaScript from your own bundled files will execute. No inline scripts, noeval(), no third-party CDNs.style-src 'self' 'unsafe-inline'– allows CSS from your own files plus inline styles, which many React component libraries require. If you can eliminate inline styles, remove'unsafe-inline'.img-src 'self' data: https:– permits images from your bundle, embedded data URIs, and HTTPS sources.
Don't Blindly Copy CSP Templates:
A CSP that is too strict breaks your app silently—the WebView simply refuses to load blocked resources. Start with a slightly more permissive policy, test every view and feature, then progressively lock it down. Using the browser’s DevTools console (when running tauri dev) will show you which resources got blocked, so you can adjust the policy with evidence rather than guesswork.
When your app needs to connect to a backend server during development on http://localhost:3000, you will need to temporarily allow that origin in the connect-src directive. The same applies to WebSocket endpoints, which require a connect-src entry with ws:// or wss://.
A common pitfall is assuming that 'self' includes localhost development servers. It doesn’t. 'self' refers to the origin of the page, which is https://tauri.localhost or a custom protocol. You must explicitly list any development server origins.
Never Ship a CSP with a Development Origin:
Leaving connect-src http://localhost:* in your production CSP exposes a hole an attacker could exploit if they can trick a user into running a local server—or if another application binds to that port. Always keep development-only origins out of the production configuration. Use a separate tauri.conf.json for development, or rely on environment variables to swap the CSP at build time.
Designing Granular Capabilities
Capabilities are the heart of Tauri v2’s permission system. A capability file declares which windows get access to which commands and plugin features. Instead of a single all-or-nothing switch, you create separate files that grant only what each window genuinely needs.
A capability file lives in src-tauri/capabilities/ and looks like this:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-window-capability",
"description": "Permissions for the primary application window",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:allow-open"
]
}
This capability gives the window labeled "main" the default core permissions (which includes things like window management and the event system) plus the ability to open a file dialog. The $schema line enables auto-completion and validation in editors like VS Code, which catches typos and invalid permission names before you ever run the app.
If your application also has an admin panel in a separate window, you would create a second capability with more powerful permissions:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "admin-window-capability",
"description": "Elevated permissions for the admin panel",
"windows": ["admin-panel"],
"permissions": [
"core:default",
"fs:scope-app-recursive",
{
"identifier": "fs:scope-app-recursive",
"allow": [{ "path": "$APPDATA/**" }]
},
"shell:allow-open"
]
}
Notice the scoped filesystem permission: the fs:scope-app-recursive entry with an allow array restricts access to only the app’s own data directory. Without that scope, a wildcard filesystem permission would let the admin panel read (or write) any file the user’s account can touch—exactly the kind of broad access you want to avoid.
Wildcards Turn Capabilities into Liabilities:
Using "*" or a wildcard identifier like "fs:default" in a capability that allows all paths defeats the entire permission model. A compromised frontend would have unrestricted filesystem access. Always scope filesystem, shell, and network permissions to the smallest set of paths, commands, or URLs the feature actually needs.
To make Tauri aware of your capability files, list them in tauri.conf.json:
{
"app": {
"security": {
"capabilities": ["main-window-capability", "admin-window-capability"]
}
}
}
The identifier here matches the identifier field in each capability file, not the filename. Keeping them consistent is good practice but technically they are independent strings.
Limiting Permissions with Scopes
The capability system gets its real power from scoping individual plugin permissions. A permission like fs:default opens a lot of doors. Scoped permissions let you say: “Yes, the frontend can read files, but only from this one directory, and only with read access.”
Here is how you permit listing the contents of a specific user-chosen directory with a custom Tauri command, scoped entirely to that path:
#[tauri::command]
fn read_dir_entries(path: String) -> Result<Vec<String>, String> {
// The capability already scoped the directory; this is an additional safety check
let allowed_base = dirs_next::data_dir()
.ok_or("Cannot determine data directory")?;
let resolved = std::path::Path::new(&path)
.canonicalize()
.map_err(|e| e.to_string())?;
if !resolved.starts_with(&allowed_base) {
return Err("Access denied".into());
}
let entries = std::fs::read_dir(&resolved)
.map_err(|e| e.to_string())?
.filter_map(|entry| entry.ok().map(|e| e.file_name().to_string_lossy().to_string()))
.collect();
Ok(entries)
}
Even though the capability might grant fs:scope-app-recursive, the Rust command itself performs a second check. Defense in depth: a mistake in the capability file cannot escalate to arbitrary file reads if the command enforces its own bounds.
For the shell plugin, you should never allow arbitrary commands. If your app needs to open a URL in the default browser, use shell:allow-open with a specific scope, not shell:allow-execute:
{
"permissions": [
{
"identifier": "shell:allow-open",
"allow": [{ "url": "https://docs.yourapp.com/**" }]
}
]
}
If you must run a specific external binary (a sidecar), use the sidecar feature rather than a generic shell command. The sidecar is bundled, hashes are verified, and it runs with the same scoping rules.
Sidecars Are Safer Than Shell Commands:
Tauri’s sidecar system lets you bundle external binaries and invoke them through a controlled interface. Because the binary is included in your app and its integrity can be verified, you avoid the risk of shell injection or running an unexpected executable from the user’s PATH. Whenever possible, prefer sidecars over shell:allow-execute.
Protecting Sensitive Information
Secrets—API keys, signing identities, database connection strings—should never appear as plain text in your configuration files committed to version control. Tauri v2’s tauri.conf.json supports environment variable substitution for build-time values using the {{ env.VARIABLE_NAME }} syntax. This is useful for signing keys and update server credentials that need to be embedded at compile time.
Example for a code signing private key:
{
"bundle": {
"windows": {
"signCommand": "signtool.exe sign /fd SHA256 /f \"{{ env.WINDOWS_PFX_PATH }}\" /p \"{{ env.WINDOWS_PFX_PASSWORD }}\" %1"
}
}
}
The actual key file path and password never appear in the committed configuration; they are read from the environment at build time. The environment variables themselves should be set only in your CI/CD system or a local .env file that is explicitly excluded from Git (add .env to .gitignore).
For runtime secrets that the frontend might need—like an API key to call a third-party service—do not embed them in the frontend bundle. The frontend code is trivially extractable from the WebView’s developer tools. Instead, create a Tauri command that fetches the secret from a secure source (such as the OS keychain via a plugin) or proxies the API request through your backend so the key stays in Rust code that never reaches the WebView.
use tauri::Manager;
fn main() {
tauri::Builder::default()
.setup(|app| {
// Read API key from OS keychain at startup, never expose to frontend
let keyring = keyring::Entry::new("com.yourapp.api", "default")?;
let api_key = keyring.get_password()?;
app.manage(Secrets { api_key });
Ok(())
})
.invoke_handler(tauri::generate_handler![call_external_api])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
struct Secrets {
api_key: String,
}
#[tauri::command]
async fn call_external_api(secrets: tauri::State<'_, Secrets>) -> Result<String, String> {
// Use secrets.api_key in an HTTPS request without ever sending it to the frontend
Ok("data fetched".into())
}
The frontend invokes call_external_api and receives the data it needs, but the API key itself never crosses the IPC boundary. This pattern—keeping secrets on the trusted side—is fundamental to Tauri application security.
Embedded Secrets Are Public:
Any string placed in a React component, a .env file that Vite inlines into the bundle, or a tauri.conf.json value that gets resolved at build time and embedded as a constant is retrievable by anyone who inspects your application binary or opens the DevTools. Treat every string in the frontend bundle as public information. The only safe storage for secrets is the Rust backend or the operating system’s secure storage.
Enforcing the Isolation Pattern for High-Security Applications
For applications that handle financial data, healthcare records, or other highly sensitive information, Tauri offers the isolation pattern. Instead of running your frontend code with direct access to the Tauri API, the isolation pattern loads the frontend inside an iframe that has no Tauri API access at all. A small, carefully audited isolation script acts as a relay, calling Tauri commands on behalf of the frontend only through a message-passing channel.
Enable the isolation pattern in tauri.conf.json:
{
"app": {
"security": {
"pattern": {
"use": "isolation",
"options": {
"dir": "../dist-isolation"
}
}
}
}
}
The dir field points to a folder containing the isolation script and its HTML shell. This script is the only piece of code that can invoke Tauri commands, drastically reducing the attack surface. If the main frontend is compromised through an XSS vulnerability, the attacker still cannot call fs:read or shell:execute directly—they must first escape the iframe, which browsers and WebViews make extremely difficult.
The isolation pattern adds complexity: you need to manage a separate build output, and all frontend-to-backend communication must go through the relay. For most productivity apps, the standard capability-based model is sufficient. But if your app’s threat model includes motivated attackers, the isolation pattern is the strongest configuration lever you have.
Securing the Build Process
Security at runtime is undermined if the build process itself is weak. The release profile in Cargo.toml controls how the Rust compiler optimizes the binary. Optimizing for size and stripping debug information reduces the application’s footprint and makes it harder to reverse-engineer:
[profile.release]
codegen-units = 1
lto = "fat"
opt-level = "z"
panic = "abort"
strip = true
codegen-units = 1andlto = "fat"enable aggressive link-time optimization, which removes dead code.opt-level = "z"optimizes for the smallest binary size.panic = "abort"removes unwinding machinery, which shrinks the binary and eliminates a potential information leak from panic messages.strip = trueremoves symbol names, making the binary harder to analyze.
In the build section of tauri.conf.json, setting removeUnusedCommands to true tells Tauri to analyze your frontend code and exclude any Tauri commands you registered but never call:
{
"build": {
"removeUnusedCommands": true
}
}
Fewer commands in the final binary means fewer avenues for an attacker to exploit if they somehow gain execution on the IPC layer.
Code signing and notarization, while distribution topics, are configured in the bundle section of tauri.conf.json and directly impact end-user trust. A properly signed application prevents tampering and reassures users that the binary came from you. Always sign your builds and, on macOS, submit for notarization.
Common Configuration Mistakes
Security configuration errors fall into predictable patterns. Knowing them in advance is the difference between catching a weakness during review and shipping it to users.
-
Using wildcard permissions in capabilities. A permission like
fs:defaultorshell:allow-executewithout a scope gives the frontend unrestricted access. Always scope to specific directories, commands, or URLs.Wildcard Permissions Are the Most Common Critical Mistake:
The Tauri community sees this more than any other misconfiguration. A single unscoped
fs:defaultin a capability file turns a frontend XSS into a full-disk compromise. Use theallowarray to restrict every permission that supports scoping. -
Shipping a CSP that allows unsafe-eval or remote scripts. If your CSP contains
script-src 'unsafe-eval'or allows a CDN, an injected script can load and execute arbitrary payloads. Only allow'self'and, if absolutely necessary, specific hashes or nonces for inline scripts. -
Forgetting to add the
$schemareference in capability files. Without the schema, your editor cannot validate permission identifiers, and you might ship a misspelled permission that silently does nothing. Always include the"$schema"line. -
Exposing the development server to the local network. When running
tauri dev, Vite’s dev server binds to a local port. If your firewall or network configuration makes that port accessible to other machines on the same network, anyone on the LAN can connect and potentially execute Tauri commands during development. Always run dev servers on loopback interfaces only, and use a firewall. -
Embedding secrets in the frontend bundle or Tauri configuration. Vite inlines
import.meta.env.VITE_*variables at build time into your JavaScript bundle. Anyone who opens the app’s DevTools or inspects the binary can read them. Never prefix a secret withVITE_. Use Tauri commands and the Rust backend for sensitive values. -
Allowing remote URLs in production capabilities. The
remotefield in a capability is meant for development with tools like Expo or a remote dev server. In production, remove any remote URL entries. If an attacker can intercept a request to a listed URL, they may be able to load their own code into the WebView.
Audit Your Capabilities Before Every Release:
As your app grows, it’s easy to add a broad permission “just to get something working” and forget to narrow it later. Before you run tauri build, open every file in src-tauri/capabilities/ and ask: does this window actually need every permission listed here? Could a narrower scope do the job? This five-minute audit catches most security regressions.
Summary
Tauri v2 gives you a configuration system designed for least privilege, but it only works if you use it deliberately. The three most impactful decisions you can make are:
- Write a strict CSP that blocks everything except what your app genuinely needs to load.
- Create small, scoped capability files for each window, never using wildcard permissions.
- Keep secrets on the Rust side and never let them cross into the frontend bundle or configuration in plain text.
These aren’t independent—they layer on top of each other. A tightly scoped capability limits what a compromised frontend can ask for. A strict CSP limits what that compromised frontend can do even with the permissions it has. And keeping secrets on the backend ensures that even if the frontend is fully controlled, the most sensitive credentials remain out of reach.
Configuration security is the first line of defense, but it pairs with the runtime security measures covered in the Security & Capabilities chapter.