Notarization is the process where Apple's notary service scans a signed Mac app, attests that it is okay to launch, and lets Gatekeeper open it on a user's Mac without the 'cannot verify the developer' warning. On modern macOS, every Mac app distributed outside the Mac App Store needs to be notarized. Skip it and your conversion rate craters.
If you build with Electron, the failure modes are noisier than they are for native apps because Electron ships an embedded Chromium plus a V8 engine that wants entitlements most Mac apps never touch. This guide covers both: how notarization works in general, and the Electron-specific gotchas (electron-builder, electron-forge, manual @electron/notarize) that account for most of the rejection emails developers send into support forums.
When you need to notarize
If the app is going to the Mac App Store, you do not notarize: Apple does its own review and signs the build with App Store distribution material. For everything else, notarization is effectively required:
- Direct download from your website. Notarized = launches with no warning. Not notarized = the user sees 'cannot verify the developer' and has to right-click > Open the first time.
- Distributed via Homebrew Cask, GitHub Releases, or auto-update frameworks like Sparkle and Squirrel.Mac.
- Internal Mac tools that are not deployed through a managed MDM channel.
The five prerequisites
Before you submit anything to Apple's notary service, the build has to satisfy these five conditions. Missing any one is the most common cause of 'we found one or more issues with your build' rejection emails.
- Signed by a Developer ID Application certificate
- Not an Apple Distribution certificate (that's the App Store one). A Developer ID Application certificate is what Gatekeeper checks. It is issued by Apple to Apple Developer Program members, lasts five years, and is what notarization binds to.
- Hardened runtime enabled
- Hardened runtime is opted-in by signing with `codesign --options runtime`. It locks down what the binary can do at runtime: no unsigned executable memory, library validation on, dyld environment variables ignored. Notarization rejects builds without it.
- Timestamped signature
- `codesign --timestamp` makes the signature include an Apple-issued timestamp. Notarization rejects un-timestamped builds.
- No prohibited entitlements
- The `com.apple.security.cs.*` family is restricted. Some loosen hardened runtime (allow-jit, disable-library-validation) and notarization scrutinizes them. Some are flat-out forbidden in notarized builds (`com.apple.security.get-task-allow`, which is debugger-only).
- An App Store Connect API key for submission
- notarytool authenticates with an App Store Connect API key (preferred) or an app-specific password. Build infrastructure should use the API key path: the .p8 plus Key ID and Issuer ID.
The submission flow with notarytool
notarytool replaced altool as the official notarization client. The submission is three calls: sign, submit, staple.
# 1. Sign the bundle with hardened runtime and a timestamp codesign --force --deep --options runtime --timestamp \ --sign "Developer ID Application: Acme Corp (ABCDE12345)" \ --entitlements MyApp.entitlements \ MyApp.app # 2. Zip and submit, wait for the result inline ditto -c -k --keepParent MyApp.app MyApp.zip xcrun notarytool submit MyApp.zip \ --key AuthKey_ABC123.p8 \ --key-id ABC123DEFG \ --issuer 12345678-90ab-cdef-1234-567890abcdef \ --wait # 3. Staple the ticket onto the bundle xcrun stapler staple MyApp.app
The `--wait` flag is convenient for CI: notarytool blocks until Apple finishes (usually a few minutes), then exits with the result. Without it, you have to poll with `notarytool history` or `notarytool log <id>`. Stapling writes the notarization ticket into the bundle's metadata so Gatekeeper can verify the build offline. Skip stapling and the user needs an internet connection on first launch for Gatekeeper to fetch the ticket from Apple.
Electron-specific gotchas
An Electron app is a regular Mac .app bundle with three extras that complicate notarization: an embedded Chromium framework, a V8 JIT compiler, and a small herd of nested helper .app bundles that all have to be signed independently.
The JIT entitlement V8 needs
V8 compiles JavaScript to native code at runtime. With hardened runtime on, executing unsigned memory is forbidden. So every Electron app needs `com.apple.security.cs.allow-jit` in its entitlements, or it crashes on launch with an EXC_BAD_ACCESS the moment V8 tries to compile anything.
<!-- entitlements.mac.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.allow-jit</key> <true/> <key>com.apple.security.cs.allow-unsigned-executable-memory</key> <true/> <key>com.apple.security.cs.disable-library-validation</key> <true/> </dict> </plist>
The helper apps inside every Electron bundle
Open MyApp.app/Contents/Frameworks/ in Finder and you will see a stack of nested .app bundles: `MyApp Helper.app`, `MyApp Helper (GPU).app`, `MyApp Helper (Plugin).app`, `MyApp Helper (Renderer).app`. Each is a separate Chromium process target. Each one has to be signed independently with `codesign --deep` (which signs nested code) or one-by-one calls to codesign.
If you sign only MyApp.app without `--deep`, the helpers stay unsigned. Notarization rejects the submission and the error is `The signature of the binary is invalid` for the helper path. The simplest fix in 2026 is to use `codesign --deep --force --options runtime` and let it walk the tree, or to use electron-builder/electron-forge, which sign each helper explicitly with the correct sub-entitlements.
Embedded frameworks and native modules
Native node modules (sqlite3, sharp, fsevents, anything with a .node file) ship as Mach-O binaries inside the .app bundle. Apple requires every Mach-O binary in a notarized bundle to be signed by the same team with hardened runtime. Forgetting one is the second most common Electron-specific rejection (after helper apps).
- Every `*.node` file under `app.asar.unpacked/` or `node_modules/` has to be signed.
- Every `.dylib` and embedded framework under `Contents/Frameworks/` has to be signed.
- Sparkle, Squirrel.Mac, and similar auto-update frameworks ship their own bundles that also have to be signed.
- If you embed ffmpeg, opencv, or any precompiled binary, it has to be signed too.
electron-builder configuration
electron-builder handles signing and notarization in one go via its `mac` section and an `afterSign` hook that calls @electron/notarize. The minimal working configuration in package.json:
{
"build": {
"appId": "com.example.myapp",
"mac": {
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist",
"notarize": {
"teamId": "ABCDE12345"
}
},
"afterSign": "build/notarize.js"
}
}The matching `build/notarize.js`:
const { notarize } = require('@electron/notarize');
exports.default = async function notarizing(context) {
const { electronPlatformName, appOutDir } = context;
if (electronPlatformName !== 'darwin') return;
const appName = context.packager.appInfo.productFilename;
return await notarize({
tool: 'notarytool',
appPath: `${appOutDir}/${appName}.app`,
appleApiKey: process.env.APPLE_API_KEY_PATH, // path to .p8
appleApiKeyId: process.env.APPLE_API_KEY_ID,
appleApiIssuer: process.env.APPLE_API_ISSUER,
});
};electron-forge configuration
electron-forge's notarization integration is in its `osxNotarize` config. The minimal forge.config.js entry:
// forge.config.js
module.exports = {
packagerConfig: {
osxSign: {
identity: 'Developer ID Application: Acme Corp (ABCDE12345)',
'hardened-runtime': true,
entitlements: 'entitlements.plist',
'entitlements-inherit': 'entitlements.plist',
'signature-flags': 'library',
},
osxNotarize: {
tool: 'notarytool',
appleApiKey: process.env.APPLE_API_KEY_PATH,
appleApiKeyId: process.env.APPLE_API_KEY_ID,
appleApiIssuer: process.env.APPLE_API_ISSUER,
},
},
// ...
};Both osxSign and osxNotarize are forge's wrappers around @electron/osx-sign and @electron/notarize respectively. The advantage of going through forge: the per-helper entitlement files (plugin, renderer, gpu) are handled automatically. The disadvantage: less visibility into what got signed with which entitlement set.
Manual @electron/notarize
When you outgrow what electron-builder and electron-forge give you (custom build pipelines, multi-arch concerns, pre-signing transformations), drop down to @electron/notarize directly.
const { notarize } = require('@electron/notarize');
await notarize({
tool: 'notarytool',
appPath: 'dist/MyApp.app',
appleApiKey: process.env.APPLE_API_KEY_PATH,
appleApiKeyId: process.env.APPLE_API_KEY_ID,
appleApiIssuer: process.env.APPLE_API_ISSUER,
});
const { execFileSync } = require('child_process');
execFileSync('xcrun', ['stapler', 'staple', 'dist/MyApp.app'], { stdio: 'inherit' });At this layer you control your own signing step (@electron/osx-sign or `codesign --deep` calls), your own packaging, your own stapling, and your own retry logic. It is more work but lets you slot notarization into any pipeline.
CI walkthrough: GitHub Actions
A working GitHub Actions job for an electron-builder-based app. Assumes the .p12 (Developer ID Application certificate plus private key) and the App Store Connect API key (.p8) are stored as base64 secrets.
name: Release macOS
on:
push:
tags: ['v*']
jobs:
build:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
# Import the Developer ID Application cert + private key
- name: Set up signing keychain
env:
MAC_CERT_P12_BASE64: ${{ secrets.MAC_CERT_P12_BASE64 }}
MAC_CERT_P12_PASSWORD: ${{ secrets.MAC_CERT_P12_PASSWORD }}
run: |
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
echo "$MAC_CERT_P12_BASE64" | base64 --decode > $RUNNER_TEMP/cert.p12
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security default-keychain -s $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import $RUNNER_TEMP/cert.p12 -P "$MAC_CERT_P12_PASSWORD" \
-A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Stage the App Store Connect API key for notarization
- name: Stage notary API key
env:
APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }}
run: |
mkdir -p $RUNNER_TEMP/api-key
echo "$APPLE_API_KEY_BASE64" | base64 --decode > \
$RUNNER_TEMP/api-key/AuthKey.p8
- name: Build and notarize
env:
APPLE_API_KEY_PATH: ${{ runner.temp }}/api-key/AuthKey.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
CSC_IDENTITY_AUTO_DISCOVERY: 'true'
run: npm run build:macTroubleshooting common rejection emails
- The signature does not include a secure timestamp
- You signed without `--timestamp`. Re-sign with `codesign --options runtime --timestamp`. CI builds sometimes hit transient timestamp-server failures; retry the sign step.
- The binary is not signed with a valid Developer ID certificate
- You signed with an Apple Distribution (App Store) certificate instead of Developer ID Application. Or the certificate is from an Apple ID that is not an Apple Developer Program member. Re-issue the certificate.
- The signature of the binary is invalid
- Almost always a nested helper or framework that did not get signed. Walk every binary inside Contents/MacOS, Contents/Frameworks, and Contents/Helpers with `codesign -dvv <path>` to find the unsigned one.
- The executable does not have the hardened runtime enabled
- One of the helpers or native modules was signed without `--options runtime`. electron-builder usually does this for you, but a manual signing step or a pre-built native module without runtime flags will not.
- Invalid entitlement key (com.apple.security.get-task-allow)
- Debug entitlement leaked into the release build. It is incompatible with notarization. Strip it from `entitlements.mac.plist` for release builds.
- Notarization succeeded but Gatekeeper still warns on launch
- You forgot to staple. Run `xcrun stapler staple MyApp.app` after a successful submission, or `stapler staple MyApp.dmg` for DMG distribution.
Stapling and offline launches
Stapling writes the notarization ticket directly into the bundle so Gatekeeper can verify it offline. Without stapling, the first launch on a fresh Mac has to make a network call to Apple to fetch the ticket, which usually succeeds in milliseconds but fails entirely if the user is offline. Staple every notarized artifact: the .app, the .dmg if you distribute as a disk image, the .pkg if you distribute as an installer.
# Staple each artifact you ship xcrun stapler staple MyApp.app xcrun stapler staple MyApp.dmg xcrun stapler staple MyApp.pkg # Verify the ticket spctl --assess --type execute --verbose=4 MyApp.app
Where to go next
- Setting up signing in CI more broadly: Apple code signing in CI/CD.
- The signing primitive itself: iOS code signing: the complete guide (most of it transfers to macOS once you swap Apple Distribution for Developer ID Application).
- Hardened runtime entitlements explained: hardened runtime glossary entry.
- App Store Connect API key setup: App Store Connect API key and Connect API key.