Plugin Best Practices

How to integrate Tauri plugins into a React and Vite project safely, keep dependencies synchronized, and configure permissions with the principle of least privilege

Tauri’s plugin system lets you add native features like file system access, HTTP requests, or database operations to a web-based desktop application. Plugins bridge your React frontend and Rust backend, but they also introduce new dependencies, permissions, and potential attack surface. The way you install, configure, and restrict plugins determines whether your app stays maintainable and secure over time.

This guide covers five practices that prevent the most common plugin-related problems in Tauri v2 projects. Each one addresses a real pain point that developers hit when they move from a working prototype to an application they ship.

Install Only What You Need

Every plugin you add to a Tauri project brings at least two packages: a Rust crate for the backend and an npm package for the JavaScript bindings. Some plugins also pull in additional system libraries on Linux or require extra build tooling on Windows. That weight adds up across compile times, binary size, and the mental overhead of keeping everything updated.

Before installing a plugin, ask two questions:

  • Does the feature genuinely require a native capability, or could it be implemented in the web frontend? For example, a simple HTTP request can stay in JavaScript with fetch; you only need the HTTP Plugin when the request must originate from the Rust side or bypass CORS entirely.
  • Is this plugin actively maintained and compatible with your Tauri version? Check the plugin’s repository for recent commits and a version matrix if one exists.

When you decide a plugin is necessary, install it using the Tauri CLI’s automated command rather than manually adding packages. This ensures both the Rust crate and the JavaScript bindings are added with compatible versions and that the necessary Rust initialization code is inserted. The full procedure is on Installing Plugins.

npm run tauri add store

Running this from your project root does three things in one step: it adds the Rust crate tauri-plugin-store to src-tauri/Cargo.toml, installs @tauri-apps/plugin-store in your frontend’s package.json, and registers the plugin in src-tauri/src/lib.rs. Skipping this and adding packages manually is the single most common source of version mismatch errors.

Resist Copy-Paste Plugin Collections:

Avoid installing a bundle of plugins "just in case" because a template or tutorial used them. Each unused plugin stays in your binary, and every permission you leave open becomes a potential path for malicious code if your web content is ever compromised. A minimal plugin set is a security practice, not just a performance one.

Keep Plugin Versions Synchronized

A Tauri plugin consists of a Rust crate and a JavaScript package that are developed together and rely on the same internal IPC protocol. When their versions drift apart, you get silent failures: a command invoked from the frontend never reaches the Rust handler, or a returned value can’t be deserialized because the data shape changed between releases.

The Tauri CLI’s tauri add command pins both sides to a compatible version at installation time. The problem appears later, when you update one side without the other. A common scenario: running npm update bumps the JavaScript binding to a new minor version, but the Rust crate stays on the old one because Cargo.toml uses an exact version or a different update cadence.

To prevent this, treat plugin updates as a single atomic operation. Instead of updating packages individually, use the Tauri CLI to re-add the plugin:

npm run tauri add store

If the plugin is already installed, this command updates both the Rust and JavaScript dependencies to the latest mutually compatible versions.

For projects with many plugins, centralize version numbers in package.json and Cargo.toml so you can review them side by side. A quick audit can catch drift before it causes a bug:

// package.json (frontend)
{
  "dependencies": {
    "@tauri-apps/plugin-store": "~2.2.0",
    "@tauri-apps/plugin-opener": "~2.2.0"
  }
}
# src-tauri/Cargo.toml
[dependencies]
tauri-plugin-store = "2.2"
tauri-plugin-opener = "2.2"

Semver Minor Bumps Can Break IPC:

Plugin maintainers follow semver, but the IPC contract between frontend and backend is not always covered by the public API surface. A minor bump in the JavaScript package that adds a new parameter to a command will fail at runtime if the Rust side hasn’t been updated to accept it. Always test after version changes, especially if you update plugins independently.

Everything Is Synced When This Works:

A quick sanity check: after updating plugins, build your app in debug mode and exercise every plugin feature once. If you see no "command not found" or deserialization errors in the console, your versions are aligned correctly.

Configure Permissions Precisely

Tauri v2 uses a capability-based permission system. By default, every potentially dangerous plugin command is blocked. You must explicitly grant access to each command your frontend needs, and you can further restrict that access with scopes—patterns that define which file paths, URLs, or other resources the command is allowed to touch.

The most impactful best practice is to scope every permission down to exactly what your application requires. A broad permission like store:allow-read without a scope allows reading any store file on disk; with a scope, you can limit reads to a single file in the app’s data directory.

Here is an example from a real capabilities configuration file. This grants the Store Plugin permission to read from and write to a single store file, and denies everything else:

// src-tauri/capabilities/default.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "main-capability",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    {
      "identifier": "store:allow-read",
      "allow": [
        { "path": "$APPDATA/settings.json" }
      ]
    },
    {
      "identifier": "store:allow-write",
      "allow": [
        { "path": "$APPDATA/settings.json" }
      ]
    },
    {
      "identifier": "store:deny-delete"
    }
  ]
}

The $APPDATA variable resolves to the platform-specific application data directory, so this scope is both restrictive and portable. If your frontend later tries to read a different file, the command is silently denied.

When you add a plugin with the Tauri CLI, it sometimes generates a default permission set that is intentionally broad to get you started. Review this generated configuration before shipping. Replace wildcard scopes (like * for all paths) with specific values.

Audit Generated Permissions Immediately:

The tauri add command may insert permissive defaults into your capabilities file. Open src-tauri/capabilities/default.json right after adding a plugin and lock down every scope. It is easier to relax a restriction later than to remember to tighten one you never saw.

Follow Security Principles That Are Specific to Plugins

Security considerations for Tauri plugins go beyond general desktop app hardening. They focus on the trust boundary between the web frontend and the Rust backend. Because the frontend runs in a webview and can load external content if you are not careful, every plugin permission you grant becomes a potential bridge that malicious JavaScript could cross.

Three principles apply directly to plugin usage:

Principle of least privilege. Grant only the commands your application actually calls. If your app only reads from a database, do not enable sql:allow-write. If you only open a specific URL with the opener plugin, scope the URL pattern exactly rather than allowing all https:// links.

Isolate sensitive operations in Rust, not in JavaScript. Plugins let you call Rust commands from the frontend, but the security-sensitive logic should live on the Rust side. For example, if your app needs to store an authentication token securely, use a store plugin command that accepts the token as a parameter and encrypts it inside the Rust handler—rather than reading the raw token from the frontend and storing it directly. The less your JavaScript code knows about secrets, the smaller the exposure if the webview is compromised.

Restrict the Content Security Policy (CSP). This is not a plugin setting, but it directly affects plugin safety. In tauri.conf.json, set a CSP that limits which scripts can run in the webview. If your frontend cannot load arbitrary remote scripts, an attacker cannot inject code that tries to call your plugin commands.

// src-tauri/tauri.conf.json (partial)
{
  "app": {
    "security": {
      "csp": "default-src 'self'; script-src 'self'"
    }
  }
}

With this CSP, even if a dependency of your frontend tries to load a script from a CDN, the webview will block it. Combined with scoped plugin permissions, you create defense in depth: an attacker would need to both inject code into your frontend and discover that the only reachable plugin commands are already locked to specific, harmless operations.

Plugin Commands Are Not Authenticated:

Any JavaScript running in your webview can invoke a permitted plugin command. There is no per-request user authentication at the IPC layer. If you grant a destructive command like file deletion, assume any script in your frontend—whether yours or injected—can trigger it. Scope it so that even a successful call does minimal damage.

Test Plugin Behavior During Development

Plugin misconfiguration often manifests silently. A command that fails due to insufficient permissions does not throw a visible exception by default; the Promise may simply never resolve, or it may resolve with a generic error that you overlook during development.

Build a small smoke test for every plugin you integrate. After installing and configuring a plugin, write a minimal React component that exercises its primary command and displays the result. This gives you immediate feedback if a permission is missing or a version is mismatched.

Here is a smoke test component for the store plugin:

// src/App.tsx
import { useState } from "react";
import { load } from "@tauri-apps/plugin-store";
function StoreSmokeTest() {
  const [status, setStatus] = useState("idle");
  async function testStore() {
    try {
      const store = await load("settings.json", { autoSave: false });
      await store.set("test_key", { value: "hello" });
      const val = await store.get("test_key");
      setStatus(val ? `Read back: ${JSON.stringify(val)}` : "Failed to read");
    } catch (err) {
      setStatus(`Error: ${String(err)}`);
    }
  }
  return (
    <div>
      <button onClick={testStore}>Test Store Plugin</button>
      <p>Status: {status}</p>
    </div>
  );
}
export default StoreSmokeTest;

Run this during development after any plugin version change. If you see "Read back:" with the correct value, the plugin is installed, synchronized, and permitted correctly. If you see an error about a denied command, check your capabilities configuration first.

Never Ship Debug Smoke Tests:

Remove or disable smoke test components from production builds. A button that writes arbitrary keys to a store is a convenient development tool but a liability in a released application. Use environment variables or conditional compilation to exclude them.

What a Well-Maintained Plugin Setup Looks Like

When you apply these practices together, your project reaches a state that is easy to audit. The signs of a well-maintained plugin setup include:

  • Your package.json and Cargo.toml list the same set of plugins, with versions that are intentionally kept in sync.
  • Every plugin registered in lib.rs has a corresponding, scoped permission entry in capabilities/default.json.
  • You can explain why each plugin is necessary; there are no leftover plugins from abandoned features.
  • Changing a plugin version triggers a smoke test that passes before you commit.

This is not a checklist you complete once. It is a rhythm you follow every time a plugin enters or leaves your dependency tree.