Authentication Flow

Understand the complete OAuth authentication flow in Tauri v2 apps using the plugin, from initiating login to securely handling tokens

The OAuth plugin for Tauri v2 solves a fundamental problem for desktop apps: OAuth providers redirect the user back to a URL after they log in, but a desktop app doesn’t have a public URL. The plugin spins up a temporary local web server on localhost, catches the redirect, and passes the authorization data to your Rust backend. This page walks through every step of that flow so you can implement it cleanly in a React + Vite frontend.

Prerequisites

You need the plugin installed in both Rust and JavaScript. Follow the installation from the OAuth Plugin introduction, then add the required capability. Your src-tauri/capabilities/default.json should include:

{
  "permissions": ["oauth:default"]
}

All examples assume a Tauri v2 project with React as the frontend framework and the plugin’s JavaScript bindings available.

The Complete Authentication Flow

The plugin’s job is to manage the local redirect server. Everything else — building the authorization URL, verifying the callback, exchanging the code for tokens, and storing tokens — is your responsibility. This gives you full control over security decisions.

1

Step 1: Start the OAuth Server

The Rust backend starts the plugin’s server. It binds to an available port (or a port you specify) and returns the port number to the frontend. The server listens for one incoming request on http://127.0.0.1:<port>/ and then shuts down automatically after capturing the callback.

In src-tauri/src/lib.rs, define a command that starts the server and emits the redirect URL to the frontend. Creating Your First Rust Command covers the same #[tauri::command] pattern.

use tauri::{command, Emitter, Window};
use tauri_plugin_oauth::start;
#[command]
async fn start_oauth_server(window: Window) -> Result<u16, String> {
    start(move |url| {
        let _ = window.emit("oauth-callback", url);
    })
    .map_err(|e| e.to_string())
}
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_oauth::init())
        .invoke_handler(tauri::generate_handler![start_oauth_server])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

The start function spawns the local server. The closure receives the full URL the OAuth provider redirected to, including query parameters. You must validate this URL — do not trust it until you verify the state parameter.

On the frontend, call the command to get the port:

import { invoke } from "@tauri-apps/api/core";
const port = await invoke<number>("start_oauth_server");

Port Returned:

If you see a valid port number in the console (e.g., 51782), the server is running and ready to receive the callback. The port is randomly assigned by the operating system unless you specify one via configuration.

2

Step 2: Build the Authorization URL and Open the Browser

Now you need the URL that sends the user to the OAuth provider’s login page. This URL must include:

  • client_id — your app’s public identifier from the provider
  • redirect_urihttp://127.0.0.1:<port> (the port you just received)
  • response_type=code — tells the provider to return an authorization code
  • scope — permissions you’re requesting
  • state — a random string you generate to prevent CSRF attacks

Generate the state value in Rust for better security, or on the frontend if you prefer simplicity. For this example we’ll generate it on the frontend and pass it to the provider, but the backend must validate it later.

import { openUrl } from "@tauri-apps/plugin-opener";
function generateState(): string {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
}
const state = generateState();
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
authUrl.searchParams.set("client_id", "YOUR_CLIENT_ID");
authUrl.searchParams.set("redirect_uri", `http://127.0.0.1:${port}`);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", "openid email profile");
authUrl.searchParams.set("state", state);
await openUrl(authUrl.toString());

The openUrl function from the opener plugin opens the user’s default browser. The user logs in, grants permissions, and the provider redirects them back to http://127.0.0.1:<port>/?code=...&state=....

State Parameter Must Be Validated:

Never skip the state check. If you don’t verify it on the callback, an attacker could trick your app into accepting a forged authorization code — a classic CSRF attack.

3

Step 3: Capture the Redirect Callback

When the browser hits the local server, the plugin’s closure fires with the full URL. The frontend needs to listen for the event you emitted in Step 1.

import { listen } from "@tauri-apps/api/event";
const unlisten = await listen<string>("oauth-callback", async (event) => {
  const callbackUrl = new URL(event.payload);
  const code = callbackUrl.searchParams.get("code");
  const returnedState = callbackUrl.searchParams.get("state");
  if (returnedState !== state) {
    console.error("State mismatch! Possible CSRF attack.");
    return;
  }
  // Step 4 will handle the code
});

The plugin closes the browser tab by displaying a simple HTML response. You can customize that message with the response field in the config. The server shuts down automatically after handling the request.

One-Time Server:

The local server handles exactly one request and then stops. If the user refreshes the callback URL in the browser, they’ll see a connection error — that’s expected.

4

Step 4: Exchange the Authorization Code for Tokens

The code you captured is a short-lived credential. To get the real access token (and optionally a refresh token), you must send it to the provider’s token endpoint. This exchange must happen from the backend because it involves your client_secret.

Add a Rust command for the exchange:

use reqwest::Client;
use serde::Deserialize;
#[derive(Deserialize)]
struct TokenResponse {
    access_token: String,
    refresh_token: Option<String>,
    expires_in: Option<u64>,
}
#[command]
async fn exchange_code(
    code: String,
    redirect_uri: String,
    client_id: String,
    client_secret: String,
) -> Result<TokenResponse, String> {
    let client = Client::new();
    let resp = client
        .post("https://oauth2.googleapis.com/token")
        .form(&[
            ("code", &code),
            ("client_id", &client_id),
            ("client_secret", &client_secret),
            ("redirect_uri", &redirect_uri),
            ("grant_type", "authorization_code".to_string()),
        ])
        .send()
        .await
        .map_err(|e| e.to_string())?
        .json::<TokenResponse>()
        .await
        .map_err(|e| e.to_string())?;
    Ok(resp)
}

Register the command and call it from the frontend after receiving the code:

const tokens = await invoke<TokenResponse>("exchange_code", {
  code,
  redirectUri: `http://127.0.0.1:${port}`,
  clientId: "YOUR_CLIENT_ID",
  clientSecret: "YOUR_CLIENT_SECRET",
});

Never Expose the Client Secret:

The client_secret must stay in Rust. Never send it to the frontend or include it in a JavaScript bundle. Anyone with access to your app binary can still extract strings, so for native apps consider using PKCE (Proof Key for Code Exchange) instead of a secret where possible.

5

Step 5: Store and Manage Tokens

After the exchange, you have an access_token and possibly a refresh_token. The access token expires quickly (often 1 hour). Store the refresh token securely so you can get new access tokens without forcing the user to log in again.

For demonstration, we’ll keep tokens in React state. In a real app, use Tauri’s store plugin or the system keychain via the authenticator plugin.

import { useState } from "react";
function useAuth() {
  const [accessToken, setAccessToken] = useState<string | null>(null);
  const [refreshToken, setRefreshToken] = useState<string | null>(null);
  async function login() {
    // Steps 1-4 above, then:
    setAccessToken(tokens.access_token);
    setRefreshToken(tokens.refresh_token);
  }
  async function refreshAccessToken() {
    if (!refreshToken) throw new Error("No refresh token");
    const newTokens = await invoke<TokenResponse>("refresh_token", {
      refreshToken,
      clientId: "YOUR_CLIENT_ID",
      clientSecret: "YOUR_CLIENT_SECRET",
    });
    setAccessToken(newTokens.access_token);
  }
  return { accessToken, login, refreshAccessToken };
}

The Rust refresh command looks nearly identical to the exchange command, but uses grant_type=refresh_token and the stored refresh token.

Token Storage on Desktop:

Storing tokens in React state or localStorage means they’re lost on app restart and are accessible to any script in your WebView. For production, encrypt tokens with the stronghold plugin or store them in the OS keychain.

Refreshing Tokens Proactively

Access tokens expire. Instead of waiting for an API call to fail with a 401, you can refresh shortly before the expiry time. The token response often includes an expires_in field (in seconds). Store the expiry timestamp and set a timer.

const expiresAt = Date.now() + (tokens.expires_in! - 60) * 1000; // 1-minute safety margin
setTimeout(refreshAccessToken, expiresAt - Date.now());

This keeps the user logged in without interruption.

Signing Out

Logging out means revoking the access token on the provider’s side (if they support it) and clearing locally stored tokens.

#[command]
async fn revoke_token(token: String, client_id: String, client_secret: String) -> Result<(), String> {
    let client = Client::new();
    client
        .post("https://oauth2.googleapis.com/revoke")
        .form(&[("token", &token), ("client_id", &client_id), ("client_secret", &client_secret)])
        .send()
        .await
        .map_err(|e| e.to_string())?;
    Ok(())
}

Call it from the frontend, then wipe your local tokens. If revocation fails, still clear local tokens to at least log the user out of your app.

Putting It All Together in a React Component

Below is a complete login button component that chains all steps. It uses the opener plugin for the browser, listens for the callback, exchanges the code, and stores tokens in state.

import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { openUrl } from "@tauri-apps/plugin-opener";
interface TokenResponse {
  access_token: string;
  refresh_token: string | null;
  expires_in: number | null;
}
function generateState(): string {
  const arr = new Uint8Array(32);
  crypto.getRandomValues(arr);
  return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
}
export function GoogleLogin() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  async function handleLogin() {
    setLoading(true);
    setError(null);
    try {
      const state = generateState();
      const port = await invoke<number>("start_oauth_server");
      const unlisten = await listen<string>("oauth-callback", async (event) => {
        unlisten();
        const url = new URL(event.payload);
        const returnedState = url.searchParams.get("state");
        if (returnedState !== state) {
          setError("State mismatch. Login aborted.");
          setLoading(false);
          return;
        }
        const code = url.searchParams.get("code");
        if (!code) {
          setError("No authorization code received.");
          setLoading(false);
          return;
        }
        try {
          const tokens = await invoke<TokenResponse>("exchange_code", {
            code,
            redirectUri: `http://127.0.0.1:${port}`,
            clientId: import.meta.env.VITE_CLIENT_ID,
            clientSecret: import.meta.env.VITE_CLIENT_SECRET,
          });
          // Store tokens in state or secure storage — here we just log them
          console.log("Access token:", tokens.access_token);
          console.log("Refresh token:", tokens.refresh_token);
          setLoading(false);
        } catch (e) {
          setError("Token exchange failed.");
          setLoading(false);
        }
      });
      const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
      authUrl.searchParams.set("client_id", import.meta.env.VITE_CLIENT_ID);
      authUrl.searchParams.set("redirect_uri", `http://127.0.0.1:${port}`);
      authUrl.searchParams.set("response_type", "code");
      authUrl.searchParams.set("scope", "openid email profile");
      authUrl.searchParams.set("state", state);
      await openUrl(authUrl.toString());
    } catch (e) {
      setError("Failed to start authentication.");
      setLoading(false);
    }
  }
  return (
    <div>
      <button onClick={handleLogin} disabled={loading}>
        {loading ? "Signing in..." : "Sign in with Google"}
      </button>
      {error && <p style={{ color: "red" }}>{error}</p>}
    </div>
  );
}

Working Flow:

If after clicking the button your browser opens, you log in, and the console prints an access token, your entire OAuth flow is functioning end-to-end.

Common Mistakes

  • Skipping state validation: Without it, anyone can inject a code into your app. Always check the state before exchanging the code.
  • Exposing the client secret: Never include client_secret in frontend code or .env variables that get bundled into the frontend. Use Rust’s env!("VARIABLE") or a secure secret store.
  • Forgetting to shut down the listener: The unlisten function returned by listen() must be called when the callback arrives, otherwise you’ll have memory leaks and stale handlers on subsequent login attempts.
  • Storing tokens in plain text: Even on a desktop, tokens stored in localStorage can be extracted. Use the stronghold plugin or system keychain for production apps.

Summary

The authentication flow with the OAuth plugin revolves around a temporary local server that bridges the gap between a desktop app and the browser-based OAuth dance. Your Rust backend starts the server, the frontend opens the browser, the provider sends the authorization code to localhost, and your backend exchanges that code for tokens. From there, you manage access and refresh tokens to keep the user signed in.