@dreamshive/better-auth-tauri
v0.1.0
Published
Better Auth plugin for Tauri desktop apps — handles OAuth via the system browser with deep-link callbacks, cookie bridging via URL, and secure token storage hooks.
Downloads
98
Maintainers
Readme
@dreamshive/better-auth-tauri
Better Auth plugin for Tauri desktop apps. OAuth happens in the user's system browser, not the embedded webview — the session cookie rides back through a custom URI scheme deep link, and every outgoing request in the app is transparently authenticated.
Mirrors the architecture of the official @better-auth/expo package, adapted for Tauri's browser-style webview.
Why use this
- OAuth providers that block embedded webviews still work (Google's
disallowed_useragent, enterprise SSO, upcoming Microsoft enforcement). - Users reuse their existing browser sessions — no re-logging-in for github.com / google.com from inside your app.
- Full address bar + padlock — users can verify the provider's domain before signing in.
- Same Better Auth API surface — you keep using
authClient.signIn.social(...)anduseSession()as if nothing changed.
How it works
┌──────────────────┐ ┌────────────────┐ ┌──────────────┐
│ Tauri app │ │ System browser │ │ Auth service │
│ (WKWebView/ │ │ (Safari/Chrome)│ │ │
│ WebView2) │ │ │ │ │
└────────┬─────────┘ └────────┬───────┘ └──────┬───────┘
│ signIn.social() │ │
│ ─────────────────────────────┼───────────────────────>│
│ │ │
│ <── { url, disableRedirect } ──────────────────────────│
│ │ │
│ shell.open(url) │ │
│ ────────────────────────────>│ │
│ │ /tauri-authz-proxy │
│ │ ──────────────────────>│
│ │ Set-Cookie: state=... │
│ │ <──────────────────────│
│ │ │
│ │ github.com OAuth... │
│ │ <─redirect to callback─│
│ │ /callback/github │
│ │ ──────────────────────>│
│ │ │
│ │ <── 302 sokudo://?cookie=<session>
│ │ (after-hook appends cookie
│ │ to custom-scheme redirect)
│ │ │
│ <── OS routes sokudo:// ────│ │
│ │
│ store cookie in OS keychain │
│ inject as `x-tauri-cookie` header on future calls │
│ ─────────────────────────────────────────────────────>│
│ │
│ <── server plugin rewrites x-tauri-cookie → Cookie ───│
│ Better Auth validates, returns session │Two things worth calling out:
x-tauri-cookieheader smuggling. The Fetch spec marksCookieas a forbidden header name. Webviews silently drop attempts to set it. We smuggle the session throughx-tauri-cookieand the server plugin rewrites it back toCookiebefore Better Auth inspects the request.disableRedirect: trueauto-injection. Better Auth's Vue/React client normally navigateswindow.location.hrefto the OAuth URL — fine in a browser, catastrophic in a single-window Tauri app (the webview takes over with the provider's login page). The client plugin injectsdisableRedirect: trueinto/sign-in/socialand/sign-in/oauth2requests so the client stays put.
Installation
bun add @dreamshive/better-auth-tauri
# or: npm install / pnpm add / yarn addPeer Tauri plugins (install only in the desktop app that consumes the client):
bun add @tauri-apps/plugin-shell @tauri-apps/plugin-deep-link
cd src-tauri
cargo add tauri-plugin-shell tauri-plugin-deep-linkIf you want the included focus/online managers to work, you also need:
bun add @tauri-apps/api # onFocusChanged lives hereTauri configuration
1. Register your URI scheme
src-tauri/tauri.conf.json:
{
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["yourapp"]
}
}
}
}2. Initialize the Rust plugins
src-tauri/src/lib.rs:
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_deep_link::init())
// ... your other plugins
.run(tauri::generate_context!())
.expect("error while running tauri application");
}3. Grant permissions
src-tauri/capabilities/default.json:
{
"permissions": [
"core:default",
"shell:allow-open",
"deep-link:default"
]
}4. (Highly recommended) Add tauri-plugin-single-instance
Without this, opening a yourapp:// deep link from a browser while the Tauri app is already running may spawn a second window instead of reusing the first. Install:
cd src-tauri
cargo add tauri-plugin-single-instance --features deep-linkRegister it before any other plugin in lib.rs:
use tauri::{Emitter, Manager};
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
for arg in argv.iter().skip(1) {
if arg.starts_with("yourapp://") {
let _ = app.emit("deep-link://new-url", vec![arg.clone()]);
}
}
}))
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_deep_link::init())
// ...
}Server setup (Better Auth backend)
import { betterAuth } from "better-auth";
import { tauri } from "@dreamshive/better-auth-tauri";
export const auth = betterAuth({
// ... your existing config
trustedOrigins: ["yourapp://"],
plugins: [
tauri(),
// ... your other plugins
],
});The server plugin:
- Remaps
tauri-origin→originso CSRF / trusted-origin checks accept the custom scheme. - Rewrites
x-tauri-cookie→Cookieso the session cookie smuggled by the client is visible to Better Auth. - Appends
?cookie=<Set-Cookie>to OAuth redirect URLs targeting custom schemes so the Tauri app can bridge the cookie jar. - Exposes a
/tauri-authorization-proxyendpoint that plants the OAuthstatecookie in the system browser's jar before redirecting to the provider (prevents callback state mismatch).
CORS
Your auth server's CORS config must allow the custom headers this plugin sends:
cors({
origin: [/* your frontend origins */, "yourapp://"],
credentials: true,
allowedHeaders: [
"Content-Type",
"Authorization",
"Cookie",
"tauri-origin",
"x-tauri-cookie",
"x-skip-oauth-proxy",
],
})Client setup
import { createAuthClient } from "better-auth/vue"; // or /react, /solid, etc.
import { tauriClient } from "@dreamshive/better-auth-tauri/client";
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { isTauri } from "@tauri-apps/api/core";
export const authClient = createAuthClient({
baseURL: "https://auth.example.com",
fetchOptions: {
// Always use tauriFetch in Tauri — the webview's native fetch() drops
// our smuggled headers and hits CORS on custom schemes.
customFetchImpl: (...args) =>
isTauri() ? tauriFetch(...args) : fetch(...args),
},
plugins: [
tauriClient({
scheme: "yourapp", // must match tauri.conf.json
cookiePrefix: "yourapp", // must match your server's cookie prefix
storage: {
// See "Secure storage" below — localStorage is dev-only.
getItem: (k) => localStorage.getItem(k),
setItem: (k, v) => localStorage.setItem(k, v),
},
}),
],
});Then use Better Auth normally:
await authClient.signIn.social({
provider: "github",
callbackURL: "/", // auto-rewritten to yourapp:///
});The plugin takes it from there.
Secure storage
localStorage is not appropriate for production — any XSS in your Tauri webview can read it directly.
For production, back the storage option with an encrypted store. Options, ranked by ergonomics:
Option A — OS keychain (recommended)
Expose keyring-rs via custom Tauri commands. Per-platform native secret store (Apple Keychain, Windows Credential Manager, Secret Service on Linux).
cd src-tauri
cargo add keyring --features apple-native,windows-native,linux-native-sync-persistent// src-tauri/src/keyring.rs
use keyring::Entry;
#[tauri::command]
pub fn keyring_get(service: String, key: String) -> Result<Option<String>, String> {
let entry = Entry::new(&service, &key).map_err(|e| e.to_string())?;
match entry.get_password() {
Ok(v) => Ok(Some(v)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
pub fn keyring_set(service: String, key: String, value: String) -> Result<(), String> {
let entry = Entry::new(&service, &key).map_err(|e| e.to_string())?;
entry.set_password(&value).map_err(|e| e.to_string())
}Register in lib.rs:
mod keyring;
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
keyring::keyring_get,
keyring::keyring_set,
])And in your client storage adapter:
import { invoke } from "@tauri-apps/api/core";
const SERVICE = "com.yourapp.session";
const keyringStorage = {
getItem: (key: string) => invoke<string | null>("keyring_get", { service: SERVICE, key }),
setItem: (key: string, value: string) => invoke("keyring_set", { service: SERVICE, key, value }),
};Option B — tauri-plugin-stronghold
Official Tauri encrypted vault (IOTA Stronghold). Requires a master passphrase that the app supplies.
Option C — @tauri-apps/plugin-store
On-disk JSON, not encrypted, but lives behind Tauri IPC (not accessible from document.localStorage). Marginal improvement over localStorage.
Options
tauri(options?) — server plugin
| Option | Default | Description |
| --- | --- | --- |
| disableOriginOverride | false | Don't remap tauri-origin → origin. |
tauriClient(options) — client plugin
| Option | Default | Description |
| --- | --- | --- |
| scheme | — (required) | Custom URI scheme registered in tauri.conf.json. |
| storage | — (required) | Key/value storage adapter. Async methods supported. |
| storagePrefix | "better-auth" | Prefix for storage keys. |
| cookiePrefix | "better-auth" | Server cookie-name prefix(es). |
| disableCache | false | Disable local /get-session response cache. |
| refetchOnWindowFocus | true | Refetch session when the Tauri window regains focus. |
| refetchOnReconnect | true | Refetch session when the network comes back online. |
Exports
// Server
import { tauri, tauriAuthorizationProxy } from "@dreamshive/better-auth-tauri";
// Client
import {
tauriClient,
setupTauriFocusManager, // manual wiring if you disabled auto-setup
setupTauriOnlineManager,
} from "@dreamshive/better-auth-tauri/client";macOS dev workflow
Deep-link URI schemes on macOS are registered with Launch Services through the app's .app bundle's Info.plist. tauri dev does not produce a bundle — it runs a raw binary which macOS can't route deep links to.
Official Tauri guidance for developing against the deep-link plugin on macOS:
# Build once (also run after any Rust/plugin/capability change)
bun tauri build --debug
# Open the bundle so Launch Services indexes it
open src-tauri/target/debug/bundle/macos/your-app.appFrontend (Vue/React/Svelte) HMR works fine through the built app if you point frontendDist at your dev server:
// src-tauri/tauri.dev.conf.json
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"build": {
"frontendDist": "http://localhost:3000",
"beforeBuildCommand": ""
}
}bun tauri build --debug --config src-tauri/tauri.dev.conf.jsonKnown limitations
- macOS dev loop requires a bundle (see above). Windows and Linux don't have this friction.
- Scheme hijacking — any app can register itself as a handler for your custom scheme. Production apps with meaningful attack surface should consider Universal Links (macOS) or App Links (Android equivalent).
- Storage is the caller's responsibility. This plugin doesn't bundle a secure-storage primitive; you provide the adapter.
- No SSR support. Tauri apps are client-only.
Comparison to @better-auth/expo
Feature-for-feature parity on the auth flow, plus two Tauri-specific additions:
| Feature | Expo | Tauri (this) |
| --- | --- | --- |
| OAuth via system browser | ✅ | ✅ |
| Cookie bridge via URL query param | ✅ | ✅ |
| Authorization proxy endpoint for state | ✅ | ✅ |
| Redirect-to-scheme callback rewrite | ✅ | ✅ |
| Sign-out local cleanup | ✅ | ✅ |
| Third-party cookie filter | ✅ | ✅ |
| Focus refetch manager | ✅ (React Query) | ✅ (notifies Better Auth session signal) |
| Online refetch manager | ✅ (React Query) | ✅ (notifies Better Auth session signal) |
| x-tauri-cookie smuggling | — | ✅ required (browser forbids Cookie) |
| disableRedirect: true injection | — | ✅ required (browser has window.location) |
License
MIT © Rully Ardiansyah
