> Signing and notarising a macOS app for direct distribution — from The Handover, the-handover.org/docs/macos-signing-and-notarisation > Authors: Leon Mallett (captivated.online) with Claude Code · Last confirmed working: 2026-08-08 > © Captivated Ltd — free to use in your own work, not to redistribute as a collection. the-handover.org/licence 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. ```bash 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: ```bash xcrun notarytool store-credentials "" \ --apple-id "" \ --team-id "" ``` From then on, everything references the profile by name — `--keychain-profile ` — 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 ```bash export APPLE_SIGNING_IDENTITY="Developer ID Application: ()" 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 ```bash APP=path/to/.app DMG=path/to/.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 "" --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 ` 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 `. - 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 | 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. |