How to notarize a Mac app with notarytool

Issue #1045

Distributing a macOS app outside the App Store requires notarization. Apple scans the app for malicious content and attaches a ticket to it so Gatekeeper can verify it offline. Without notarization, users on macOS 10.15 and later see a blocking warning or the app refuses to open entirely.

The old altool approach is deprecated. The modern tool is xcrun notarytool, available since Xcode 13.

Setting up credentials

notarytool authenticates using an App Store Connect API key. If you already use the asc CLI, you likely have credentials at ~/.asc/credentials.json. If not, generate an API key in App Store Connect under Users and Access, download the .p8 file, and note the Key ID and Issuer ID.

Store the credentials in the keychain once, and reference them by a profile name in all future submissions:

xcrun notarytool store-credentials "MyProfile" \
  --key "/path/to/AuthKey_KEYID.p8" \
  --key-id "KEYID" \
  --issuer "YOUR-ISSUER-UUID"

Do not include --apple-id or --team-id. Those flags are for Apple ID authentication, not API key authentication, and combining them causes an error.

Verify the profile was saved:

security find-generic-password -l "com.apple.gke.notary.tool.saved-creds.MyProfile"

Archiving with xcodebuild

Notarization requires a Developer ID-signed app, not an App Store build. Use xcodebuild with your standalone scheme:

xcodebuild \
  -project "MyApp.xcodeproj" \
  -scheme "MyApp Standalone" \
  -configuration Release \
  -archivePath /tmp/notarize-work/MyApp.xcarchive \
  DEVELOPMENT_TEAM=YOUR_TEAM_ID \
  clean archive

Set DEVELOPMENT_TEAM to the team that owns your Developer ID certificate. You can find this with:

security find-identity -v -p codesigning | grep "Developer ID Application"

Exporting with Developer ID signing

Write an export options plist that requests Developer ID signing:

<?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>method</key>
  <string>developer-id</string>
  <key>teamID</key>
  <string>YOUR_TEAM_ID</string>
  <key>signingStyle</key>
  <string>automatic</string>
</dict>
</plist>

Then export:

xcodebuild -exportArchive \
  -archivePath /tmp/notarize-work/MyApp.xcarchive \
  -exportPath /tmp/notarize-work/export \
  -exportOptionsPlist /tmp/notarize-work/ExportOptions.plist

The signed app lands at /tmp/notarize-work/export/MyApp.app.

Submitting to Apple

Zip the app with ditto before submitting. ditto preserves macOS metadata correctly, unlike zip:

ditto -c -k --keepParent \
  /tmp/notarize-work/export/MyApp.app \
  /tmp/notarize-work/MyApp-submit.zip

Submit and wait for the result in one command:

xcrun notarytool submit /tmp/notarize-work/MyApp-submit.zip \
  --keychain-profile "MyProfile" \
  --wait

A successful submission prints status: Accepted. If it prints status: Invalid, fetch the log with the submission ID shown in the output:

xcrun notarytool log SUBMISSION_ID \
  --keychain-profile "MyProfile" \
  /tmp/notarize-work/notary-log.json

The log is a JSON file that lists every issue Apple found, including which file failed and why.

Stapling the ticket

Once accepted, staple the notarization ticket to the app bundle so Gatekeeper can verify it without a network request:

xcrun stapler staple /tmp/notarize-work/export/MyApp.app

Look for The staple and validate action worked! in the output. Then confirm Gatekeeper accepts it:

spctl -a -vv /tmp/notarize-work/export/MyApp.app

The output should read accepted with source=Notarized Developer ID. If you see rejected: Insufficient Context, the check needs the app to be in a user-accessible location like /Applications or ~/Downloads. That is a false negative from the path, not a signing problem.

The unsealed contents problem

A subtle failure mode appears when the app is copied after signing. macOS creates ._ AppleDouble files alongside files with extended attributes when copying to certain destinations. These files end up inside the framework bundles and break the code signature seal.

spctl reports this as:

rejected (unsealed contents present in the root directory of an embedded framework)

Find and remove them before re-signing:

find /path/to/MyApp.app -name "._*" -type f -delete

After removing the files, the entire signing chain is broken and the app must be re-archived, exported, notarized, and stapled from scratch. The safest approach is to keep the app inside /tmp/notarize-work/ throughout the process and only copy it to its final destination after stapling, using ditto to create the distribution zip.

Packaging for distribution

After stapling, create the final zip for distribution:

ditto -c -k --keepParent \
  /tmp/notarize-work/export/MyApp.app \
  ~/Downloads/Standalone/MyApp.zip

This zip can be attached to a GitHub release, hosted on S3, or distributed through a Sparkle appcast. Users who download and unzip it will pass Gatekeeper without any warnings.

Written by

I’m open source contributor, writer, speaker and product maker.

Start the conversation