Skip to main content

Command Palette

Search for a command to run...

Tauri 2 IPC: How Rust and React Actually Talk

A source-code walkthrough of the bridge — and a Bitcoin wallet that keeps private keys safe

Published
29 min read
Tauri 2 IPC: How Rust and React Actually Talk
B
Author of Rust Blockchain: A Full-Stack Implementation Guide, a 24-chapter, 696-page book covering Axum, Iced, Tauri 2, Tokio, SQLCipher, Docker, and Kubernetes through one cohesive project. I write about building real systems in Rust — from blockchain infrastructure to desktop applications. Currently exploring the move from JVM-heavy data platforms to a Rust-native Big Data stack as a path to 10x more efficient data pipelines, eliminate GC pauses and reduce infrastructure costs using Arrow and DataFusion.

Published May 3, 2026

Just last week, somebody filed a bug against Tauri. Their app pointed a webview at an external URL via tauri::WebviewUrl::External, and invoke() started failing with "Origin header is not a valid URL." They were certain they'd configured everything correctly. The docs didn't help. The framework was just rejecting their commands.

The bug is real and the cause is specific: the embedded webview doesn't add an Origin header on cross-origin requests, and the Referer is empty, so Tauri's IPC scope checker can't verify the request came from an allowed origin and refuses to dispatch it. To debug it, you have to know what the IPC bridge actually does — not what invoke() looks like, but how the request travels from JavaScript through the OS-native webview into Rust and back.

Most Tauri tutorials show you invoke('hello') and stop. That's enough to ship a hello-world; it's not enough to debug #15190-class bugs, design a security boundary, or make architectural choices that survive contact with production. This post reads the Tauri 2 IPC bridge end to end, traces a real Bitcoin wallet's commands across it, and shows the security pattern that makes Tauri worth picking over Electron in the first place.

By the end you'll know:

  • What invoke() actually does, with line references to the Tauri source
  • How to design an IPC surface where private keys never leak to the frontend
  • When Tauri 2's capability system blocks a command and when it doesn't
  • The difference between the Tauri and Electron threat models, in one paragraph
  • The five mistakes that bite real teams using Tauri in 2026

The example throughout is a Bitcoin wallet from my book Rust Blockchain: A Full-Stack Implementation Guide — 18 commands, two halves, a real working architecture you can clone and run. But the lessons generalize to any Tauri app: a password manager, an enterprise SSO dashboard, an ML inference UI. Anywhere a Rust backend has secrets and a webview UI shouldn't.

1. What Tauri IPC actually is

Tauri is a desktop-app shell. Your application's UI runs in the operating system's native webview (WebView2 on Windows, WKWebView on macOS, WebKitGTK on Linux). Your application's logic runs in a Rust process. The two halves live in the same OS process, but the boundary between them is enforced — JavaScript inside the webview can only reach Rust through commands that Rust has explicitly registered.

The mechanism for crossing that boundary is inter-process communication (IPC), even though it's technically intra-process. The naming is conventional, not literal.

A request from React to Rust looks like a remote procedure call:

  1. React calls invoke('cmd_name', { arg: 'value' })
  2. Tauri serializes the arguments to JSON
  3. The message is delivered through the webview's host-bridge channel
  4. Tauri dispatches to a Rust function registered with #[tauri::command]
  5. The Rust function returns a Result<T, E>
  6. Tauri serializes the result to JSON
  7. React receives the resolved promise

That's the whole protocol. It looks like fetch, except there's no network — the bridge is a message channel inside one OS process — and the "server" is a Rust function that has memory-safe access to your filesystem, your secrets, and your databases.

Tauri 2 architecture overview — React, IPC bridge, Rust backend, blockchain API

The diagram above is the architecture for bitcoin-desktop-ui-tauri, the admin UI from Chapter 17 of the book. A React component asks useQuery() for the latest blockchain state. The query function calls commands.getBlockchainInfo(). That function wraps a single invoke() call. On the Rust side, a #[tauri::command] handler receives the call, asks Tauri's dependency injection for the shared AppState, builds an HTTP client (AdminClient) from the shared bitcoin-api crate, hits the blockchain API at :8080, and returns JSON. React gets the response. The component re-renders.

The whole roundtrip is about ~150 lines of Rust and TypeScript split across four files. We'll walk all four.

2. The IPC bridge under the hood

This section is the longest, because most existing Tauri content stops where this section begins. Read it once and you'll understand invoke() better than 95% of people shipping Tauri apps today.

2.1 Where the bridge lives in the source

The Tauri repository at github.com/tauri-apps/tauri contains the IPC implementation in two places:

  • crates/tauri/src/ipc/ — the high-level command dispatch (the invoke() you call, the #[tauri::command] macro, Channel, InvokeBody, InvokeResolver)
  • crates/tauri-runtime-wry/src/webview.rs — the wry-backed implementation of the message bus that talks to the actual platform webview

When you call invoke('cmd_get_balance', { addr: '1A1zP1...' }) in TypeScript, here's what happens:

Step 1 — JavaScript-side serialization. The @tauri-apps/api/core package's invoke function bundles the command name and the arguments object, then posts a message to the webview's host bridge. On Windows that's chrome.webview.postMessage. On macOS it's webkit.messageHandlers.ipc.postMessage. On Linux/GTK it's a custom URI scheme handler. Tauri abstracts these three platform mechanisms behind a single Webview::eval and Webview::on_window_event API so user code never has to think about them.

Step 2 — message arrives in Rust. The Tauri runtime receives the platform message and routes it to crates/tauri/src/ipc/invoke.rs, where the Invoke struct is constructed. This struct holds the command name (a string), the payload (a JSON value), and a resolver — the channel back to the webview that will eventually carry the response.

Step 3 — capability check. Before dispatching, the IPC layer consults the access control list (ACL) loaded from the capabilities/*.json files in your project. If the calling window's capability set doesn't include the requested command, the request is rejected with a permission error. We'll come back to this in §6.

Step 4 — argument deserialization. Tauri uses serde and the #[tauri::command]-generated wrapper code to deserialize the JSON payload into the typed parameters of your Rust function. Each parameter is matched by name to a key in the JSON object. Type mismatches become deserialization errors that propagate back to the JavaScript caller as Error objects, not panics.

Step 5 — your function runs. The Rust function executes. Async commands execute on tauri::async_runtime (Tokio underneath). The function can take a State<T> parameter, which Tauri's dependency injection populates from values you registered with tauri::Builder::manage().

Step 6 — result serialization and return. The function's return value (Result<T, E> where both T and E are serde::Serialize) is serialized back to JSON and posted through the resolver back to the JavaScript caller. The TypeScript invoke() Promise resolves with the decoded result, or rejects with the decoded error.

2.2 What this means for your application code

The Rust side of an IPC handler is genuinely small. From the book's admin UI, here is the canonical handler — cmd_get_blockchain_info, in bitcoin-desktop-ui-tauri/src-tauri/src/commands/blockchain.rs:

use crate::api::service::BitcoinApiService;
use tauri::State;
use serde_json::Value;

use crate::AppState;

#[tauri::command]
pub async fn cmd_get_blockchain_info(
    state: State<'_, AppState>,
) -> Result<Value, String> {
    let response = BitcoinApiService::get_blockchain_info(
        state.base_url.clone(),
        state.api_key.clone(),
    )
    .await?;

    serde_json::to_value(&response)
        .map_err(|e| e.to_string())
}

Three things to notice. First, the #[tauri::command] macro is doing a lot of work — it generates a wrapper that handles JSON deserialization, dispatches to your function, and serializes the result. You don't write any of that.

Second, the state: State<'_, AppState> parameter is dependency injection. You registered AppState once in main() via .manage(app_state), and Tauri populates this parameter on every call. No globals, no thread-local statics, no manual locking — it's the same idiomatic pattern as axum::extract::State or actix_web::web::Data.

Third, the function returns Result<serde_json::Value, String>. The String error type is conventional in Tauri because everything that crosses the bridge has to be Serialize, and String is the cheapest serializable error. Production code typically uses a custom error enum that derives Serialize, but the pattern is the same.

The TypeScript side is even smaller, in bitcoin-desktop-ui-tauri/src/commands.ts:

import { invoke } from '@tauri-apps/api/core';
import { BlockchainInfo, BlockSummary, ApiResponse } from './types';

export async function getBlockchainInfo(): Promise<ApiResponse<BlockchainInfo>> {
    return invoke<ApiResponse<BlockchainInfo>>('cmd_get_blockchain_info');
}

A one-line wrapper around invoke(). The string 'cmd_get_blockchain_info' is the same name the Rust function exports through tauri::generate_handler!. The TypeScript generic argument <ApiResponse<BlockchainInfo>> tells the compiler what shape the resolved value will have — but note this is a TypeScript-only assertion, not a runtime guarantee. If the Rust side returns a differently-shaped object, TypeScript won't notice until something reads a missing property at runtime.

We'll come back to this gap when we discuss tauri-specta.

2.3 The tauri::generate_handler! macro

Every command you want to expose has to be listed in a single tauri::generate_handler! call inside main(). The book's admin UI does this with all 22 of its commands:

fn main() {
    tauri::Builder::default()
        .manage(AppState {
            base_url: "http://127.0.0.1:8080".to_string(),
            api_key: std::env::var("BITCOIN_API_ADMIN_KEY")
                .unwrap_or_else(|_| "admin-secret".to_string()),
        })
        .invoke_handler(tauri::generate_handler![
            cmd_get_blockchain_info,
            cmd_get_latest_blocks,
            cmd_get_blocks,
            cmd_get_block_by_hash,
            cmd_get_mining_info,
            cmd_generate_to_address,
            cmd_get_health,
            cmd_get_liveness,
            cmd_get_readiness,
            cmd_get_mempool,
            cmd_get_mempool_transaction,
            cmd_get_transactions,
            cmd_get_address_transactions,
            cmd_create_wallet_admin,
            cmd_get_addresses_admin,
            cmd_get_wallet_info_admin,
            cmd_get_balance_admin,
            cmd_send_transaction_admin,
            cmd_set_base_url,
            cmd_set_api_key,
            cmd_get_config,
            cmd_check_connection,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Two gotchas worth flagging immediately:

  • Calling invoke_handler more than once silently overwrites. Only the last call wins. If you split command registration across multiple files thinking each one will register its own handlers, only one set will actually be live. Pass every command to a single generate_handler! call.
  • A command not listed in generate_handler! fails silently from the frontend. The Rust function still compiles; it's just unreachable through IPC. From JavaScript you get a generic "command not found" error at runtime, with no compile-time warning. The list above is a runtime contract — keep it audited.

2.4 Async, runtimes, and Send

Async commands run on tauri::async_runtime, which is a Tokio multi-threaded runtime under the hood. Two consequences follow.

First, everything in your async command's environment must be Send, because the runtime can move the future across worker threads at any .await point. Standard std::sync::MutexGuard is intentionally not Send (this is what protects you from cross-thread deadlocks in non-async code), so you cannot hold one across an .await. Use tokio::sync::Mutex, whose MutexGuard is Send. If you guess wrong, you get a runtime panic — there's no compile-time check that catches it.

Second, State<T> references and AppHandle work differently at .await boundaries. A State<'_, T> borrow is short-lived and shouldn't be held across an .await. If you need to own data across an await point, extract the data you need out of State before the first .await, or use AppHandle (which is Clone and owned) to access state inside async closures. The book's wallet uses both: synchronous reads use State, while long-running async tasks pass an owned AppHandle.

2.5 The ergonomic gap: stringly-typed invoke()

If you've made it this far, you've noticed something: the link between invoke('cmd_get_balance') and cmd_get_balance in Rust is a string. TypeScript doesn't know the command exists until the Promise resolves at runtime. There's no autocomplete on command names, no compile-time check that you spelled the command correctly, no signal that you passed the wrong argument shape.

This is where the community has done the work that the official docs haven't done, in the form of tauri-specta. It's a library that generates TypeScript bindings from your Rust commands at build time, using the Specta crate for type introspection. The same cmd_get_balance function, with #[tauri::command] augmented by #[specta::specta], becomes a typed export the TypeScript side can import:

import { commands } from './bindings'; // generated by tauri-specta

const balance: u64 = await commands.cmdGetBalance({ addr: '1A1zP1...' });

The compiler now knows the command exists, knows it takes an addr: string, and knows it resolves to a u64. Rename the Rust function and the TypeScript build breaks immediately. Ship the wrong argument shape and the TypeScript build breaks immediately. The Tauri 2 docs don't show this. Production teams use it almost universally.

If you take only one practical recommendation from this post, it's add tauri-specta to any Tauri 2 project as soon as you have more than ~five commands. The official invoke() is great for prototypes and tutorials. For real applications, the typed bindings are the difference between bugs at compile time and bugs at runtime.

3. Tauri vs Electron — design and security, not vibes

The "Tauri vs Electron" comparison appears in every Rust desktop discussion in 2026, and most of those comparisons are vibes-based. Here is the comparison grounded in design and security, with citations to each project's own documentation.

Axis Tauri 2 Electron
Backend language Rust Node.js (V8)
Webview engine OS-native (WebView2 / WKWebView / WebKitGTK) Bundled Chromium
Mobile support Yes (Tauri 2 added iOS and Android, Oct 2024) Desktop only
IPC mechanism JSON over the platform webview's host-bridge JSON / structured-clone over ipcMain / ipcRenderer
Permission model Capability-based ACL (opt-in per window) None by default; full filesystem unless you add manual gates
Plugin ecosystem ~120 official + community plugins (April 2026) 10,000+ npm packages
Bundle size, hello-world 2–10 MB (community-cited) 80–200 MB (community-cited)
Memory at idle ~50 MB (community-cited) ~120 MB+ (community-cited)
License MIT / Apache 2.0 MIT

(Bundle and memory figures are widely-cited community measurements from 2026 comparative analyses; I'm not claiming these as my own benchmarks. They are useful as orders-of-magnitude context, not as precision numbers.)

The headline trade-off is this. Electron buys you the most mature desktop-app ecosystem in existence in exchange for shipping an entire copy of Chromium with every app and giving the JavaScript side default access to the user's filesystem. Tauri buys you a tenth of the bundle size and a permission system that's secure by default, in exchange for asking your team to write the backend in Rust and giving up most of npm.

Pick Electron when:

  • Your team doesn't know Rust and doesn't have time to learn it
  • The application doesn't hold secrets, doesn't need a small footprint, and ships only on desktop
  • You depend on a Node.js library (or an Electron-specific plugin) that has no Rust equivalent
  • Examples that match: VS Code, Slack, Discord, Atom

Pick Tauri when:

  • Your application holds secrets — wallets, password managers, API keys, encryption keys
  • Your application is positioned on lightweight or low-resource — productivity tools that compete with Electron incumbents (AppFlowy vs Notion is the canonical example)
  • You need iOS or Android support from the same codebase
  • Your business logic is already a Rust crate, and the desktop client is just the latest consumer (Spacedrive's spacedrive-core is the textbook case)
  • Examples that match: Cap.so, AppFlowy, Spacedrive, Padloc, Hoppscotch

For the rest of this post we're firmly in Tauri territory: the example application holds private keys, has a strict threat model, and wants the smallest reasonable attack surface.

4. A working example: a Bitcoin wallet that keeps keys safe

This is where the post's example earns its keep. The book's bitcoin-wallet-ui-tauri crate is a Bitcoin wallet — 18 IPC commands, an encrypted SQLite database, and a strict invariant: private keys never cross the IPC boundary. The React UI never touches them. JavaScript can ask Rust to sign a transaction; it cannot ask Rust to hand over the key that does the signing.

This pattern generalizes beyond blockchain. A password manager has the same threat model. So does an enterprise dashboard that holds API tokens. So does an ML inference UI that holds proprietary model weights. The wallet is a concrete instance of a class of secret-holding desktop apps; once you understand its IPC surface, you understand all of them.

Wallet IPC boundary — keys stay in Rust, only signed transactions cross to React

The diagram above shows the wallet's IPC surface. The left side is what React can ask for. The right side is what only Rust knows. The orange dashed bar between them is the IPC boundary, and the design choice is that nothing on the right side has a path to the left side, even in principle.

4.1 The wallet's state structure

The wallet's AppState looks like this:

use tauri::State;
use std::sync::RwLock;

#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct WalletState {
    pub current_wallet: Option<String>,
    pub wallets: Vec<String>,
    pub database_password: String,
}

#[derive(Clone)]
pub struct AppState {
    pub wallet: RwLock<WalletState>,
    pub base_url: String,
}

Notice what WalletState doesn't contain. There is no private_key field. There is no seed_phrase field. There is no keypair. The struct that's available to IPC handlers via State<AppState> does not contain anything secret. Private keys live elsewhere — loaded from an encrypted SQLite database into a SecretKey value that's used inside a single function call and dropped before the function returns.

The database_password field is a deterministic hash of \(USER and \)HOME, so the same user on the same machine always unlocks the same wallet database — but the password itself is derived locally and never shipped to the frontend.

4.2 The 18 commands the wallet exposes

These are all 18 commands the wallet exposes across IPC, in the order they're registered with generate_handler!:

Command Returns What it does
cmd_list_wallets Vec<String> List wallet identifiers stored on disk
cmd_create_wallet String Create a new wallet, return its identifier
cmd_select_wallet () Set the active wallet for subsequent operations
cmd_get_balance u64 Balance of an address, in satoshis
cmd_get_addresses Vec<Address> All addresses owned by the active wallet
cmd_generate_address Address Derive and return a new address
cmd_get_transaction_history Vec<Tx> Past transactions for the active wallet
cmd_get_unspent_outputs Vec<Utxo> UTXOs available for spending
cmd_send_transaction TxId Build, sign, and broadcast a transaction
cmd_sign_transaction SignedTx Sign a draft transaction (does not broadcast)
cmd_get_tx_status TxStatus Confirmation count for a given txid
cmd_export_wallet String Export an encrypted wallet bundle (no plaintext keys)
cmd_import_wallet () Import a wallet bundle into local storage
cmd_backup_wallet () Trigger a backup to the configured location
cmd_set_passphrase () Set the wallet's unlock passphrase
cmd_unlock_wallet bool Verify the passphrase and unlock for the session
cmd_get_wallet_info WalletInfo Metadata about the active wallet
cmd_cancel_transaction () Cancel a pending transaction by txid

Look at what isn't in this list. There is no cmd_get_private_key. There is no cmd_export_seed_phrase. There is no cmd_decrypt_database. The IPC surface itself does not offer a path for keys to escape Rust. Even if a malicious npm package compromised the React UI tomorrow and an attacker had complete control of the JavaScript side, the worst they could do is ask Rust to sign things — they could not extract the keys to sign things on their own machine.

This is the security pattern. The IPC layer is your API surface; design it so that the operations you need are present and the operations that would leak secrets are intentionally absent.

4.3 Three IPC patterns the wallet demonstrates

Pattern 1 — synchronous query. A read-only command that takes no parameters or simple parameters and returns a value:

#[tauri::command]
pub async fn cmd_get_balance(
    state: State<'_, AppState>,
    addr: String,
) -> Result<u64, String> {
    let wallet = state.wallet.read().map_err(|e| e.to_string())?;
    // ... query the chain, sum UTXOs, return total
}

The TypeScript side calls it the same way as a fetch:

const balance: number = await invoke<number>('cmd_get_balance', { addr });

React Query wraps this with caching:

export function useBalance(addr: string | null) {
    return useQuery({
        queryKey: ['balance', addr],
        queryFn: () => addr ? commands.getBalance(addr) : Promise.resolve(0),
        enabled: !!addr,
        staleTime: 10000,  // balances refresh every 10s
    });
}

Pattern 2 — async write that mutates state. A command that changes something — broadcasts a transaction, creates a wallet — and that the rest of the UI needs to know about:

#[tauri::command]
pub async fn cmd_send_transaction(
    state: State<'_, AppState>,
    to: String,
    amount: u64,
) -> Result<String, String> {
    // build the tx, sign it (in place, with the key never leaving Rust),
    // broadcast to the network, return the txid
}

The React side uses useMutation and invalidates relevant cached reads on success:

const sendTx = useMutation({
    mutationFn: (vars: { to: string; amount: number }) =>
        commands.sendTransaction(vars.to, vars.amount),
    onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: ['balance'] });
        queryClient.invalidateQueries({ queryKey: ['transaction-history'] });
    },
});

After a successful send, the cached balance is marked stale so the next render fetches fresh data. This is React Query's bread and butter — it's also the right design for any IPC bridge that needs to keep client-side caches consistent with server-side mutations.

Pattern 3 — security-bounded operation. This is the wallet's distinctive pattern. cmd_sign_transaction takes a transaction draft (recipient address, amount, fee), signs it inside Rust, and returns the signed bytes. The private key is loaded from the encrypted database, used inside a single function call, and dropped before the response is serialized back to the frontend.

#[tauri::command]
pub async fn cmd_sign_transaction(
    state: State<'_, AppState>,
    draft: TransactionDraft,
) -> Result<SignedTransaction, String> {
    let wallet = state.wallet.read().map_err(|e| e.to_string())?;
    let sk = load_secret_key(&wallet.database_password)?;
    let signed = sign_in_place(draft, &sk)?;
    // sk is dropped here, before the function returns
    Ok(signed)
}

The crucial observation: there is no path through this function that surfaces sk to the caller. The function returns SignedTransaction, which is just bytes — no key material in it. The IPC surface offers signing but does not offer key extraction. A malicious frontend can spam-call this function but cannot exfiltrate the underlying key.

This is the pattern that separates a Tauri-style desktop wallet from a browser extension wallet. In a browser extension, the "frontend" and the "backend" share the same JavaScript runtime, so isolating keys requires a much weaker form of process boundary. In Tauri, the IPC bridge is a hard boundary enforced by the operating system's webview, and the Rust side controls what crosses it.

4.4 The shared-crate architecture

One more architectural insight worth its own subsection. The book has four implementations of the wallet UI: a pure-Rust Iced version (Chapter 18), this Tauri-with-React version (Chapter 19), and a parallel admin UI in both frameworks (Chapters 16 and 17).

All four call the same underlying Rust crate: bitcoin-api, which exposes AdminClient and WalletClient types that talk to the blockchain over HTTP. The Tauri commands are thin wrappers — they receive State<AppState>, call into the bitcoin-api crate, and return JSON. The Iced UIs do the same calls from Message handlers. The framework choice is decoupled from the application logic.

Why does this matter? Because Tauri is a UI shell, not an application framework. If your team's business logic is already a reusable Rust crate, swapping the desktop shell from Electron to Tauri (or from one Rust UI framework to another) doesn't require refactoring anything except the layer that makes API calls. Spacedrive's open-source codebase demonstrates this at production scale: their spacedrive-core crate is a pure Rust library; the Tauri 2 desktop app, the CLI, and the Spacebot service are all thin consumers of that one crate.

If you're considering Tauri but worried about lock-in, this architecture pattern is your insurance policy. Keep the application logic in plain Rust crates; treat the Tauri layer as the desktop adapter; you can rip out the Tauri layer in a weekend if you ever need to.

5. The capability system: security at the IPC boundary

We promised in §2 that we'd come back to the capability system. Here it is.

In Tauri 1, every IPC command was reachable from every window by default. You opted out of dangerous APIs by editing an allowlist. Tauri 2 inverts this: nothing is reachable by default, and you grant access by declaring capabilities in JSON files under your project's capabilities/ directory.

A capability declaration is a small JSON document that names a window or webview, names the permissions it should have, and optionally scopes those permissions further (which filesystem paths, which HTTP endpoints, which IPC commands). When a window calls invoke(), the Tauri IPC layer consults the capabilities for that window before dispatch — if the requested command isn't covered by a capability grant, the call is rejected before the Rust handler is ever invoked.

For the wallet, a minimal capability file granting access only to the wallet commands looks something like this:

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "wallet-main",
  "description": "Capabilities for the main wallet window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    {
      "identifier": "wallet:allow-balance-and-history",
      "allow": [
        "cmd_get_balance",
        "cmd_get_addresses",
        "cmd_get_transaction_history",
        "cmd_send_transaction",
        "cmd_sign_transaction"
      ]
    }
  ]
}

The windows: ["main"] line means this capability only applies to the window labeled main. If your app has a tray-icon window or a notification overlay window, those windows have their own capability files with their own permission grants. Per-window scoping is the design feature that makes the capability system useful — you can give the main interactive window the broad permissions it needs while keeping the tray-icon window restricted to a single command.

Three things this design buys you:

A clear audit story. A security reviewer can read your capabilities/ directory and immediately see what each window can ask Rust to do. There is no hidden middleware, no ambient permissions. The capability file is the authorization policy.

Defense in depth. Even before the ACL check, the IPC surface itself is limited (we saw this in §4 with the wallet's commands). Even if a misconfigured capability accidentally grants too much, the only attack surface available is the commands you actually wrote. The capability layer is the second gate, not the only gate.

A red-team analysis you can write down. Suppose tomorrow a malicious npm package compromised your React build. The attacker now controls the JavaScript running in the webview. What can they do? The answer is: exactly what the capability file allows, no more. They can call cmd_send_transaction if cmd_send_transaction is in the allowlist. They cannot reach files outside the configured filesystem scope. They cannot make HTTP requests to arbitrary servers. They especially cannot extract private keys, because no command exists that would return one.

This story is impossible to write for an Electron app with the default configuration, because Electron's default is "the renderer process can do anything the main process can do." You can lock Electron down — contextIsolation, sandbox, nodeIntegration: false, custom IPC validators — but these are opt-in, ad hoc, and easy to misconfigure. Tauri 2's capability model is opt-in by inversion: secure by default, dangerous by explicit grant. For an application that holds secrets, this default is the entire reason to pick Tauri.

6. Five things that bit me

A practical section for readers who are about to ship Tauri 2 in production. These are the recurring pitfalls — drawn from the Tauri repo's issue tracker, Discord conversations, and the experience of building the book's two Tauri apps.

1. invoke_handler only registers the last call. If you call .invoke_handler(generate_handler![cmd_a]) and then .invoke_handler(generate_handler![cmd_b]), only cmd_b is registered. There is no compile-time warning. Pass every command to a single generate_handler! invocation. If the list gets unwieldy, group commands into modules and re-export them, but the final list lives in one macro call.

2. Standard MutexGuard is not Send — your async command won't compile or will panic. When you hold a std::sync::MutexGuard across an .await point, the Rust compiler refuses to make the future Send, and Tauri requires async-command futures to be Send. Use tokio::sync::Mutex and tokio::sync::RwLock, whose guards are Send. The book's wallet uses std::sync::RwLock for synchronous code and switches to Tokio's primitives whenever a lock is held across an await.

3. Event listeners need cleanup. Tauri's frontend event API (listen('event-name', handler)) registers a JavaScript callback for a Rust-emitted event. If the React component that registered the listener unmounts without calling the unlisten function, the callback stays alive — and if a new instance of the component mounts, you now have two callbacks reacting to the same event. The book's React hooks always store the unlisten promise and clean up in useEffect return:

useEffect(() => {
    const unlistenPromise = listen('block-mined', (event) => {
        // ...
    });
    return () => {
        unlistenPromise.then(fn => fn());
    };
}, []);

4. Setup-hook events fire before listeners attach. If you call app.emit('startup') from the Tauri setup hook in Rust, none of the React components are mounted yet, so the event has no listeners and is dropped. The pattern is to wait for a "ready" handshake from the frontend before emitting startup events. The frontend mounts, calls invoke('cmd_frontend_ready'), and the Rust side then emits the startup events with confidence.

5. The official invoke() is stringly-typed; production teams use tauri-specta. This was the recommendation in §2.5 and it's worth saying again as a pitfall. Without tauri-specta, every IPC call is a string lookup, every argument is unknown until you assert the type, and every rename of a Rust function is a runtime bug waiting to happen. Add tauri-specta early. The migration cost from invoke() to typed bindings is small if you do it before you have hundreds of invoke() call sites.

Bonus issue worth knowing about: Origin header handling on external URLs (issue #15190) is the bug we opened the post with. If you set the webview to an external URL via tauri::WebviewUrl::External, the embedded webview may not include an Origin header on IPC requests, and the scope checker rejects them. The current workaround is to use tauri::WebviewUrl::App with an internal HTML shim that loads the external content, or to disable origin checking for that specific window (which has security trade-offs of its own).

7. Where Tauri is heading

The Tauri team has been public about what comes after 2.x. Three directions worth knowing about:

Servo as an alternative webview engine. Tauri currently uses each platform's native webview, which means inheriting that webview's bugs and feature set. Servo — Mozilla's experimental Rust-native browser engine — is being actively developed as an alternative renderer. If it lands, you'll be able to ship Tauri apps that don't depend on the host operating system's webview, eliminating a class of cross-platform inconsistency bugs at the cost of bundling a (still much smaller than Chromium) browser engine. Watch Tauri Discussions and the Servo project for status updates.

WASI plugin architecture. Native Tauri plugins are written in Rust and compiled into the application binary. A WASI-based plugin model would let plugins ship as .wasm files that the Tauri runtime loads at startup, with capability constraints enforced by the WebAssembly sandbox. This unlocks third-party plugins that the user can install at runtime without recompiling their Tauri app.

Mobile maturity. Tauri 2.0 added iOS and Android targets, but the mobile experience still has rough edges: Xcode tooling integration is immature, many plugins don't yet support mobile, and the docs lag behind desktop. Expect the 2.x line to keep filling these gaps.

For the Bitcoin wallet specifically, the most interesting near-term direction is mobile. A Tauri-on-iOS Bitcoin wallet that shares its Rust core with the desktop wallet — same bitcoin-api crate, same signing logic, same secret handling — is a single-codebase mobile + desktop app of a kind that's been historically very expensive to build. The shared-crate architecture from §4.4 is what makes that affordable.

8. Summary

The five takeaways:

  1. invoke() is a JSON-RPC over the platform webview's host bridge — you can read the implementation in crates/tauri/src/ipc/ and it's not much code. Understanding it makes debugging IPC bugs tractable.
  2. Design your IPC surface so secrets never have a path out. The Bitcoin wallet's 18 commands include cmd_sign_transaction but not cmd_get_private_key. The capability system gates command access; the API surface itself bounds what's even askable.
  3. Tauri is a UI shell, not an application framework. Keep your business logic in plain Rust crates. The framework choice becomes a swap, not a rewrite.
  4. Use tauri-specta for any non-trivial app. The default invoke() is stringly-typed; production teams add tauri-specta for compile-time safety on every command call.
  5. Tauri vs Electron is a threat-model question. If your app holds secrets, the capability-based opt-in permission model is the entire reason you'd pick Tauri. If your app doesn't hold secrets and you have an Electron-only npm dependency, Electron is fine.

If you want the working code that goes with this post — all 22 admin commands, all 18 wallet commands, the React frontends, the Rust backends, the capability files, and the Iced parallel implementations — clone the companion repository on GitHub. The wallet alone is ~1,400 lines of Rust and ~1,200 lines of TypeScript, and you can have it building locally in fifteen minutes.

If you want the long form — 33 chapters, 768 pages, taking the same blockchain from the Bitcoin whitepaper to a deployed Kubernetes cluster — that's the book.


Sources and further reading

Tauri primary sources

Tauri repo issues and discussions cited

Type safety

Production examples

Comparative analyses

The book and its companion repository


Available now on Amazon (paperback + Kindle + hardcover), Gumroad (PDF + EPUB), and Leanpub (pay what you want).

Free resource: Clone the Starter Template

Rust Blockchain

Part 1 of 6

Companion blog posts for Rust Blockchain: A Full-Stack Implementation Guide

Up next

Rust Crypto: SHA-256, ECDSA, Keys

The cryptographic primitives behind every blockchain transaction

More from this blog

B

Build with Rust

6 posts

Production-grade Rust tutorials for backend engineers and systems programmers. Each post is a deep dive into real architecture patterns extracted from a full-stack project: REST APIs with Axum, desktop applications with Iced and Tauri 2, async concurrency with Tokio, encrypted storage with SQLCipher, containerization with Docker, and orchestration with Kubernetes. No toy examples — every code snippet compiles and runs. Companion blog to the book Rust Blockchain: A Full-Stack Implementation Guide