Cross-Platform Code Signing for Electron Apps: A Battle-Tested Guide

The Golden Rules

After 48 hours of debugging, here are the non-negotiable truths about cross-platform code signing:

  1. Build and sign Mac on Mac, Windows on Windows - Don't fight the platform
  2. Use GitHub Actions - It's free, reliable, and saves you from architecture hell
  3. Azure Trusted Signing for Windows - Cheap, easy, no USB dongles
  4. Apple Developer Program via web portal - More forgiving than Xcode for initial setup
  5. Build unsigned, then sign separately - electron-builder's Azure integration is broken on Mac

Quick Setup: Azure Trusted Signing for Windows

Step 1: Azure Setup

  1. Create an Azure subscription
  2. Create a Trusted Signing Account (not Key Vault!)
  3. Create an App Registration in Entra ID
  4. Generate a Client Secret for the app registration
  5. Assign the Trusted Signing Certificate Profile Signer role to your app

You'll need these values:

Step 2: Build Configuration

Remove spaces from your product name everywhere:

// package.json
{
  "productName": "YourAppName",  // NO SPACES!
  "build": {
    "productName": "YourAppName",
    "win": {
      // DON'T use azureSignOptions from macOS - it's broken on Mac
      // Build unsigned instead when building from Mac
      // (azureSignOptions may work fine on Windows)
    }
  }
}

Step 3: GitHub Actions (The Right Way)

# .github/workflows/build-windows.yml
runs-on: windows-latest  # MUST be x64, not ARM
steps:
  - name: Install TrustedSigning Module
    shell: powershell
    run: Install-Module -Name TrustedSigning -Force

  - name: Build Unsigned
    run: npm run build:win:unsigned

  - name: Sign with Azure
    shell: powershell
    env:
      AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
      AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
      AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
    run: |
      $env:AZURE_TENANT_ID = "${{ secrets.AZURE_TENANT_ID }}"
      $env:AZURE_CLIENT_ID = "${{ secrets.AZURE_CLIENT_ID }}"
      $env:AZURE_CLIENT_SECRET = "${{ secrets.AZURE_CLIENT_SECRET }}"

      Invoke-TrustedSigning `
        -Endpoint "https://eus.codesigning.azure.net" `
        -CodeSigningAccountName "YourAccount" `
        -CertificateProfileName "YourProfile" `
        -Files "dist/YourApp-Setup-*.exe"

Quick Setup: Apple Code Signing for macOS

Step 1: Apple Developer Setup

  1. Join Apple Developer Program ($99/year) via web portal (not Xcode)
  2. In Xcode: Settings → Accounts → Manage Certificates → Create "Developer ID Application"
  3. Export certificate as .p12 for CI (keep password safe)

Step 2: Create Entitlements

<!-- entitlements.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>com.apple.security.cs.disable-library-validation</key>
  <true/>
</dict>
</plist>

Step 3: GitHub Actions for macOS

# .github/workflows/build-macos.yml
runs-on: macos-latest
steps:
  - name: Import Certificates
    run: |
      echo "${{ secrets.MAC_CERTS_P12 }}" | base64 --decode > cert.p12
      security create-keychain -p temp build.keychain
      security import cert.p12 -k build.keychain -P "${{ secrets.MAC_CERTS_PASSWORD }}"

  - name: Build and Sign
    env:
      APPLE_ID: ${{ secrets.APPLE_ID }}
      APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
      APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
    run: npm run dist

Common Errors and Solutions

Error: "SignTool.exe crashed with exit code 3"

Problem: You're on ARM64 Windows. SignTool is x64-only. Solution: Use GitHub Actions with windows-latest (x64)

Error: "missing terminator" in PowerShell

Problem: PowerShell quote escaping nightmare Solution: Use variables instead of inline strings:

# BAD
Write-Host "Path: $($env:PATH)"

# GOOD
$path = $env:PATH
Write-Host "Path: $path"

Error: "One of '--azure-key-vault-*' must be supplied"

Problem: You're using AzureSignTool (wrong tool!) Solution: Use TrustedSigning PowerShell module instead

Error: "type.lastIndexOf is not a function"

Problem: electron-builder can't parse --win.sign=false Solution: Build with cleared environment variables:

delete process.env.AZURE_TENANT_ID;
delete process.env.AZURE_CLIENT_ID;
delete process.env.AZURE_CLIENT_SECRET;
// Now build unsigned

Error: Spaces in installer filename

Problem: Hyperclay Local Setup 1.1.0.exe breaks PowerShell Solution: Remove ALL spaces from productName in package.json

Error: Azure credentials not found

Problem: electron-builder spawns subprocess that loses env vars Solution: Build unsigned, sign separately with explicit env passing

Error: "weu.codesigning.azure.net not found"

Problem: electron-azure-trusted-signing package has hardcoded endpoint Solution: Don't use it. Use PowerShell module directly.

Error: Secret exposed in temp files

Problem: Writing credentials to .ps1 files Solution: Pass via environment variables only:

// Node.js
process.env.AZURE_CLIENT_SECRET_TEMP = secret;
// PowerShell reads from $env:AZURE_CLIENT_SECRET_TEMP

Error: Here-string terminator must be at column 0

Problem: PowerShell here-strings are indent-sensitive Solution:

# BAD (indented terminator)
$text = @"
  Hello
  "@

# GOOD (terminator at column 0)
$text = @"
Hello
"@

Architecture Gotchas

ARM64 Windows Is Not Ready

electron-builder Azure Integration Is Broken on Mac

The azureSignOptions in electron-builder doesn't work when building from macOS:

Tool Confusion Matrix

Tool Purpose Service Status
AzureSignTool Azure Key Vault Different service Wrong tool
electron-azure-trusted-signing Trusted Signing Hardcoded endpoints Broken
TrustedSigning PowerShell Trusted Signing Official Microsoft Works on Windows
electron-builder azureSignOptions Trusted Signing Via PowerShell Broken on Mac, may work on Windows

Time Investment Reality Check

Key Lessons

Don't Fight the Platform

GitHub Actions Is Your Friend

Separate Build from Sign

Environment Variables Are Tricky

Final Implementation

Your repository should have:

.github/workflows/
  build-windows.yml     # Builds on windows-latest
  build-macos.yml       # Builds on macos-latest

build-scripts/
  build-unsigned.js     # Builds without signing
  notarize.js          # macOS notarization

package.json           # productName with NO SPACES
entitlements.plist     # macOS entitlements

The Right Way (If Starting Fresh)

  1. Day 1: Set up GitHub Actions, not local builds
  2. Day 1: Register for Apple Developer via web portal
  3. Day 1: Create Azure Trusted Signing account (not Key Vault)
  4. Day 2: Build unsigned first, verify it works
  5. Day 2: Add signing as separate step
  6. Day 2: Test with real installers on real machines

Skip the local VMs, skip the cross-platform attempts, skip the integrated signing. Build where the platform expects, sign with official tools, use the cloud.

Conclusion

Cross-platform code signing shouldn't take 48 hours, but if you're reading this because you searched for "SignTool.exe exit code 3" or "type.lastIndexOf is not a function" or "missing terminator PowerShell Azure", you're not alone. The ecosystem is fragmented, the tools make assumptions, and the error messages are cryptic.

Use GitHub Actions. Build unsigned. Sign separately. Remove spaces from filenames. Don't use ARM64 Windows for development. These aren't best practices—they're survival tactics.