Better Auth is one of the nicest things in TypeScript right now: framework-agnostic, plugin-based, and it owns the boring parts (OAuth state, sessions, account linking, CSRF). So whenever I try a new framework, the first thing I check is whether someone already has a Better Auth integration for it.
For desktop apps there are two places to look: the official Electron integration and the community Tauri plugins. Neither drops into Deno Desktop as-is, but both taught us what the moving parts are.
Better Auth ships @better-auth/electron. It has three pieces:
The client half is built around Electron's process model. There's a Node main process that owns windows and OS integration, sandboxed Chromium renderer processes (one per window), and preload scripts that bridge them with contextBridge + ipcRenderer. setupMain() registers the protocol handler for deep links, a user-image:// protocol, CSP, and ipcMain handlers. setupRenderer() then exposes window.requestAuth(), window.onAuthenticated() and friends through the preload. Tokens never reach the renderer.
Deno Desktop has none of that plumbing (comparison):
- No
electron module, so no ipcMain, contextBridge, protocol, or app.on("open-url"). - No separate main process. The Deno runtime and the webview live in one process (CEF) or one coordinated process group (OS webview). They talk through bindings (
win.bind / bindings.x()), which are in-process channels, not IPC. - Deep links are registered but not delivered.
desktop.app.deepLinks registers com.tigawanna.tangerine at package time, but Deno does not hand open-url to JS yet (see Asking upstream below). The custom-scheme return that the Electron flow depends on never arrives.
The good news: the server plugin and the web proxy client are plain HTTP. They don't care what the desktop shell is. Only the Electron client half is Electron-specific, so that's the only part we rewrite.
There's no official Tauri package (issue #8409 was closed as "no official plan yet"), but two community plugins solve the same problem in different ways.
daveyplate/better-auth-tauri replays the OAuth callback inside the app. A server hook rewrites callbackURL onto the app's scheme. When the OS delivers the deep link (@tauri-apps/plugin-deep-link), the client strips the scheme and re-issues the same /api/auth/... request through authClient.$fetch, so the callback lands in the app's cookie jar instead of the browser's:
1
2const href = `/${url.replace(`${scheme}:/${basePath}`, "")}`;
3const response = await authClient.$fetch(href);
4if (response.error?.status !== 302 && response.error?.message) onError?.(response.error);
5else onSuccess?.(new URL(url).searchParams.get("callbackURL"));
Sign-in calls signIn.social({ disableRedirect: true }) and opens the returned URL with @tauri-apps/plugin-opener, so the webview never navigates to GitHub. On macOS/Windows it swaps fetch for @tauri-apps/plugin-http so cookies stick.
DreamsHive/better-auth-tauri copies the official Expo plugin. The server appends the session cookie to the scheme redirect (yourapp://?cookie=…), and the client stores it and sends it back on every request. Webviews can't set Cookie (it's a forbidden header), so the client uses an x-tauri-cookie header and the server plugin rewrites it back to Cookie:
1export const auth = betterAuth({
2 trustedOrigins: ["yourapp://"],
3 plugins: [tauri()],
4});
It also leaves storage to you, and its README recommends the OS keychain over localStorage.
Before building anything, I asked on the Deno repo how Better Auth OAuth should work in Deno Desktop: Deno desktop better auth (discussion #36796). The accepted answer, from @ryux1, settled most of the design:
However, current Deno main only writes the OS registration metadata. The implementation explicitly says that delivering the opened URL to the running app, single-instance forwarding, and the JavaScript open-url event are tracked separately […] >For now I would keep Better Auth on a normal web backend and use a system-browser authorization flow with PKCE and state. The return path needs to be either: a loopback listener bound to 127.0.0.1 on a temporary port; or a manual short-lived authorization-code handoff similar to Better Auth's documented Electron fallback.
What that meant for us:
- The Electron client can't be reused, but the server side can. The Electron package's client pieces depend on Electron APIs. The Better Auth server, the
electron() plugin, and the web proxy flow are plain HTTP, so only the desktop half needs writing: browser launch, PKCE state, code exchange, and token storage. - Registering the scheme doesn't make deep links work. The source the answer points to (
cli/tools/desktop.rs) confirms the app never receives the URL. A custom-scheme OAuth callback can't complete today. - Two return paths: a
127.0.0.1 loopback, plus a paste-the-code fallback modelled on Better Auth's Electron manual token exchange. - The GitHub client secret stays on the API. Nothing secret ships in the binary.
- The loopback is temporary. Once Deno delivers
open-url both at launch and to an already-running app, the custom scheme can replace it.
We ended up building both return paths: the loopback is the main one, and the paste box is the fallback.
- OAuth belongs in the system browser. Real address bar, existing GitHub session, and providers that block embedded webviews still work.
- The return path is the hard part. Electron and Tauri both use a custom-scheme deep link, which Deno can't deliver yet. Per the discussion, use a
127.0.0.1 loopback plus a paste-the-code fallback. - Keep the session out of the webview. Electron keeps it in main, and Tauri fights the webview's cookie rules. With Deno we can keep it in the Deno runtime and only expose
bindings.getSession(). - Storage is your problem. Electron uses
conf in userData, and Tauri asks for a keychain adapter. Deno Desktop has no native secure-storage API yet, so we use a JSON file under the config dir. - Reuse the official server plugin.
electron() already implements the PKCE exchange, so we don't reinvent it. - Secrets stay on the server. The GitHub client secret lives only on
apps/api. The desktop binary only ever sees a PKCE verifier and, after the exchange, a session token.
Same shape as Electron: electron() on the API, electronProxyClient() on the web page, and our own client in the Deno preload. The deep link is swapped for a loopback HTTP server on 127.0.0.1.
1 +-------------+ bindings.requestAuth() +----------------+
2 | Desktop UI | -------------------------> | Deno preload |
3 | (webview) | | PKCE + loopback|
4 +-------------+ +----------------+
5 ^ |
6 | | open system browser
7 | v
8 | +-------------------+ GitHub OAuth +-----------+
9 | | apps/web /auth | <------------------> | apps/api |
10 | | (system browser) | | electron()|
11 | +-------------------+ +-----------+
12 | | ^
13 | | fetch 127.0.0.1:17832/callback?token= |
14 | v |
15 | tangerine:authenticated +----------------+ POST /electron/token |
16 +--------------------------------- | Deno preload | -----------------------------+
17 + navigate("/viewer") | save session | { token, state, verifier }
18 +----------------+
Three apps take part: apps/api (:5000) owns Better Auth and the GitHub secret, apps/web (:3064) hosts the sign-in page the system browser opens, and apps/desktop (:3070) is the native shell. No GitHub secret ever ships in the desktop binary.
apps/api/src/lib/auth.ts is the only Better Auth instance. The desktop-relevant bits are the two plugins and the trusted origin:
1betterAuth({
2 baseURL: envVariables.BETTER_AUTH_URL,
3 basePath: "/api/auth",
4 trustedOrigins: [...AUTHORIZED_ORIGINS, ELECTRON_TRUSTED_ORIGIN],
5 socialProviders: {
6 github: {
7 clientId: envVariables.GITHUB_CLIENT_ID,
8 clientSecret: envVariables.GITHUB_CLIENT_SECRET,
9 scope: [...DEFAULT_GITHUB_SCOPES],
10 mapProfileToUser: (profile: GithubProfile) => ({ githubUsername: profile.login }),
11 },
12 },
13 plugins: [electron(), bearer() ],
14});
electron() gives us /electron/token (PKCE exchange) and transferUser for free.bearer() lets the preload authenticate with Authorization: Bearer <token> instead of rebuilding a signed cookie jar (see lesson 4 below).ELECTRON_TRUSTED_ORIGIN comes from packages/auth/src/electron.ts, so the API, web, and desktop all agree on the scheme.
The desktop sign-in button (src/routes/auth/-components/GitHubSignIn.tsx) doesn't touch OAuth. It calls a binding and waits:
1const desktopSignIn = useMutation({
2 mutationFn: async () => {
3 if (!globalThis.bindings) throw new Error("Desktop bindings unavailable");
4 setAwaitingBrowser(true);
5 await globalThis.bindings.requestAuth();
6 },
7});
deno/auth/request-auth.ts does what electronClient().requestAuth() would do in Electron main:
1export async function requestAuth() {
2 const cfg = readConfig();
3 const state = randomString(16);
4 const codeVerifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
5 const codeChallenge = await generateCodeChallenge(codeVerifier);
6 await rememberPkce(state, codeVerifier);
7
8 const loopback = await startLoopbackServer();
9
10 const url = new URL(cfg.signInURL);
11 url.searchParams.set("client_id", CLIENT_ID);
12 url.searchParams.set("state", state);
13 url.searchParams.set("code_challenge", codeChallenge);
14 url.searchParams.set("loopback", loopback);
15
16 await openExternal(url.toString());
17 return { loopback, state };
18}
The query params are exactly what electronProxyClient expects. loopback is our one addition.
deno/auth/loopback.ts is a tiny Deno.serve on 127.0.0.1 that stands in for the deep link. It starts at app boot (from deno/window.ts), not on click, so the port is ready before the browser needs it:
1const server = Deno.serve(
2 {
3 hostname: "127.0.0.1",
4 port: preferredPort,
5 onListen: (addr) => {
6 boundPort = addr.port;
7 },
8 },
9 async (req) => {
10 const url = new URL(req.url);
11 if (url.pathname === "/health") return Response.json({ ok: true }, { headers: corsHeaders });
12 if (url.pathname !== "/callback") return new Response("Not found", { status: 404 });
13
14 const token = url.searchParams.get("token");
15 if (!token) return Response.json({ ok: false, error: "missing_token" }, { status: 400 });
16 await authenticate({ token });
17 return Response.json({ ok: true }, { headers: corsHeaders });
18 },
19);
The handle lives on globalThis under a Symbol.for(...) key and gets probed via /health before reuse, so HMR or a second requestAuth() doesn't orphan a dead listener.
The discussion suggested a temporary port. We start with a fixed preferred port instead, because random ports kept breaking after HMR and re-sign-in: the browser tab still held the old URL. Deno Desktop can remap it anyway, so the advertised URL always uses the real port from onListen.
This whole step is the part that goes away once Deno delivers open-url. At that point the web page can redirect to com.tigawanna.tangerine:/auth/callback?token=… like the stock Electron flow, and the preload handles it with the same authenticate() call.
The browser half (apps/web/src/routes/auth/-components/GitHubSignIn.tsx) uses the official proxy client (apps/web/src/lib/auth-client.ts):
1export const authClient = createAuthClient({
2 baseURL: clientEnv.VITE_APP_URL,
3 basePath: "/api/auth",
4 fetchOptions: { credentials: "include" },
5 plugins: [electronProxyClient({ protocol: { scheme: ELECTRON_PROTOCOL_SCHEME } })],
6});
On click it forwards the PKCE params into signIn.social and sets the callback back to /auth, so the page can finish the handoff after GitHub returns:
1await authClient.signIn.social({
2 provider: "github",
3 callbackURL: `${window.location.origin}/auth${window.location.search}`,
4 scopes: buildGithubOAuthScopes([...selected]),
5 fetchOptions: { query: electronQuery },
6});
Back on /auth, instead of ensureElectronRedirect() (which would redirect to the custom scheme), it asks the API for an authorization code and fetches the loopback with it:
1const transferred = await authClient.electron.transferUser({
2 fetchOptions: { query: electronQuery },
3});
4const identifier = transferred.data?.electron_authorization_code;
5
6const target = new URL(loopback);
7target.searchParams.set("token", encodeRedirectToken(identifier, electronQuery.state));
8const res = await fetch(target, { headers: { accept: "application/json" } });
9if (res.ok) window.location.replace("/auth/desktop-done");
It uses fetch rather than navigating to the loopback URL, so if the listener is down the page stays put and shows the token for the paste fallback.
deno/auth/authenticate.ts decodes the token, finds the matching PKCE verifier by state, and calls the electron() plugin's exchange endpoint:
1const { identifier, state } = decodeRedirectToken(input.token);
2const codeVerifier = await peekPkce(state);
3
4const res = await fetch(`${authBase(cfg)}/electron/token`, {
5 method: "POST",
6 headers: { "content-type": "application/json" },
7 body: JSON.stringify({ token: identifier, state, code_verifier: codeVerifier }),
8});
9if (!res.ok) throw new Error(await res.text());
10
11await clearPkce(state);
12const data = (await res.json()) as { token: string; user: DesktopAuthUser };
13await saveStored({ cookies, user: data.user, token: data.token });
14getAuthListeners().onAuthenticated?.(data.user);
The same function backs the paste fallback: bindings.authenticate({ token }) from the UI ends up here too.
The session is a JSON file in the Deno runtime, never in the webview:
1~/.config/tangerine-desktop/
2├── session.json # { user, token, cookies }, written after a good exchange
3└── pkce.json # in-flight state → verifier (last 20), survives restarts/HMR
Every call from the preload to the API goes through one helper (deno/auth/cookies.ts):
1export function apiHeaders(stored: StoredSession, extra?: HeadersInit): Headers {
2 const headers = new Headers(extra);
3 headers.set("origin", `${PROTOCOL_SCHEME}:/`);
4 const cookie = cookieHeader(stored.cookies);
5 if (cookie) headers.set("cookie", cookie);
6 if (stored.token) headers.set("authorization", `Bearer ${stored.token}`);
7 if (!headers.has("content-type")) headers.set("content-type", "application/json");
8 return headers;
9}
Deno's fetch runs outside the webview, so it can set Cookie and Origin freely. That's the problem DreamsHive works around with x-tauri-cookie, and here it simply doesn't exist.
Plain JSON is a tradeoff: Deno Desktop doesn't have a secure-storage API yet. When it lands, session-store.ts is the only file that needs to change.
After onAuthenticated, the preload (deno/window.ts) pushes an event into the webview and navigates, because executeJs events can get dropped:
1setAuthListeners({
2 onAuthenticated: (user) => {
3 notifyRenderer("tangerine:authenticated", user);
4 win.navigate(`${appOrigin()}/viewer`);
5 },
6 onAuthError: (message) => notifyRenderer("tangerine:auth-error", { message }),
7});
Route guards ask the preload, not cookies (src/routes/_dashboard/layout.tsx). The same code still works in a plain browser tab via the normal Better Auth session:
1if (hasDesktopBindings() && globalThis.bindings) {
2 const desktopSession = await globalThis.bindings.getSession();
3 if (!desktopSession) throw redirect({ to: "/auth", search: { returnTo: location.pathname } });
4 sessionUser = desktopSession.user;
5} else {
6 const session = await getSession();
7 if (!session) throw redirect({ to: "/auth", search: { returnTo: location.pathname } });
8 sessionUser = session.user;
9}
The whole point is a real user token for GitHub. It lets us list starred repos and pull READMEs and metadata at the authenticated rate limit instead of the anonymous one. deno/auth/session.ts exposes it as bindings.getGithubAccessToken():
1
2const accountId = await resolveGithubAccountRowId(stored);
3const res = await fetch(`${authBase(cfg)}/get-access-token`, {
4 method: "POST",
5 headers: apiHeaders(stored),
6 body: JSON.stringify({ accountId }),
7});
Better Auth keeps the GitHub OAuth token on the API side, and the desktop fetches it on demand. Chapter 3's worker uses it to crawl stars.
Most of these cost an evening each. The full list with debugging tips is in docs/auth.md.
- Deno Desktop can remap your loopback port. Ask for
17832, but always advertise the port from onListen. Start the loopback at boot and keep a strong reference to the server, or the browser ends up posting to a dead port.
1
2 Deno.serve({ port: 17832 }, handler);
3 loopback = "http://127.0.0.1:17832/callback";
4
5
6 Deno.serve({ port: 17832, onListen: ({ port }) => (boundPort = port) }, handler);
7 loopback = `http://127.0.0.1:${boundPort}/callback`;
8
- Use a query string, not a fragment.
#token= never reaches an HTTP server. Use ?token=. - Persist PKCE and peek, don't pop. HMR and React Strict Mode remounts will lose an in-memory verifier. Keep it on disk and clear it only after a successful exchange. If it's gone but
session.json is valid, treat that as already signed in.
1 const verifier = await peekPkce(state);
2 if (!verifier) return (await getSession()) ?? fail("start sign-in again");
3
4 const res = await exchange(identifier, state, verifier);
5 if (res.ok) await clearPkce(state);
6
- The raw session token is not the cookie.
/electron/token returns the DB session token, while the better-auth.session_token cookie is signed. Putting the raw token in Cookie makes get-session come back empty. Use Authorization: Bearer (hence bearer() on the API). - Send
Origin on every cookie-bearing request. Better Auth's CSRF check rejects requests with a missing or null Origin (MISSING_OR_NULL_ORIGIN). Electron sets it for you. In Deno, send com.tigawanna.tangerine:/ yourself.
1
2 headers.set("cookie", `better-auth.session_token=${rawToken}`);
3
4
5 headers.set("authorization", `Bearer ${rawToken}`);
6 headers.set("origin", "com.tigawanna.tangerine:/");
7
- Don't wipe the session on soft failures. Only clear
session.json when the API says the session is gone (user: null) or on sign-out. Never do it on a network error or a bad cookie. - Guard with bindings, not cookies. Server functions reading Start request cookies always look logged-out inside the native shell. The session lives in the preload.
- Tell the UI more than once.
executeJs CustomEvents can be dropped. We send the event, call win.navigate("/viewer"), and poll bindings.getSession() while the UI shows "Waiting for browser…". - Gate the web handoff on the click. Don't poll
transferUser while the user is still picking scopes. If a web session already exists, run only the handoff and not signIn.social as well, or GitHub bounces you back to /auth looking stuck. /get-access-token wants { accountId }, the account row id from list-accounts, not GitHub's numeric id.- Keep the env split straight.
- Desktop
VITE_API_URL points at the API (:5000) for the token exchange. VITE_APP_URL is the desktop UI (:3070).VITE_SIGN_IN_URL is the web /auth page (:3064).- On the API,
BETTER_AUTH_URL is the web origin, so OAuth cookies stay first-party.
- The preload does not hot-reload. Changes to
deno/* and .env only apply after you fully quit and restart the app. Vite HMR only refreshes the UI.
Most of the code in deno/auth/ works around something the runtime doesn't do yet. These are the features that would shrink it, roughly in order of impact:
- Deliver
open-url to JS. A Deno.BrowserWindow or app-level open-url event that fires both when the scheme launches the app and when it's already running. This alone would delete the loopback server (loopback.ts, about 200 lines), the port-remap handling, and the web-side fetch handoff. The web page could just use the stock ensureElectronRedirect().
1
2 Deno.desktop.addEventListener("open-url", async ({ url }) => {
3 const token = new URL(url).searchParams.get("token");
4 if (token) await authenticate({ token });
5 });
6
- Single-instance lock with argument forwarding. Opening
com.tigawanna.tangerine:/… while the app runs should focus the existing window and pass the URL along, not start a second copy. Electron has requestSingleInstanceLock() and Tauri has tauri-plugin-single-instance. Deep links are only half useful without it. - A native auth-session API. Something like macOS's
ASWebAuthenticationSession or Windows' WebAuthenticationBroker: await Deno.desktop.authenticate({ url, callbackScheme }) opens the system browser and resolves with the callback URL. That would replace deep links, the loopback, and the paste fallback in one call.
1
2 const callback = await Deno.desktop.authenticate({
3 url: signInUrlWithPkce,
4 callbackScheme: "com.tigawanna.tangerine",
5 });
6 await authenticate({ token: new URL(callback).searchParams.get("token")! });
7
- Secure storage. Keychain / Credential Manager / Secret Service behind one API. The session is plain JSON in
~/.config/tangerine-desktop/session.json today because there's nothing else; Deno lists this under "doesn't have yet". - Open a URL in the default browser. A built-in
openExternal(url). We shell out to xdg-open / open / cmd /c start in open-external.ts, which is fiddly on Windows and needs --allow-run. - Reliable Deno → webview events. Bindings only go from the webview to Deno. To push "signed in" back, we
executeJs a CustomEvent, which can get dropped, so we also navigate and poll bindings.getSession(). A typed win.emit(channel, payload) paired with a webview-side bindings.on(channel, fn) would remove all three workarounds.
1
2 win.executeJs(`window.dispatchEvent(new CustomEvent("tangerine:authenticated", …))`);
3
4
5 win.emit("auth:changed", user);
6 bindings.on("auth:changed", (user) => navigate({ to: "/viewer" }));
7
- Hot reload for the preload.
--preload and --env-file are read once at startup, so every auth change means fully quitting the app. HMR for deno/* (or at least a "reload preload" command) would make iterating on this flow far less painful. - Predictable
Deno.serve ports. A way to opt out of port remapping when an explicit port is requested, or a documented rule for when it happens. Half of lesson 1 exists because the port you ask for isn't always the port you get. - Typed bindings. A shared type between
win.bind(...) and bindings.*. Today we keep src/lib/desktop-bindings.ts in sync with deno/window.ts by hand, and the docs suggest the same.