Auth for Electron and native desktop apps: loopback, deep links, and storage

Desktop applications — Electron apps, native macOS/Windows apps — cannot receive OAuth redirects the same way web apps do. There is no domain to redirect to, no server listening at a public URL. Two mechanisms handle this: loopback redirects (the app starts a local HTTP server to receive the callback) and deep link URLs (the OS routes a custom scheme like myapp:// to the registered application). Each has specific security considerations and appropriate use cases.

Loopback redirect URIs

The app starts an HTTP server on a random available port on localhost before initiating the OAuth flow. The redirect URI is http://127.0.0.1:{PORT}/callback. After the user authenticates, the authorization server redirects to this URL, the local server receives the authorization code, and the app proceeds with the token exchange.

// Electron: loopback OAuth flow
import { createServer } from 'http';
import { shell } from 'electron';
import crypto from 'crypto';

async function initiateOAuthFlow() {
  const state = crypto.randomBytes(32).toString('base64url');
  const codeVerifier = crypto.randomBytes(48).toString('base64url');
  const codeChallenge = crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64url');

  // Start local callback server on a random port
  const { server, port, codePromise } = await startCallbackServer(state);

  const redirectUri = `http://127.0.0.1:${port}/callback`;

  const authUrl = new URL('https://auth.yourapp.com/oauth/authorize');
  authUrl.searchParams.set('client_id', 'your-electron-app-client-id');
  authUrl.searchParams.set('redirect_uri', redirectUri);
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('scope', 'openid profile offline_access');
  authUrl.searchParams.set('state', state);
  authUrl.searchParams.set('code_challenge', codeChallenge);
  authUrl.searchParams.set('code_challenge_method', 'S256');

  // Open browser (system default, not Electron's webview)
  await shell.openExternal(authUrl.toString());

  // Wait for callback
  const code = await codePromise;
  server.close();

  // Exchange code for tokens
  return exchangeCodeForTokens(code, codeVerifier, redirectUri);
}

function startCallbackServer(expectedState) {
  return new Promise((resolve) => {
    let resolveCode;
    const codePromise = new Promise(r => { resolveCode = r; });

    const server = createServer((req, res) => {
      const url = new URL(req.url, 'http://localhost');
      const code = url.searchParams.get('code');
      const state = url.searchParams.get('state');

      if (state !== expectedState) {
        res.end('State mismatch — possible CSRF attack.');
        return;
      }

      res.end('Authentication successful. You can close this tab.');
      resolveCode(code);
    });

    server.listen(0, '127.0.0.1', () => {
      const port = server.address().port;
      resolve({ server, port, codePromise });
    });
  });
}
The redirect URI registered with your authorization server for loopback flows should use the wildcard port pattern. RFC 8252 (OAuth for Native Apps) specifies that loopback redirect URIs with different port numbers must be accepted if they match in scheme, host, and path. Register http://127.0.0.1/callback (without a port) and the auth server should accept any port at that loopback address. Not all servers implement this correctly — verify with your auth provider.

Custom scheme deep links

The app registers a custom URI scheme (e.g., myapp://) with the operating system. The authorization server redirects to myapp://callback?code=...&state=.... The OS recognizes the scheme, finds the registered application, and passes the URL to it.

// macOS/Windows: register custom scheme in Electron
// main.js
const { app } = require('electron');

// Register custom protocol
if (process.defaultApp) {
  if (process.argv.length >= 2) {
    app.setAsDefaultProtocolClient('myapp', process.execPath, [path.resolve(process.argv[1])]);
  }
} else {
  app.setAsDefaultProtocolClient('myapp');
}

// Handle deep link on macOS
app.on('open-url', (event, url) => {
  event.preventDefault();
  handleDeepLink(url);
});

// Handle deep link on Windows (second instance)
app.on('second-instance', (event, commandLine) => {
  const url = commandLine.find(arg => arg.startsWith('myapp://'));
  if (url) handleDeepLink(url);
});

function handleDeepLink(url) {
  const parsed = new URL(url);
  if (parsed.pathname === '/callback') {
    const code = parsed.searchParams.get('code');
    const state = parsed.searchParams.get('state');
    // Validate state, exchange code for tokens
    processOAuthCallback(code, state);
  }
}

Custom schemes have a security concern: on Windows, any application can register for a given custom scheme, and the last one registered wins. A malicious application could register myapp:// and intercept your OAuth callback. PKCE mitigates this: even if a malicious app receives the authorization code, it cannot exchange it without the code verifier, which exists only in memory of the legitimate app.

Which to use: loopback vs deep links

  • Loopback: more secure on Windows (no scheme hijacking possible), but requires a port to be available and the local server to start successfully. Fails in environments with restrictive local firewall policies.
  • Deep links: more reliable delivery (no port binding required), works across app restarts if the app was not running when the callback arrives. Less secure without PKCE. Required for mobile apps (loopback is not possible).
  • Recommendation: use loopback as the primary mechanism for Electron apps on desktop. Fall back to deep links if loopback fails. Always use PKCE regardless of which mechanism you choose.

Token storage in the system keychain

After receiving tokens, you need persistent storage so the user does not log in every time the app starts. The system keychain is the correct choice — it is encrypted, access-controlled to the user account, and survives app reinstalls if the user's account is intact.

// keytar: cross-platform keychain access
// npm install keytar
// Requires native module compilation
import keytar from 'keytar';

const SERVICE = 'com.yourcompany.yourapp';

async function storeTokens(userId, accessToken, refreshToken) {
  // Store each token separately — different secrets have different lifetimes
  await keytar.setPassword(SERVICE, `${userId}:access`, accessToken);
  await keytar.setPassword(SERVICE, `${userId}:refresh`, refreshToken);
}

async function loadTokens(userId) {
  const [accessToken, refreshToken] = await Promise.all([
    keytar.getPassword(SERVICE, `${userId}:access`),
    keytar.getPassword(SERVICE, `${userId}:refresh`),
  ]);
  return { accessToken, refreshToken };
}

// macOS Keychain: stored in login keychain, accessible only to this app + user
// Windows Credential Manager: stored in Windows Vault
// Linux: stored in libsecret (GNOME Keyring or KWallet)
// All are encrypted at rest, access-controlled by the OS

On Linux, libsecret requires a running secret service daemon (GNOME Keyring or KWallet). On headless systems (CI, Docker), neither is present. Detect this at startup and fall back gracefully — either prompt the user that secure storage is unavailable and offer a file-based alternative (with a strong warning), or refuse to cache credentials and require login on each app start. Never silently fall back to plaintext storage.

Electron-specific security notes

Electron's BrowserWindow can load web content, which creates an additional attack surface. When implementing the auth flow, never use Electron's own webview to display the authorization server's login page — always open the system browser. Using Electron's internal webview means the app could theoretically intercept the user's password as they type it, and sophisticated users and enterprise security teams know this. RFC 8252 explicitly requires using the system browser for OAuth in native apps for this reason.

// WRONG: embedded webview (security risk, policy violation)
const authWindow = new BrowserWindow();
authWindow.loadURL(authorizationUrl);

// CORRECT: system browser
const { shell } = require('electron');
shell.openExternal(authorizationUrl);
← Back to blog Try Bastionary free →