Building Production Electron Apps with Vue 3 and Azure SSO
A comprehensive guide to building secure, enterprise-ready Electron applications with Vue 3, TypeScript, and Azure AD authentication.
Robert Fridzema
Fullstack Developer

After shipping three production Electron applications, I've learned that building a "real" desktop app is very different from following a tutorial. This guide covers the patterns and pitfalls I've encountered, with a focus on enterprise requirements like Azure AD authentication.
Why Electron in 2025?
Before diving in, let's address the elephant in the room: Electron has a reputation for being bloated. However, for certain use cases, it remains the best choice:
- Enterprise integration - When you need deep OS integration with web-based systems
- Cross-platform requirement - Ship to Windows and macOS from one codebase
- Existing web expertise - Your team knows Vue/React, not Swift or C#
- Rapid iteration - Web tooling enables fast development cycles
If your app is purely offline or doesn't need web tech, consider native alternatives. But for bridging web systems to the desktop, Electron still wins.
Project Architecture
The most important decision is your process architecture. Electron runs multiple processes:
┌─────────────────────────────────────────────┐ │ Main Process │ │ (Node.js - full system access) │ ├─────────────────────────────────────────────┤ │ Preload Scripts │ │ (Bridge - controlled exposure) │ ├─────────────────────────────────────────────┤ │ Renderer Process │ │ (Chromium - Vue 3 app, sandboxed) │ └─────────────────────────────────────────────┘
Never let your renderer process access Node.js APIs directly. This is the #1 security mistake in Electron apps.
Recommended Structure
packages/ ├── main/ │ ├── src/ │ │ ├── index.ts # Entry point │ │ ├── ipc/ # IPC handlers │ │ ├── services/ # Auth, updates, etc. │ │ └── windows/ # Window management │ └── tsconfig.json ├── preload/ │ ├── src/ │ │ └── index.ts # Exposed APIs │ └── tsconfig.json └── renderer/ ├── src/ │ ├── App.vue │ ├── components/ │ └── stores/ # Pinia stores └── tsconfig.json
Each package has its own TypeScript config and build process. This enforces separation and catches cross-boundary mistakes at compile time.
Setting Up Azure AD Authentication
Enterprise apps need SSO. Here's how to implement Azure AD auth properly in Electron.
1. Register Your App in Azure
In the Azure Portal:
- Go to Azure Active Directory > App registrations
- Create a new registration
- Set redirect URI to
http://localhost(for development) - Under Authentication, enable "Allow public client flows"
- Note your Application (client) ID and Directory (tenant) ID
2. Install MSAL
npm install @azure/msal-node
Note: Use msal-node in the main process, not msal-browser. The browser version won't work correctly in Electron's main process.
3. Implement the Auth Service
// packages/main/src/services/auth.ts import { PublicClientApplication, AuthenticationResult } from '@azure/msal-node' import { BrowserWindow } from 'electron' const MSAL_CONFIG = { auth: { clientId: process.env.AZURE_CLIENT_ID!, authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`, }, } const SCOPES = ['User.Read', 'openid', 'profile', 'email'] export class AuthService { private pca: PublicClientApplication private account: AccountInfo | null = null constructor() { this.pca = new PublicClientApplication(MSAL_CONFIG) } async login(parentWindow: BrowserWindow): Promise<AuthenticationResult> { // Try silent auth first (cached tokens) const accounts = await this.pca.getTokenCache().getAllAccounts() if (accounts.length > 0) { try { return await this.pca.acquireTokenSilent({ account: accounts[0], scopes: SCOPES, }) } catch { // Silent auth failed, continue to interactive } } // Interactive login required return this.interactiveLogin(parentWindow) } private async interactiveLogin( parentWindow: BrowserWindow ): Promise<AuthenticationResult> { // Open auth window const authWindow = new BrowserWindow({ parent: parentWindow, modal: true, width: 500, height: 600, webPreferences: { nodeIntegration: false, contextIsolation: true, }, }) const authUrl = await this.pca.getAuthCodeUrl({ scopes: SCOPES, redirectUri: 'http://localhost', }) return new Promise((resolve, reject) => { // Listen for redirect authWindow.webContents.on('will-redirect', async (event, url) => { if (url.startsWith('http://localhost')) { event.preventDefault() const urlParams = new URL(url).searchParams const code = urlParams.get('code') if (code) { try { const result = await this.pca.acquireTokenByCode({ code, scopes: SCOPES, redirectUri: 'http://localhost', }) this.account = result.account authWindow.close() resolve(result) } catch (error) { authWindow.close() reject(error) } } } }) authWindow.loadURL(authUrl) }) } async getAccessToken(): Promise<string | null> { if (!this.account) return null try { const result = await this.pca.acquireTokenSilent({ account: this.account, scopes: SCOPES, }) return result.accessToken } catch { return null } } async logout(): Promise<void> { const accounts = await this.pca.getTokenCache().getAllAccounts() for (const account of accounts) { await this.pca.getTokenCache().removeAccount(account) } this.account = null } }
4. Expose Auth to Renderer via Preload
// packages/preload/src/index.ts import { contextBridge, ipcRenderer } from 'electron' contextBridge.exposeInMainWorld('electronAPI', { auth: { login: () => ipcRenderer.invoke('auth:login'), logout: () => ipcRenderer.invoke('auth:logout'), getToken: () => ipcRenderer.invoke('auth:getToken'), onAuthStateChange: (callback: (user: User | null) => void) => { ipcRenderer.on('auth:stateChange', (_, user) => callback(user)) }, }, })
5. Handle IPC in Main Process
// packages/main/src/ipc/auth.ts import { ipcMain, BrowserWindow } from 'electron' import { authService } from '../services/auth' export function registerAuthHandlers(mainWindow: BrowserWindow) { ipcMain.handle('auth:login', async () => { try { const result = await authService.login(mainWindow) mainWindow.webContents.send('auth:stateChange', { name: result.account?.name, email: result.account?.username, }) return { success: true } } catch (error) { return { success: false, error: error.message } } }) ipcMain.handle('auth:logout', async () => { await authService.logout() mainWindow.webContents.send('auth:stateChange', null) return { success: true } }) ipcMain.handle('auth:getToken', async () => { return authService.getAccessToken() }) }
6. Use in Vue Components
<script setup lang="ts"> import { ref, onMounted } from 'vue' const user = ref<User | null>(null) const isLoading = ref(false) onMounted(() => { window.electronAPI.auth.onAuthStateChange((newUser) => { user.value = newUser }) }) async function handleLogin() { isLoading.value = true try { await window.electronAPI.auth.login() } finally { isLoading.value = false } } </script> <template> <div v-if="user"> Welcome, {{ user.name }} <button @click="window.electronAPI.auth.logout()">Logout</button> </div> <button v-else @click="handleLogin" :disabled="isLoading"> {{ isLoading ? 'Signing in...' : 'Sign in with Microsoft' }} </button> </template>
Auto-Updates
Production apps need automatic updates. Electron-builder makes this straightforward.
Configuration
# electron-builder.yml appId: com.company.yourapp productName: Your App publish: provider: github owner: your-org repo: your-app releaseType: release
Update Service
// packages/main/src/services/updater.ts import { autoUpdater } from 'electron-updater' import { BrowserWindow } from 'electron' export function initAutoUpdater(mainWindow: BrowserWindow) { autoUpdater.autoDownload = true autoUpdater.autoInstallOnAppQuit = true autoUpdater.on('update-available', (info) => { mainWindow.webContents.send('update:available', info.version) }) autoUpdater.on('update-downloaded', (info) => { mainWindow.webContents.send('update:ready', info.version) }) autoUpdater.on('error', (error) => { console.error('Update error:', error) }) // Check for updates every hour setInterval(() => { autoUpdater.checkForUpdates() }, 60 * 60 * 1000) // Initial check autoUpdater.checkForUpdates() }
State Persistence with Pinia
Keep user state across app restarts:
// packages/renderer/src/stores/settings.ts import { defineStore } from 'pinia' export const useSettingsStore = defineStore('settings', { state: () => ({ theme: 'system' as 'light' | 'dark' | 'system', sidebarCollapsed: false, recentFiles: [] as string[], }), actions: { addRecentFile(path: string) { this.recentFiles = [ path, ...this.recentFiles.filter((p) => p !== path), ].slice(0, 10) }, }, persist: true, // pinia-plugin-persistedstate })
Common Pitfalls
1. Memory Leaks
Electron apps are notorious for memory leaks. Watch out for:
- Event listeners not being removed
- Large objects held in closures
- IPC handlers accumulating
// Bad - accumulates listeners ipcMain.on('some-event', handler) // Good - clean up when window closes mainWindow.on('closed', () => { ipcMain.removeHandler('some-event') })
2. Security Vulnerabilities
Never do this:
// DANGEROUS - allows arbitrary code execution new BrowserWindow({ webPreferences: { nodeIntegration: true, contextIsolation: false, }, })
Always use context isolation and preload scripts.
3. Blocking the Main Process
Long operations in main process freeze the entire app:
// Bad - blocks everything ipcMain.handle('process-file', (_, path) => { return fs.readFileSync(path) // Sync = blocking }) // Good - async and non-blocking ipcMain.handle('process-file', async (_, path) => { return fs.promises.readFile(path) })
Build and Distribution
macOS Code Signing
For distribution outside the App Store, you need:
- Developer ID Application certificate
- Notarization with Apple
# electron-builder.yml mac: hardenedRuntime: true entitlements: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.plist afterSign: scripts/notarize.js
Windows Code Signing
Windows SmartScreen requires an EV certificate or reputation building:
# electron-builder.yml win: certificateSubjectName: 'Your Company Name' signAndEditExecutable: true
Conclusion
Building production Electron apps requires attention to security, performance, and user experience. The key takeaways:
- Enforce process separation - Never give renderer access to Node
- Use MSAL-node for Azure auth - The browser version doesn't work correctly
- Implement auto-updates early - Users expect seamless updates
- Persist state thoughtfully - Pinia with persistence works great
- Test on both platforms - Differences will surprise you
The combination of Vue 3's reactivity, TypeScript's safety, and Electron's capabilities makes for a powerful development experience - once you understand the architecture.
Have questions about Electron development? Get in touch - I'm always happy to discuss desktop app architecture.
Related Articles

PDF Processing in the Browser with pdf-lib and Tesseract.js
Build powerful PDF manipulation and OCR capabilities entirely in the browser, keeping sensitive documents private and eliminating server dependencies.

Getting Started with Nuxt 3 - A Practical Guide
Learn how to build modern web applications with Nuxt 3, from project setup to deployment. Includes tips from migrating a real project.

Migrating Legacy Databases: A Real-World n8n + PostgreSQL Story
How we migrated decades of business data from a legacy system to a modern Laravel application using n8n workflows and PostgreSQL.