Skip to content
The Handover

CodingGuides

Signing and notarising a macOS app for direct distribution

Hard-Won

The certificate types, the notarisation flow, and the build setting that silently ships an unnotarised DMG.

For signing and notarising a macOS desktop application distributed directly — downloaded from your own site, updated in place — rather than through the App Store.

Two signings are involved and they are not the same thing. Code signing (Developer ID) makes the operating system trust the app. Update signing (a separate keypair) makes the app trust an update. A release build needs both; this document covers the first.

The certificate types are not interchangeable

A fresh Mac with Xcode usually has only an Apple Development certificate. That is a local-development certificate. It cannot sign for distribution, and notarisation will reject anything signed with it. Direct distribution needs a Developer ID Application certificate. These are different certificate types, not the same certificate under another name — the single most common starting mistake.

Useful property: security find-identity is team-agnostic. It lists every usable identity regardless of Team ID, so a “wrong team” never hides a certificate. If the Developer ID line is not there, the identity genuinely is not in a searched keychain.

security find-identity -v -p codesigning      # valid identities
security list-keychains                       # what is actually searched
security find-identity -p codesigning         # includes expired/invalid

Organisation account specifics

  • Only the Account Holder can create a Developer ID certificate. On an organisation account the option is greyed out or absent for every other role, including Admin. This is not a bug to debug; it is a permission.
  • The certificate is issued in the company name, with the organisation’s Team ID in the OU field. Do not confuse that with the personal identifier that appears in an Apple Development certificate’s common name — that one is the individual, not the team.
  • Certificates are not per-app. One Developer ID Application certificate signs every app you ship, and Apple caps how many you may hold, so you could not make one per app even if you wanted to.

Getting the identity onto the signing machine

An identity signs only if its private key is in that machine’s keychain. find-identity showing nothing means the key is not there.

Fresh certificate via CSR — cleanest, because the private key is generated on the machine that will sign, so no .p12 transfer is needed:

  1. Keychain Access → Certificate Assistant → Request a Certificate From a Certificate Authority. Leave the CA email blank, choose Saved to disk. (If macOS suggests the Passwords app, ignore it — certificate work stays in Keychain Access.)
  2. Developer portal → Certificates → + → Software → Developer ID Application. When asked for the sub-CA, choose the G2 Sub-CA; the previous sub-CA’s certificates expire in early 2027.
  3. Double-click the downloaded .cer and install it into the login keychain — the same keychain as the CSR’s private key, so the two pair into a full identity. Not iCloud, not System.

Reusing an existing certificate — export from the other machine as a .p12 (Keychain Access → My Certificates → expand to confirm a private key is nested → Export), transfer it as the secret it is, and import. That same .p12, base64-encoded, is what CI will need later.

Notarisation credentials

Notarisation uploads the built artifact to Apple to be scanned. notarytool needs to authenticate, and the credential is a secret.

The human runs the credential-storing step, not the assistant — it prompts interactively, so the secret never enters a transcript:

xcrun notarytool store-credentials "<profile-name>" \
  --apple-id "<apple-id>" \
  --team-id "<TEAM_ID>"

From then on, everything references the profile by name — --keychain-profile <profile-name> — and the password is never seen again. Set the profile up once per machine and reuse it across projects.

For CI, prefer an App Store Connect API key owned by the organisation over an app-specific password tied to a personal account: it survives staff changes and carries no personal two-factor prompt.

Build, then notarise — as separate steps

export APPLE_SIGNING_IDENTITY="Developer ID Application: <ORG NAME> (<TEAM_ID>)"
npx tauri build

The signing identity string is not a secret — the Team ID is embedded in every distributed app — so it is fine in a command or a CI variable. The notarisation password is a secret, which is why signing happens during the build and notarisation happens separately.

The gotcha that ships a broken build

Do not “simplify” by setting the notarisation environment variables during the build. It looks like it does everything in one step. It does not, and it fails silently.

The build notarises and staples the .app, then wraps it in a .dmg and only signs the container — leaving the .dmg, which is the artifact you actually ship, unnotarised. The build log reports success throughout. Only checking the DMG reveals rejected — source=Unnotarized Developer ID.

So separating the two steps is correct for two independent reasons: secret hygiene, and actually producing a shippable artifact.

Related: the certificate-import variables some CI templates set are not needed on a development Mac at all. They exist to import a certificate into a runner’s keychain. The login-keychain certificate is enough locally.

Watch for the keychain prompt. The first time codesign uses a newly imported key, macOS asks whether to allow it. Click Always Allow, not Allow, or an unattended build hangs at signing — near the end of a build that may take ten to fifteen minutes cold.

Verify, notarise, staple, verify again

APP=path/to/<App>.app
DMG=path/to/<App>.dmg

# 1. Signature sanity — must show the hardened runtime flag.
codesign -dv --verbose=4 "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"

# 2. Pre-notarisation Gatekeeper check.
#    "rejected: Unnotarized Developer ID" is EXPECTED at this point.
spctl -a -t exec -vvv "$APP"

# 3. Notarise the DMG. Apple scans server-side, usually one to five minutes.
xcrun notarytool submit "$DMG" --keychain-profile "<profile-name>" --wait

# 4. Staple the ticket so it validates OFFLINE.
xcrun stapler staple "$DMG"

# 5. Final check on the stapled DMG — must be "accepted".
spctl -a -t open --context context:primary-signature -vvv "$DMG"

# 6. Best check: simulate a real download. Gatekeeper treats a plain local
#    file more leniently than a quarantined one, so test what users get.
cp "$DMG" /tmp/gk-test.dmg
xattr -w com.apple.quarantine "0083;00000000;Safari;" /tmp/gk-test.dmg
spctl -a -t open --context context:primary-signature -vvv /tmp/gk-test.dmg
rm -f /tmp/gk-test.dmg

You submit the DMG, and notarisation covers the app inside it. Stapling is what lets a downloaded DMG pass Gatekeeper with no network connection.

Verify the artifact, never the build log. The log says “success” in exactly the case that ships a broken DMG. Checking the DMG is the only verdict that matters, and it should be a hard gate in CI rather than a step someone remembers.

If notarisation returns Invalid, notarytool log <submission-id> pinpoints it. The usual causes are a missing hardened runtime, an unsigned nested binary, or a non-Developer-ID signing certificate.

Do not pre-emptively write an entitlements file. The hardened runtime with default entitlements cleared notarisation for an app that registered global shortcuts, installed a launch agent for autostart, shelled out to system daemons, drove shared file lists and made network calls — with zero custom entitlements. Add one only when a notarisation log names a specific capability.

Secret hygiene

  • Never put the app-specific password or API key in a command an assistant runs, or in any file in a repository. The human runs store-credentials; the assistant only ever uses --keychain-profile <name>.
  • The signing identity string and Team ID are fine to use and commit — they are public in every shipped app.
  • The .p12 export and any API key file are secrets. For CI they belong in encrypted secret storage, base64-encoded, never in the repository.

Gotcha quick-reference

SymptomCause / fix
find-identity shows only Apple DevelopmentThat is a development-only certificate. Create a Developer ID Application certificate.
Developer ID certificate “should be here” but is not listedIts private key is not in this keychain. Import the .p12, or make a fresh certificate by CSR here. Not a Team ID issue — find-identity is team-agnostic.
“Developer ID Application” greyed out in the portalYou are not signed in as the Account Holder. Organisation accounts restrict it to that role.
Xcode has no “Manage Certificates”The UI moved. Use the portal plus Keychain Access CSR flow, which is version-independent.
macOS prompts to open the Passwords appIgnore it. Passwords does not handle certificates.
Which sub-CA?G2. The previous sub-CA’s certificates expire in early 2027.
Which keychain for the .cer?Login — the same one holding the CSR’s private key.
Build hangs near the endThe codesign keychain prompt. Click Always Allow.
Build log: “skipping app notarization, no APPLE_ID…”Expected. Notarisation is a separate step.
Build says “notarised successfully” but the DMG is rejectedNotarisation credentials were set at build time. The .app got the ticket; the shipped .dmg did not. Notarise the DMG separately.
spctl says “rejected: Unnotarized Developer ID”Expected before notarisation. After submitting and stapling it becomes “accepted”.
spctl accepts locally but users still get warnedYou tested an unquarantined file. Re-test with the quarantine attribute set.
notarytool returns InvalidRead the log. Usually a missing hardened runtime or an unsigned nested binary.
Downloaded DMG still warns despite notarisingYou did not staple. Run stapler staple so it validates offline.