GenerateSaaS

Sign-in and security

Device authorization sign-in for the desktop app plus the shell's security boundaries - CSP, navigation, the preload bridge, and the OS keychain.

The app has no password form: sign-in runs the OAuth 2.0 Device Authorization Grant through the Better Auth deviceAuthorization and bearer plugins, with the desktop client sending client_id "desktop".

The app's "Sign in" requests a device/user code, opens the system browser at ${baseUrl}/auth/device?user_code=XXXX, and shows the user code.
The user approves in the web app, where they are already signed in.
The app polls, receives a bearer token, and stores it in the OS keychain via Electron safeStorage behind a validated IPC handler (window.api.auth) - never exposed to renderer JavaScript.

The verification page is your web frontend's /auth/device route with its deviceAuthorizationClient wiring. See your project's Authentication guide for the shared Better Auth setup.

Account security settings

/settings/security (the Security entry beside Profile and Billing in the settings rail) manages the signed-in user's own credentials. The screen body is shared with the web app (@repo/ui/screens/settings/security), bound here to the same authClient through the app-core auth.security seam. /account/security keeps a permanent redirect, because a deep link may still carry it.

Sub-flowIn the appCall
PasswordInline form: current, new, confirm, plus "sign out of all other devices". A user with no credential account is sent to the web instead, because setting a first password rides a captcha-protected email.authClient.changePassword
Two-factor (TOTP)Enable (QR + manual key + 6-digit verification + backup codes), disable, regenerate backup codes.authClient.twoFactor.*
Signed-in devicesLists every session with this device badged; revoke one or all others.authClient.listSessions, revokeSession, revokeOtherSessions
PasskeysRead-only notice plus a button that opens the web security page in the system browser. WebAuthn registration cannot complete in the renderer - see below.-

The TOTP secret never leaves the machine: the otpauth:// URI comes from the auth API, and both the QR (qrcode, into a data: URL) and the manual-entry key are produced in the renderer.

Passkeys are web-only in the app. From the packaged file: renderer navigator.credentials.create() throws SecurityError (public-key credentials need an HTTPS or localhost origin); from a dev http://localhost renderer the promise never settles, because Electron ships no browser-side authenticator delegate and no OS passkey sheet appears. The shared card branches on the CAPABILITY rather than on a flag: this app binds no passkey member on the auth.security seam, so the card offers the web instead. Bind one once a real ceremony completes in a packaged build.

Verifying TOTP enrollment, disabling 2FA, and changing a password with "sign out other devices" all mint a new session and delete the current one. A cookie browser absorbs that silently; this app carries a bearer token, so the auth client's onSuccess adopts the replacement from the set-auth-token header (adoptRotatedToken). Remove that and the user is signed out the moment they enable 2FA.

Security and native boundaries

The renderer is fully isolated - contextIsolation on, sandbox on, nodeIntegration off - and reaches main only through the narrow preload bridge (window.api), never raw ipcRenderer. Every main-process handler validates its inputs.

BoundaryBehaviorWhere to extend
Content-Security-Policyconnect-src is 'self' and names no backend origin: every backend call rides the main-process bridge (backend-transport.ts), which CSP does not govern. img-src allows any HTTPS image (OAuth provider avatars).Repoint the backend with config.desktop.baseUrl (pinned at build time in apps/desktop/src/main/backend-url.ts), not connect-src. Restrict image hosts in buildCspPolicy (apps/desktop/src/main/csp-policy.ts).
External linksOpen in the system browser via window.api.shell.openExternal, which accepts only http(s) URLs, so a stray file: or javascript: link cannot open. New windows are always denied.-
NavigationTop-level navigation and redirects are allowlisted to the app's own document (the dev server origin, or the packaged file: renderer); anything else is blocked.-
Auth tokenStored in the OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service) via safeStorage, with a dev-only file fallback. Never in renderer web storage.-
Model credentialsThe app holds none. Every model runs through a coding CLI the user signed into themselves.See Models.
Integration credentialsThe app holds none either. A user's service connections are stored encrypted in your backend and never returned to any client; an OAuth sign-in runs in the system browser.See Integrations.
IPC surfaceEvery renderer-callable method is an explicit window.api.* entry backed by one validating ipcMain.handle. There is no general-purpose bridge.Add a typed method to the preload bridge plus a validating handler in apps/desktop/src/main/ipc.ts.
Device sign-inAccepts any client_id by default (the desktop sends "desktop").Set validateClient on the deviceAuthorization plugin in @repo/auth to restrict clients in production.

One policy builder (csp-policy.ts) enforces CSP two ways so dev and packaged cannot drift: a header CSP for the dev server (onHeadersReceived), and a build-time <meta> CSP for the packaged file: renderer, which headers do not reach.

The renderer hands main a request PATH; main composes it against the origin pinned from config.desktop.baseUrl and refuses anything that resolves off it, so a compromised renderer cannot redirect main's bearer-carrying fetch. Auth is a bearer token, not cookies, and main sends no Origin header, so the backend treats it as a native client and never CORS-checks it. A failing API call is therefore a bridge or backend-URL issue, not connect-src; your backend's TRUSTED_ORIGINS still governs the web frontends only.

On this page