Code signing and notarisation on macOS, in practice
A practical guide to macOS code signing and notarisation: Developer ID certificates, codesign with the hardened runtime, entitlements, notarytool, stapling, Gatekeeper and common failures.
If you ship a Mac app outside the App Store, code signing and notarisation on macOS are not optional. Without them, Gatekeeper tells your users the app cannot be checked for malicious software, and since macOS Sequoia there is no quick Control-click way around that message. We make desktop apps for the Mac, so this is the checklist we work from, with the exact commands and the failures that come up most.
The pipeline has five steps: build a release, sign it with a Developer ID certificate and the hardened runtime, send it to Apple's notary service, staple the ticket, and ship. Each step catches a different class of mistake, and most problems come from doing them in the wrong order.
Developer ID certificates
You need a paid Apple Developer Program membership and two kinds of certificate, depending on what you ship:
- Developer ID Application signs apps, command line tools, frameworks and disk images.
- Developer ID Installer signs
.pkginstallers. You only need it if you ship a package.
Only the Account Holder of a team can create Developer ID certificates. The private key lives in the keychain of the Mac that made the request, so export it (with its certificate, as a password-protected .p12) and store it somewhere safe before you need it on a build machine. Losing it means revoking and reissuing, and revoking a Developer ID certificate can stop existing copies of your app from launching. Treat that key like a production secret, because it is one.
Check what the keychain can sign with:
security find-identity -v -p codesigning
The identity string it prints, Developer ID Application: Your Company (TEAMID1234), is what you pass to codesign.
Signing with the hardened runtime
Notarisation requires three things from the signature: a Developer ID certificate, a secure timestamp from Apple's server, and the hardened runtime switched on. One command covers all three:
codesign --force --options runtime --timestamp \
--entitlements app.entitlements \
--sign "Developer ID Application: Your Company (TEAMID1234)" \
MyApp.app
The timestamp matters beyond notarisation. Certificates expire, and a timestamped signature proves the app was signed while the certificate was valid, so it keeps working afterwards.
Order matters too. A signature seals everything inside the bundle, so nested code has to be signed before the thing that contains it. Sign loose dylibs and helper tools first, then frameworks, then nested apps and XPC services, and the main app bundle last. Apple's advice is to avoid --deep for signing. It applies one set of options and entitlements to everything it finds, which is rarely what a helper tool needs, and it misses code in places it does not look.
Verify before you upload anything:
codesign --verify --deep --strict --verbose=2 MyApp.app
codesign --display --entitlements - MyApp.app
Using --deep for verifying is fine. It is only for signing that it causes trouble.
Entitlements: ask for less
The hardened runtime blocks a set of risky behaviours by default: writable and executable memory, loading libraries signed by someone else, attaching a debugger, and environment variables like DYLD_INSERT_LIBRARIES. Entitlements turn individual protections off, and each one you add widens what an attacker can do with your process.
The ones we see most often:
com.apple.security.cs.allow-jitfor JavaScript engines and other JIT compilers, which Electron apps usually need.com.apple.security.cs.disable-library-validationwhen the app loads plug-ins signed by other developers. Avoid it if you can. It is a common path for code injection.com.apple.security.device.audio-inputandcom.apple.security.device.camerafor recording. Without them the hardened runtime silently denies access, and the app just gets no audio.
Start with an empty entitlements file and add only what breaks. We will write more about that habit in minimal app permissions. And make sure a release build never carries com.apple.security.get-task-allow. Xcode adds it to debug builds so the debugger can attach, and notarisation rejects it.
Notarising with notarytool
Notarisation uploads your signed software to Apple, which scans it for malware and checks the signature rules above. If it passes, Apple issues a ticket that Gatekeeper can find later. The tool is notarytool. The older altool route stopped working for notarisation in November 2023, so old scripts that still call it need replacing.
Store credentials once in the keychain, using an app-specific password or an App Store Connect API key:
xcrun notarytool store-credentials "notary" \
--apple-id "you@example.com" --team-id TEAMID1234
It prompts for the app-specific password and saves a profile called notary. For CI, an API key (--key, --key-id, --issuer) is easier to rotate than a person's Apple ID.
You cannot upload a bare .app. Zip it with ditto, which keeps the symlinks and extended attributes that a plain zip can break, then submit and wait:
ditto -c -k --keepParent MyApp.app MyApp.zip
xcrun notarytool submit MyApp.zip --keychain-profile "notary" --wait
Most submissions finish in a few minutes, and some take longer. If the status comes back as Invalid, the log explains why:
xcrun notarytool log <submission-id> --keychain-profile "notary"
Read the log every time, even on success. It lists warnings that may become errors later.
Stapling, Gatekeeper and shipping
When a user opens a downloaded app for the first time, the browser has marked it with a quarantine attribute and Gatekeeper checks it. If it cannot find a ticket attached to the app, it asks Apple's servers. That works most of the time, and fails on a plane or behind a strict firewall. Stapling attaches the ticket to the app so the check works offline:
xcrun stapler staple MyApp.app
xcrun stapler validate MyApp.app
You cannot staple a zip. Staple the app, then zip it again for distribution, or ship a disk image. For a .dmg, the usual order is: put the signed and stapled app in the image, sign the image itself with codesign --timestamp --sign, notarise the image, and staple the image. The same applies to a .pkg, signed with the Developer ID Installer certificate using productsign.
Then test it the way a user would, not from your build folder:
spctl --assess --type execute --verbose MyApp.app
Better still, download your own release from the website on a second Mac and open it. That is the only test that includes the quarantine flag.
Common failures
These are the ones most small teams run into, with the fix for each:
- "The signature does not include a secure timestamp." A nested binary was signed without
--timestamp, often by a build tool that signs frameworks for you. Re-sign it. - "The executable does not have the hardened runtime enabled." Same pattern, missing
--options runtimeon one binary, usually a helper tool or a bundled command line binary. - "The binary is not signed with a valid Developer ID certificate." Something was signed with an Apple Development certificate, or ad hoc. Check each binary with
codesign -dvv. - Unsigned code in odd places. Python wheels, Node native modules and bundled tools like
ffmpeghide executables deep inResources. Notarisation finds them, so your script has to as well. - Modifying the bundle after signing. Editing
Info.plist, stripping symbols or adding a file aftercodesignbreaks the seal. Signing has to be the last thing that touches the bundle before notarisation.
Apple's own guides are the reference: Notarizing macOS software before distribution, Hardened Runtime and Resolving common notarization issues.
All of this fits in a release script of a few dozen lines of shell. Sign from the inside out, verify, notarise, staple, then download the published file again and run spctl on it. That last step is the one people skip, and it is the only one that tests what your users actually receive.