Skip to main content

Fixing 'Unidentified Developer': Automating Electron macOS Notarization

 The most frustrating bug report an Electron maintainer can receive isn't a runtime error or a layout gltich—it's the report that the application simply won't open. The dreaded "Unidentified Developer" modal is a hard stop for user acquisition.

While code signing proves who you are, it no longer proves what your code is. Since macOS 10.15 (Catalina), Apple enforces Notarization for all software distributed outside the Mac App Store. If you aren't stapling a notarization ticket to your DMG or ZIP, your app is effectively dead on arrival.

Automating this in a headless CI/CD environment (GitHub Actions, GitLab CI, CircleCI) is notoriously brittle due to Apple ID 2FA requirements. This guide implements a robust, stateless solution using App Store Connect API Keys and notarytool, bypassing legacy app-specific passwords entirely.

The Root Cause: Gatekeeper & Notarytool

Under the hood, macOS Gatekeeper performs a quarantine check on downloaded binaries.

  1. Signature Check: It validates the Developer ID Application certificate.
  2. Notarization Check: It looks for a "ticket" stapled to the binary or queries Apple's servers online.

Historically, we used altool, a legacy Java-based CLI that required an Apple ID and an App-Specific Password. This was slow, prone to timeouts, and difficult to debug.

The modern standard is notarytool (introduced in Xcode 13), which communicates with the Apple Notary Service via a REST API. To automate this securely, we utilize App Store Connect API Keys, which use JWT-based authentication. This removes the need for 2FA handling in CI/CD.

The Fix: Automated Notarization Pipeline

We will configure electron-builder to trigger a custom afterSign hook. This hook utilizes @electron/notarize configured to use the notarytool backend with API keys.

Prerequisites

  1. Xcode Command Line Tools installed on the build agent (xcode-select --install).
  2. A valid Developer ID Application certificate.
  3. npm packages:
    npm install --save-dev electron-builder @electron/notarize dotenv
    

Step 1: Generate App Store Connect API Key

Do not use your personal Apple ID. Create a dedicated API Key for CI.

  1. Log in to App Store Connect.
  2. Navigate to Users and Access > Integrations > App Store Connect API.
  3. Click + to create a key.
    • Name: Electron CI Notarization
    • Access: Developer
  4. Download the .p8 private key file immediately (you can only do this once).
  5. Note the Issuer ID and the Key ID.

Step 2: The Notarization Script

Create a file at build/notarize.js. This script acts as the afterSign hook. It intelligently determines if notarization should run (skipping it during local dev builds) and invokes the notarization service.

File: build/notarize.js

require('dotenv').config();
const { notarize } = require('@electron/notarize');
const path = require('path');
const fs = require('fs');

exports.default = async function (context) {
  const { electronPlatformName, appOutDir } = context;

  // 1. Only notarize on macOS
  if (electronPlatformName !== 'darwin') {
    return;
  }

  // 2. Skip notarization if explicitly requested (e.g., local dev builds)
  // or if we are not building a target that requires it (e.g. MAS)
  if (process.env.CSC_IDENTITY_AUTO_DISCOVERY === 'false' || process.env.SKIP_NOTARIZE === 'true') {
    console.log('  • Skipping notarization (SKIP_NOTARIZE set)');
    return;
  }

  // 3. Validate Env Vars
  const apiKeyId = process.env.APPLE_API_KEY_ID;
  const apiIssuerId = process.env.APPLE_API_ISSUER;
  // In CI, we usually store the content of the .p8 file in a secret
  // or a path to a temporary file. 
  // Here, we assume APPLE_API_KEY contains the path to the .p8 file.
  const apiKeyPath = process.env.APPLE_API_KEY;

  if (!apiKeyId || !apiIssuerId || !apiKeyPath) {
    console.warn('  • Skipping notarization: Missing APPLE_API_KEY_ID, APPLE_API_ISSUER, or APPLE_API_KEY');
    return;
  }

  const appName = context.packager.appInfo.productFilename;
  const appPath = path.join(appOutDir, `${appName}.app`);

  // 4. Verify the App exists
  if (!fs.existsSync(appPath)) {
    throw new Error(`Cannot find application at: ${appPath}`);
  }

  console.log(`  • Notarizing ${appName} with API Key ${apiKeyId}...`);

  try {
    // 5. Invoke @electron/notarize
    await notarize({
      appPath,
      appBundleId: 'com.yourcompany.yourapp', // REPLACE THIS
      tool: 'notarytool', // Crucial: forces the modern, faster tool
      appleApiKey: apiKeyPath,
      appleApiKeyId: apiKeyId,
      appleApiIssuer: apiIssuerId,
    });
    console.log(`  • Notarization successful for ${appName}`);
  } catch (error) {
    console.error('  • Notarization failed:', error);
    // Fail the build if notarization fails
    throw error;
  }
};

Step 3: Configure Electron Builder

Update your package.json or electron-builder.yml to point to this hook. We also need to ensure "Hardened Runtime" is enabled, as it is a requirement for notarization.

File: package.json

{
  "build": {
    "appId": "com.yourcompany.yourapp",
    "mac": {
      "category": "public.app-category.developer-tools",
      "target": ["dmg", "zip"],
      "hardenedRuntime": true,
      "gatekeeperAssess": false,
      "entitlements": "build/entitlements.mac.plist",
      "entitlementsInherit": "build/entitlements.mac.plist"
    },
    "afterSign": "build/notarize.js"
  }
}

File: build/entitlements.mac.plist (Minimal requirement)

<?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.allow-jit</key>
    <true/>
    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
    <true/>
    <!-- Add camera/mic entitlements here if needed -->
  </dict>
</plist>

Step 4: CI/CD Pipeline Integration (Example: GitHub Actions)

In your CI environment, you must handle the secret .p8 file. You cannot store this file in your repo. Store the content of the key in a CI secret, and write it to disk during the build.

File: .github/workflows/build.yml

name: Build macOS

on: [push]

jobs:
  release:
    runs-on: macos-latest
    env:
      # Secrets configured in GitHub Repo Settings
      APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
      APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
      # We will write the file content to a temp path
      APPLE_API_KEY: ${{ github.workspace }}/auth_key.p8
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install Dependencies
        run: npm ci

      - name: Decode Apple API Key
        # Validates that the secret exists before trying to write it
        if: ${{ secrets.APPLE_API_KEY_CONTENT != '' }}
        run: |
          echo "${{ secrets.APPLE_API_KEY_CONTENT }}" | base64 --decode > $APPLE_API_KEY
          chmod 600 $APPLE_API_KEY

      - name: Build & Publish
        run: npm run electron-builder -- --mac --publish always
        env:
          # Required for code signing (identity)
          CSC_LINK: ${{ secrets.MAC_CERTS_BASE64 }}
          CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}

The Explanation

Why tool: 'notarytool'?

By setting tool: 'notarytool' in the notarize() function, we force @electron/notarize to use xcrun notarytool instead of xcrun altool. The notarytool binary is significantly optimized for CI environments. It uploads binary chunks in parallel and provides immediate, structured JSON feedback upon failure, rather than the opaque email-based reporting of the past.

Why API Keys over App-Specific Passwords?

App-specific passwords are tied to a standard Apple ID. If that ID changes its password, gets locked, or hits 2FA triggers due to login attempts from AWS/Azure IP ranges, your build pipeline halts.

API Keys are:

  1. Role-based: They have specific permissions scoped to development tasks.
  2. Long-lived: They do not expire based on user password resets.
  3. Headless: Designed specifically for non-interactive scripts.

Conclusion

The "Unidentified Developer" warning is a trust barrier you cannot afford. By moving away from legacy user-based authentication and embracing the notarytool workflow with App Store Connect API keys, you turn a fragile manual release step into a resilient, "set and forget" pipeline operation.

Your users get a secure app, and you get green build indicators.