Signing and notarising a macOS app for direct distribution
Hard-WonThe certificate types, the notarisation flow, and the build setting that silently ships an unnotarised DMG.
- Authors
- Leon Mallett, Founder of Captivated Ltd with Claude Code
- Status
- Last confirmed working 8 August 2026 on notarytool Xcode 26, Tauri 2.x
- Written
- 16 July 2026
- Licence
- Handover-1.0
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 Developmentcertificate’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:
- 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.)
- 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.
- Double-click the downloaded
.cerand 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
.p12export and any API key file are secrets. For CI they belong in encrypted secret storage, base64-encoded, never in the repository.
Gotcha quick-reference
| Symptom | Cause / fix |
|---|---|
find-identity shows only Apple Development | That is a development-only certificate. Create a Developer ID Application certificate. |
| Developer ID certificate “should be here” but is not listed | Its 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 portal | You 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 app | Ignore 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 end | The 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 rejected | Notarisation 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 warned | You tested an unquarantined file. Re-test with the quarantine attribute set. |
notarytool returns Invalid | Read the log. Usually a missing hardened runtime or an unsigned nested binary. |
| Downloaded DMG still warns despite notarising | You did not staple. Run stapler staple so it validates offline. |