Best Practices for OAuth Authentication

Learn how to securely store tokens implement refresh and logout and follow OAuth security guidelines in Tauri v2 applications

OAuth tokens are the keys to user data. A single oversight in storage, refresh, or logout can expose sensitive information or silently break a user’s session. This guide covers the three essential areas — secure storage, token lifecycle management, and proper logout — along with foundational security habits that apply to any OAuth plugin you use with Tauri v2 and a React frontend.

Secure Token Storage

After a successful OAuth sign-in you receive at least an access token and often a refresh token. Storing them in localStorage or a plain JavaScript variable makes them trivially accessible to any script that runs inside your webview. Even though Tauri apps are not public websites, a compromised dependency or a poorly sanitized input can still lead to token theft.

Never Store Raw Tokens in the Frontend:

Placing tokens in localStorage or sessionStorage means any code executing in the renderer process can read them. This is the most common mistake that turns a minor XSS‑like bug into a full account takeover.

Option 1 – Keep Tokens in the Rust Backend

The strongest approach is to never send the raw token to the frontend at all. After the OAuth callback gives you the tokens, pass them to a Tauri command that stores them in Rust. All subsequent API calls that need authentication go through other commands that read the token and inject it on the backend. That is the same frontend-to-Rust command pattern used throughout Tauri.

src-tauri/src/lib.rs
use tauri::State;
use std::sync::Mutex;
struct AuthTokens {
    access: Mutex<Option<String>>,
    refresh: Mutex<Option<String>>,
}
#[tauri::command]
fn store_tokens(state: State<AuthTokens>, access: String, refresh: String) {
    *state.access.lock().unwrap() = Some(access);
    *state.refresh.lock().unwrap() = Some(refresh);
}
#[tauri::command]
fn get_user_info(state: State<AuthTokens>) -> Result<String, String> {
    let token = state.access.lock().unwrap()
        .clone()
        .ok_or("Not authenticated")?;
    // call your API with the token, e.g. using reqwest
    // ...
    Ok("user data".into())
}
pub fn run() {
    tauri::Builder::default()
        .manage(AuthTokens {
            access: Mutex::new(None),
            refresh: Mutex::new(None),
        })
        .invoke_handler(tauri::generate_handler![store_tokens, get_user_info])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Now the React code never handles the raw token after the initial handshake:

src/auth.ts
import { invoke } from '@tauri-apps/api/core';
async function handleSignIn(url: string) {
  // … exchange the OAuth code for tokens …
  const tokens = await exchangeCodeForTokens(url);
  await invoke('store_tokens', {
    access: tokens.accessToken,
    refresh: tokens.refreshToken,
  });
}

Guaranteed Isolation:

When tokens live only in Rust, they are outside the reach of JavaScript — even if a malicious script runs inside your webview. This is the recommended pattern for production desktop applications.

Option 2 – Encrypted Store on Disk

If your app must access the token from the frontend (for example, to attach it to requests that the webview itself makes), use the tauri-plugin-store plugin with an encryption password. The store persists data to a file and encrypts it at rest, but the decrypted values are still available to your React code.

src/store.ts
import { Store } from '@tauri-apps/plugin-store';
const store = new Store('.tokens.dat');
export async function saveTokens(access: string, refresh?: string) {
  await store.set('access_token', access);
  if (refresh) await store.set('refresh_token', refresh);
  await store.save();
}
export async function getAccessToken(): Promise<string | null> {
  return await store.get('access_token');
}
export async function clearTokens() {
  await store.delete('access_token');
  await store.delete('refresh_token');
  await store.save();
}

The React component that triggers sign-in then saves the tokens immediately:

src/components/LoginButton.tsx
import { saveTokens } from '../store';
async function onSignIn() {
  const response = await startOAuthFlow();
  await saveTokens(response.accessToken, response.refreshToken);
}

Token Refresh

Access tokens are short‑lived — typically one hour or less. Forcing the user to re‑authenticate every time a token expires makes the app feel broken. A refresh token allows the app to obtain a new access token silently.

When Refresh Should Happen

Refresh before the token expires, not after. If the OAuth response includes expires_at, schedule a refresh a few minutes before that timestamp. If you miss that window, a 401 Unauthorized response from an API call is your fallback signal.

src/auth.ts
function scheduleRefresh(expiresAt: number, refreshFn: () => Promise<void>) {
  const now = Date.now();
  const delay = (expiresAt * 1000) - now - 300_000; // 5 minutes before expiry
  if (delay > 0) {
    setTimeout(refreshFn, delay);
  }
}

Using the Plugin’s Refresh API

Plugins like tauri-plugin-google-auth expose a refreshToken function. Other plugins may leave this to you to implement by calling the token endpoint directly from Rust. The following example assumes the plugin provides a refresh method:

src/auth.ts
import { refreshToken } from '@choochmeque/tauri-plugin-google-auth-api';
import { getAccessToken, saveTokens } from '../store';
export async function performTokenRefresh() {
  const currentRefresh = await store.get('refresh_token');
  if (!currentRefresh) throw new Error('No refresh token available');
  const newTokens = await refreshToken({
    refreshToken: currentRefresh,
    clientId: import.meta.env.VITE_GOOGLE_CLIENT_ID,
    clientSecret: import.meta.env.VITE_GOOGLE_CLIENT_SECRET,
  });
  await saveTokens(newTokens.accessToken, newTokens.refreshToken);
  return newTokens.accessToken;
}

The Client Secret Problem:

Desktop apps that require a client secret cannot truly hide it from a determined attacker. Embedding it in a JavaScript file that ships with the application makes it immediately readable. If your OAuth flow demands a secret on the client, route the token exchange through a thin backend service you control, or use a plugin that supports PKCE with no secret.

Wrapping API Calls with Auto‑Refresh

A small utility function can make every network request retry once when it gets a 401:

src/hooks/useApi.ts
import { performTokenRefresh } from '../auth';
import { getAccessToken } from '../store';
async function fetchWithAuth(url: string, options?: RequestInit) {
  let token = await getAccessToken();
  if (!token) throw new Error('Not authenticated');
  const response = await fetch(url, {
    ...options,
    headers: { ...options?.headers, Authorization: `Bearer ${token}` },
  });
  if (response.status === 401) {
    token = await performTokenRefresh();
    return fetch(url, {
      ...options,
      headers: { ...options?.headers, Authorization: `Bearer ${token}` },
    });
  }
  return response;
}

Rotated Refresh Tokens:

Many providers issue a brand‑new refresh token on every refresh call. If you forget to store the new value, the old one will eventually expire and the user will be logged out unexpectedly. Always save the refresh token that comes back from the refresh endpoint.

Logout

A logout that only deletes local data leaves the server‑side token alive. An attacker who already intercepted the token can keep using it. Proper logout revokes the tokens at the provider and then wipes the local copy.

Full Logout Flow

  1. Call the provider’s revoke endpoint with the access token (and refresh token if the provider allows it).
  2. Remove all stored tokens from the app.
  3. Redirect the user to the login page.

If you use tauri-plugin-google-auth:

src/components/LogoutButton.tsx
import { signOut } from '@choochmeque/tauri-plugin-google-auth-api';
import { clearTokens, getAccessToken } from '../store';
async function handleLogout() {
  try {
    const accessToken = await getAccessToken();
    if (accessToken) {
      await signOut({ accessToken });
    }
  } catch (error) {
    console.error('Server‑side revoke failed, clearing locally', error);
  } finally {
    await clearTokens();
    // navigate to login
  }
}

When the plugin does not provide a revoke method, issue the revocation HTTP request from Rust so the token never travels through JavaScript again. For Google, the endpoint is POST https://oauth2.googleapis.com/revoke?token={token}.

Local Clear Is Not Enough:

Removing the token from your app does not invalidate it on Google’s side. If the token was ever logged or intercepted, it can still be used until it naturally expires. Always try the revoke call during logout.

Foundational Security Habits

Always Use PKCE

For desktop and mobile applications the Authorization Code flow with PKCE (Proof Key for Code Exchange) is the only recommended approach. It prevents an attacker from swapping the authorization code for a token, even if they intercept the redirect. Most Tauri OAuth plugins generate the PKCE challenge and verifier automatically or expose a flag to enable it.

Request Only the Scopes You Need

An app that requests drive.readonly when it only needs the user’s email is asking for trouble. Every extra scope increases the damage if tokens leak and makes the consent screen less trustworthy. Audit your scope list and remove anything your application does not actually use.

Separate Test and Production Projects

Create two OAuth client IDs in the Google Cloud Console: one for local development and one for the shipped application. The test client can use http://localhost:1420 as a redirect URI, while the production client uses the final redirect configuration. This prevents accidental misuse and keeps your production project under stricter verification controls.

Secure Redirect URIs

  • On desktop, use a loopback (localhost) redirect with a dynamically assigned port. This is the standard for native apps and is handled automatically by plugins like tauri-plugin-oauth.
  • Avoid custom URI schemes on desktop. They are harder to secure and many providers no longer support them for confidential clients.
  • If your application also targets mobile, prefer platform‑native auth sessions (e.g., via tauri-plugin-auth-session) over a localhost redirect.

Keep Tokens Out of Logs

A single console.log(tokens) in a build can leak credentials into log files that users or system administrators may later access. Before shipping, audit every console.log, println!, and tracing::info! call that could contain token data.

Validate the ID Token

If your app uses the ID token to determine the user’s identity, validate its signature, issuer, audience, and expiration in Rust — not in JavaScript. An unvalidated ID token is a plain‑text string that any client could forge.

Summary

The difference between an OAuth integration that works and one that stays secure over months of production use comes down to three disciplines: never expose raw tokens to the frontend, refresh them before they expire with a mechanism that saves the new values, and always attempt server‑side revocation on logout. Layer on PKCE, scope minimization, and clean separation of test and production configurations, and you have a foundation that protects your users even when something else in your dependency tree goes wrong.

To put these practices into action, start by reviewing your OAuth plugin’s authentication flow documentation, then implement storage and refresh as described here before you implement the rest of your application logic.