← ./articles

Gate Tauri Updater Permissions at Runtime, Not in default.json

Tauri v2 permissions are intentionally explicit. That is good for security, but it creates a trap for optional plugins.

If an updater feature is optional, putting updater permissions in src-tauri/capabilities/default.json can make the default build load permissions for a plugin that is not compiled or initialized. The result is a feature-off build that still depends on updater capability configuration.

The safer pattern is to keep default.json feature-agnostic and add updater permissions only in updater builds.

The symptom: a feature-off build still loads updater permissions

The broken setup often looks reasonable at first:

{
  "identifier": "default",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "updater:default",
    "process:allow-restart"
  ]
}

Then the default build is supposed to run without the updater feature:

npm run tauri -- build

But the application startup or capability validation still sees updater-related permissions. Depending on the exact plugin and Tauri version, the failure may appear as a startup error, a permission error, or a plugin command that is unavailable from the frontend.

The mistake is not the permission itself. The mistake is making an optional feature part of the default capability set.

Why static capabilities do not follow Cargo cfg gates

Rust can exclude code with #[cfg(feature = "updater")].

Capability JSON is data. It is loaded by Tauri as configuration. Unless you keep that data out of the default path, it does not automatically follow Rust's compile-time gates.

That means these two statements can both be true:

  • updater Rust code is not compiled
  • updater permission strings still exist in loaded capability files

For optional plugins, keep the base permission file boring. It should describe the always-on application surface.

Create a separate updater capability file

Move updater-specific permissions into a separate file, for example:

{
  "identifier": "updater-runtime",
  "windows": ["main"],
  "permissions": [
    "updater:default",
    "process:allow-restart"
  ]
}

The exact permission set depends on how your app applies updates. If the frontend calls relaunch() from @tauri-apps/plugin-process, it needs restart permission. If Rust performs the restart directly through the app handle, the frontend may not need that command.

The key is not the exact list. The key is that this file is not loaded as part of every build.

Inject the capability only in updater builds

Gate capability injection in Rust:

#[cfg(feature = "updater")]
fn init_updater_capability(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
    let capability = include_str!("../updater-capability.json");
    app.add_capability(capability)?;
    Ok(())
}

#[cfg(not(feature = "updater"))]
fn init_updater_capability(_app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
    Ok(())
}

Then call it from setup:

tauri::Builder::default()
    .setup(|app| {
        init_updater_capability(app.handle())?;
        Ok(())
    })
    .run(tauri::generate_context!())
    .expect("error while running tauri application");

This keeps the setup path consistent. The default build calls a no-op function. The updater build injects the extra capability.

Keep frontend updater calls behind the same boundary

Permissions alone are not enough. If the frontend imports updater APIs unconditionally, the feature-off build can still fail at runtime or bundle time.

Use a small wrapper:

export async function checkForAppUpdate() {
  try {
    const { check } = await import("@tauri-apps/plugin-updater");
    return await check();
  } catch {
    return null;
  }
}

Then the rest of the UI talks to checkForAppUpdate() instead of importing the plugin directly.

This does not hide real release errors. The release build still needs an updater-specific smoke test. It simply prevents the default build from being coupled to an optional plugin.

Verify both builds start cleanly

Run both variants:

npm run tauri -- build
npm run tauri -- build --features updater --config src-tauri/tauri.updater.conf.json

Then verify the expected behavior:

  • default build starts without updater capability errors
  • default build does not expose an updater UI that claims automatic updates work
  • updater build starts with updater permissions available
  • updater build can call the update check path
  • restart or relaunch behavior is covered by the process permission actually used

If only the updater build is tested, a broken default build can ship unnoticed. If only the default build is tested, the release updater path can be missing permissions.

Keep the security boundary visible

Avoid treating capability injection as a workaround. It is a security boundary.

The base capability file should answer: what can the application always do?

The updater capability should answer: what extra permissions are needed only when this build can update itself?

That separation makes reviews easier. A reviewer can see that updater permissions are not silently granted to builds that do not need them.

References

Summary

Do not put optional updater permissions in the default capability file. Keep the always-on capability set small, inject updater permissions only in updater builds, and verify both build variants. That gives you a cleaner security model and fewer feature-off startup surprises.