Better Auth - Electron Desktop Integration
Better Auth works seamlessly with Electron using the React client in the renderer process with secure IPC patterns.
AI Tooling
IMPORTANT: Before implementing Better Auth in Electron, consult:
-
AI Documentation: https://better-auth.com/llms.txt
-
MCP Server: https://mcp.chonkie.ai/better-auth/better-auth-builder/mcp
Use Context7 to look up Better Auth patterns:
get_library_docs({ libraryName: "better-auth", topic: "react client" }) get_library_docs({ libraryName: "better-auth", topic: "session management" })
Installation
Install Better Auth and electron-store for session persistence
npm install better-auth electron-store
Architecture
┌─────────────────────────────────────────────────────┐ │ Main Process │ │ - Session validation via IPC │ │ - Secure token storage (electron-store) │ │ - Auth state management │ └───────────────────┬─────────────────────────────────┘ │ IPC (contextBridge) ┌───────────────────▼─────────────────────────────────┐ │ Preload Script │ │ - Expose safe auth APIs to renderer │ │ - No direct Node.js access │ └───────────────────┬─────────────────────────────────┘ │ ┌───────────────────▼─────────────────────────────────┐ │ Renderer Process │ │ - Better Auth React client │ │ - UI components (React/shadcn) │ │ - Uses window.authApi from preload │ └─────────────────────────────────────────────────────┘
Backend Configuration
Your Electron app needs a Better Auth backend (can be local or remote):
Backend (server/auth.ts ):
import { betterAuth } from "better-auth" import { drizzleAdapter } from "better-auth/adapters/drizzle" import { db } from "./db"
export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "sqlite" }), emailAndPassword: { enabled: true, }, socialProviders: { github: { clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }, }, // Trust Electron app origin trustedOrigins: [ "app://.", // Electron custom protocol "file://", // File protocol "http://localhost", // Dev server ], })
Main Process
main.ts :
import { app, BrowserWindow, ipcMain, shell } from "electron" import Store from "electron-store" import path from "path"
// Secure persistent storage for auth state const store = new Store({ name: "auth", encryptionKey: "your-encryption-key", // Use secure key management })
function createWindow() { const win = new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: path.join(__dirname, "preload.js"), contextIsolation: true, // REQUIRED - security nodeIntegration: false, // REQUIRED - security sandbox: true, // RECOMMENDED }, })
// Open OAuth callbacks in external browser win.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith("http")) { shell.openExternal(url) return { action: "deny" } } return { action: "allow" } })
win.loadFile("index.html") }
// IPC handlers for auth operations ipcMain.handle("auth:get-stored-session", async () => { return store.get("session", null) })
ipcMain.handle("auth:store-session", async (_, session) => { store.set("session", session) })
ipcMain.handle("auth:clear-session", async () => { store.delete("session") })
ipcMain.handle("auth:get-api-url", async () => { return process.env.AUTH_API_URL || "http://localhost:3000" })
app.whenReady().then(createWindow)
Preload Script
preload.ts :
import { contextBridge, ipcRenderer } from "electron"
// Expose secure auth API to renderer contextBridge.exposeInMainWorld("authApi", { // Session persistence getStoredSession: () => ipcRenderer.invoke("auth:get-stored-session"), storeSession: (session: unknown) => ipcRenderer.invoke("auth:store-session", session), clearSession: () => ipcRenderer.invoke("auth:clear-session"),
// Config getApiUrl: () => ipcRenderer.invoke("auth:get-api-url"),
// Events onAuthStateChange: (callback: (session: unknown) => void) => { ipcRenderer.on("auth:state-changed", (_, session) => callback(session)) }, })
// Type declarations for renderer declare global { interface Window { authApi: { getStoredSession: () => Promise<unknown> storeSession: (session: unknown) => Promise<void> clearSession: () => Promise<void> getApiUrl: () => Promise<string> onAuthStateChange: (callback: (session: unknown) => void) => void } } }
Renderer Process (React)
Auth Client (src/lib/auth-client.ts ):
import { createAuthClient } from "better-auth/react"
// Get API URL from main process const getAuthClient = async () => { const baseURL = await window.authApi.getApiUrl()
return createAuthClient({ baseURL, // Custom fetch to handle Electron environment fetchOptions: { credentials: "include", }, }) }
// Export singleton let authClientPromise: ReturnType<typeof getAuthClient> | null = null
export const getClient = () => { if (!authClientPromise) { authClientPromise = getAuthClient() } return authClientPromise }
// Hook for components export function useAuthClient() { const [client, setClient] = useState<Awaited<ReturnType<typeof getAuthClient>> | null>(null)
useEffect(() => { getClient().then(setClient) }, [])
return client }
Simplified Client (if API URL is static):
import { createAuthClient } from "better-auth/react"
export const authClient = createAuthClient({ baseURL: "http://localhost:3000", // Your auth server })
export const { signIn, signUp, signOut, useSession } = authClient
Sign In Component
import { useState } from "react" import { authClient } from "@/lib/auth-client" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export function SignIn() { const [email, setEmail] = useState("") const [password, setPassword] = useState("") const [loading, setLoading] = useState(false) const [error, setError] = useState<string | null>(null)
const handleSignIn = async (e: React.FormEvent) => { e.preventDefault() setLoading(true) setError(null)
const { data, error } = await authClient.signIn.email({
email,
password,
})
if (error) {
setError(error.message)
} else if (data?.session) {
// Persist session to main process store
await window.authApi.storeSession(data.session)
}
setLoading(false)
}
return ( <Card className="w-full max-w-md mx-auto"> <CardHeader> <CardTitle>Sign In</CardTitle> </CardHeader> <CardContent> <form onSubmit={handleSignIn} className="space-y-4"> <Input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} required /> <Input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} required /> {error && <p className="text-sm text-destructive">{error}</p>} <Button type="submit" className="w-full" disabled={loading}> {loading ? "Signing in..." : "Sign In"} </Button> </form> </CardContent> </Card> ) }
Session Management
import { useEffect } from "react" import { authClient } from "@/lib/auth-client" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"
export function UserMenu() { const { data: session, isPending } = authClient.useSession()
// Sync session changes to main process useEffect(() => { if (session) { window.authApi.storeSession(session) } }, [session])
const handleSignOut = async () => { await authClient.signOut() await window.authApi.clearSession() }
if (isPending) { return <div className="h-8 w-8 animate-pulse rounded-full bg-muted" /> }
if (!session) { return <Button variant="outline">Sign In</Button> }
return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="relative h-8 w-8 rounded-full"> <Avatar className="h-8 w-8"> <AvatarImage src={session.user.image || ""} /> <AvatarFallback> {session.user.name?.[0]?.toUpperCase()} </AvatarFallback> </Avatar> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem className="font-medium"> {session.user.name} </DropdownMenuItem> <DropdownMenuItem className="text-muted-foreground"> {session.user.email} </DropdownMenuItem> <DropdownMenuItem onClick={handleSignOut}> Sign Out </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ) }
Restore Session on App Launch
// App.tsx - Restore session when app starts import { useEffect, useState } from "react" import { authClient } from "@/lib/auth-client"
export function App() { const [initialized, setInitialized] = useState(false)
useEffect(() => { async function restoreSession() { // Check for stored session from previous launch const storedSession = await window.authApi.getStoredSession()
if (storedSession) {
// Validate session with server
const { data: currentSession } = await authClient.getSession()
if (!currentSession) {
// Session expired, clear stored data
await window.authApi.clearSession()
}
}
setInitialized(true)
}
restoreSession()
}, [])
if (!initialized) { return <div>Loading...</div> }
return <MainApp /> }
OAuth in Electron
For OAuth flows, open the auth URL in the system browser and handle the callback:
import { shell } from "electron" // Main process only
// In main process - handle OAuth callback app.setAsDefaultProtocolClient("myapp") // Register custom protocol
app.on("open-url", (event, url) => { // Handle OAuth callback: myapp://auth/callback?code=... if (url.includes("/auth/callback")) { mainWindow.webContents.send("auth:oauth-callback", url) } })
Security Best Practices
-
Always use contextIsolation - Never expose Node.js to renderer
-
Encrypt stored sessions - Use electron-store with encryption
-
Validate sessions on startup - Check with server before trusting local state
-
Handle OAuth via system browser - More secure than in-app browser
-
Use sandbox mode - Additional security layer
-
Clear sensitive data on sign out - Both in-memory and persistent storage
Documentation: https://better-auth.com/docs