Why your .dmg tells users the app is damaged, and the exact codesign, notarytool and stapler commands that make a macOS build open on a stranger's Mac.
The last line of my build script for Sonar — a native SwiftUI network scanner I wrote for my own Mac — is codesign --force --deep --sign - "$APP". That trailing - means ad-hoc: sign it with nothing, using no identity, proving no origin. It produces a Sonar.app that double-clicks perfectly on the machine that built it. It also produces an app that, the moment I zip it and send it to somebody, greets them with "Sonar cannot be opened because the developer cannot be verified" and an OK button that is not really an OK button.
That's the good outcome. The bad one is "Sonar is damaged and can't be opened. You should move it to the Bin." Nothing is damaged. The bytes are perfect. macOS is lying to your user because it decided the truthful message — "this binary carries a quarantine flag and has no signature I can trace to a paid developer account" — was too much detail for a dialog box. I've watched three separate people delete a working app because of that sentence, and one of them asked me, reasonably, whether my download had a virus.
This post is the whole chain from ad-hoc binary to a .dmg that a stranger can download and open with a normal double-click: what Gatekeeper is actually checking, why an Apple Developer Program membership is the real gate rather than any particular command, the exact codesign / notarytool / stapler invocations in the order they have to run, how to package and run this on CI where the keychain isn't yours, what to tell a user who is stuck right now, and how the calculus changes if you ship through the Mac App Store instead. Exact commands throughout, because this is a domain where "roughly this" costs you an afternoon.
Everything starts with an extended attribute called com.apple.quarantine. Any application that writes a file it didn't produce itself — Safari, Chrome, Mail, Messages, AirDrop, Slack — stamps that attribute on it. You can see it:
xattr -l ~/Downloads/Sonar.app# com.apple.quarantine: 0083;68b1f0a4;Safari;A1B2C3D4-...
Four fields: flags, a hex timestamp, the app that downloaded it, and an event UUID. That's it. That attribute is the entire trigger. A binary you compiled locally has no quarantine flag and launches with zero ceremony, which is exactly why "works on my machine" is the default state of every unsigned Mac app and why you will not discover the problem until you hand it to someone.
When a quarantined app is launched for the first time, Gatekeeper evaluates it and you get one of three outcomes:
That third message is the one that costs you users, and it is worth understanding why it's phrased that way. From Gatekeeper's point of view a broken signature and a corrupted download are the same observation: the code doesn't match its seal. Apple picked the wording that protects users from tampered binaries, and unsigned indie apps get caught in the same net. There's a second, sneakier way to land in that bucket: a correctly signed app that got mangled in transit. Zipping a .app with plain zip instead of ditto flattens the symlinks inside Contents/Frameworks, which genuinely does break the signature — so a signed build can produce the "damaged" dialog purely because of how you packaged it.
It's worth being precise about what ad-hoc signing does and doesn't do, because --sign - looks like signing and the command exits 0.
codesign -dv --verbose=4 Sonar.app 2>&1 | grep -E 'Signature|Authority|TeamIdentifier'# Signature=adhoc# TeamIdentifier=not set
Signature=adhoc means there is a code directory — a hash of every file in the bundle — but no certificate chain and no team identity. That's genuinely useful for some things: it's required on Apple silicon for a binary to run at all, it gives you a stable code identity for keychain items and TCC prompts, and it means the OS can detect if the bundle is modified after the fact. What it cannot do is answer Gatekeeper's question, which is not "is this intact" but "who is accountable for this." Ad-hoc's answer is "nobody," and that's a rejection.
The equivalent for the notarized path is:
codesign -dv --verbose=4 Sonar.app 2>&1 | grep -E 'Authority|TeamIdentifier'# Authority=Developer ID Application: Your Name (ABCDE12345)# Authority=Developer ID Certification Authority# Authority=Apple Root CA# TeamIdentifier=ABCDE12345
Getting the certificate that produces those lines is the real gate, and it costs $99 a year. A free Apple ID gives you an "Apple Development" certificate, which signs apps that run on your own registered machines and nothing else. Developer ID Application — the certificate type that lets a Mac anywhere in the world trust your build — is only issued to Apple Developer Program members. There is no command that works around this, no flag, no free tier. Every guide that promises otherwise is describing the xattr workaround, which I'll get to, and which is not a distribution strategy.
Once you're enrolled, create the certificate in Xcode (Settings → Accounts → Manage Certificates → + → Developer ID Application) or on the developer portal, then confirm the private key is in your login keychain:
security find-identity -v -p codesigning# 1) 4F2A... "Developer ID Application: Your Name (ABCDE12345)"# 2) 9C71... "Apple Development: you@example.com (XYZ9876543)"# 2 valid identities found
If only the second line appears, you have the certificate but not the private key — you exported the wrong half, or you're on a machine that never generated the CSR. Export a .p12 from the Mac that did.
Four steps, in this order. Skipping any one of them puts you back in a dialog.
The hardened runtime is a set of protections — no unsigned executable memory, no DYLD environment overrides, no unsigned library loading, no debugger attach — that opts your process into stricter enforcement. Notarization requires it. You turn it on with --options runtime and then punch specific holes with entitlements:
<?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>com.apple.security.cs.allow-jit</key><true/> <key>com.apple.security.network.client</key><true/></dict></plist>
allow-jit is the one Flutter and Electron apps need. If you load plug-ins or frameworks you didn't sign, you'll also need com.apple.security.cs.disable-library-validation — add it only if you actually need it, because it weakens the guarantee you just turned on.
The brief version of the command, the one you'll see everywhere:
IDENTITY="Developer ID Application: Your Name (ABCDE12345)"codesign --deep --force --options runtime --timestamp \ --entitlements Sonar.entitlements \ --sign "$IDENTITY" Sonar.app
That works for a single-binary app like Sonar. For anything with frameworks, helper tools or XPC services, don't use --deep. Apple says so explicitly and the reason is concrete: --deep does not apply your --entitlements to nested executables, it happily re-signs vendor frameworks that were already correctly signed, and it misses code sitting in non-standard locations inside the bundle. The correct shape is inside-out — nested code first, each with its own entitlements if it needs any, then the outer bundle last:
find Sonar.app/Contents/Frameworks \ \( -name "*.dylib" -o -name "*.framework" \) -maxdepth 1 -print0 |while IFS= read -r -d '' f; do codesign --force --timestamp --options runtime --sign "$IDENTITY" "$f"donecodesign --force --timestamp --options runtime \ --entitlements Sonar.entitlements --sign "$IDENTITY" Sonar.app
Two flags people drop and then spend an hour debugging. --timestamp fetches a secure timestamp from Apple's timestamp server, which requires a working network connection at signing time; notarization rejects any signature without one, because without it your signature stops being verifiable the day your certificate expires. And make sure com.apple.security.get-task-allow is not in your entitlements — Xcode adds it to Debug builds so the debugger can attach, and the notary service refuses anything carrying it. That single stale entitlement is the most common first-submission failure I've seen.
Verify before you go further:
codesign --verify --deep --strict --verbose=2 Sonar.app# Sonar.app: valid on disk# Sonar.app: satisfies its Designated Requirement
Notarization is an automated malware scan, not a review. You upload a build, Apple's service checks it for known malicious content and for the signing requirements above, and issues a ticket saying "build with this code directory hash is fine." Turnaround is usually a couple of minutes.
notarytool won't take a bare .app — it accepts .zip, .dmg or .pkg. Use ditto, not zip, so symlinks survive:
ditto -c -k --keepParent Sonar.app Sonar.zip
Authenticate once and store it in the keychain so credentials never appear in a command again. The password here is an app-specific password from appleid.apple.com, not your Apple ID password:
xcrun notarytool store-credentials "AC_NOTARY" \ --apple-id "$APPLE_ID" \ --team-id "ABCDE12345" \ --password "$APP_SPECIFIC_PASSWORD"
Then submit and block until it's done:
xcrun notarytool submit Sonar.zip --keychain-profile "AC_NOTARY" --wait# id: 7f3c1a90-...# status: Accepted
When it says Invalid, the summary tells you nothing useful. Pull the actual log, which is a JSON list of every binary that failed and why:
xcrun notarytool log 7f3c1a90-... --keychain-profile "AC_NOTARY" notary.json
(If you're following an older guide that uses altool for this: it stopped working for notarization at the end of 2023. notarytool is the only path now.)
Notarization publishes the ticket to Apple's servers, so a Mac with a network connection will find it on first launch. Stapling writes a copy of the ticket into the bundle, so the app also opens on a Mac that's offline, behind a corporate proxy, or on conference wifi. It's one command and there's no reason to skip it:
xcrun stapler staple Sonar.appxcrun stapler validate Sonar.app# The validate action worked!
You cannot staple a .zip — a zip is not a container the tool can write into. Staple the .app, then re-ditto it for distribution, or ship a .dmg and staple that.
codesign --verify only tells you the signature is intact. The question that matters is what the policy engine thinks:
spctl -a -vvv -t exec Sonar.app# Sonar.app: accepted# source=Notarized Developer ID# origin=Developer ID Application: Your Name (ABCDE12345)
source=Notarized Developer ID is the finish line. If it says source=Developer ID without "Notarized", you signed but didn't notarize, and your users get dialog number one. And the only test I fully trust is the end-to-end one: put the artifact on a real URL, download it in Safari on a Mac that has never held your signing key, and double-click it. That's the only run that exercises the quarantine attribute the same way your user's will.
For a DMG, sign the app, build the image, sign the image, notarize, staple both:
hdiutil create -volname "Sonar" -srcfolder Sonar.app \ -ov -format UDZO Sonar.dmgcodesign --force --timestamp --sign "$IDENTITY" Sonar.dmgxcrun notarytool submit Sonar.dmg --keychain-profile "AC_NOTARY" --waitxcrun stapler staple Sonar.dmg
Stapling the app and the DMG is belt and braces, and it's worth it: the DMG's ticket covers the download, the app's ticket keeps working after the user drags it to Applications.
On CI there's no login keychain and no interactive unlock, so you build a throwaway one. Store the exported .p12 as a base64 secret:
security create-keychain -p "$KEYCHAIN_PW" build.keychainsecurity default-keychain -s build.keychainsecurity unlock-keychain -p "$KEYCHAIN_PW" build.keychainsecurity set-keychain-settings -t 3600 -u build.keychainecho "$DEVELOPER_ID_P12_BASE64" | base64 --decode > cert.p12security import cert.p12 -k build.keychain -P "$P12_PASSWORD" \ -T /usr/bin/codesignsecurity set-key-partition-list -S apple-tool:,apple:,codesign: \ -s -k "$KEYCHAIN_PW" build.keychain
That last line is the one everybody misses, and its absence produces a build that hangs forever on an invisible "allow access to your keychain" prompt no one can click. For notarization on CI, prefer an App Store Connect API key over an app-specific password — it's revocable per-key and doesn't tie your pipeline to one human's Apple ID:
xcrun notarytool submit Sonar.dmg \ --key "AuthKey_$KEY_ID.p8" --key-id "$KEY_ID" --issuer "$ISSUER_ID" --wait
Delete the keychain and the .p12 in a cleanup step that runs even on failure.
Sometimes you have an unsigned build in someone's hands today. There are two honest answers.
The first is the Finder path: Control-click the app, choose Open, then Open again in the dialog. That used to be the universal escape hatch. As of macOS 15 Sequoia it no longer bypasses Gatekeeper for unnotarized software — the user has to open System Settings → Privacy & Security, scroll to the message naming the blocked app, and click Open Anyway, then authenticate. Note the direction of travel: this route has gotten narrower every couple of releases, and betting your distribution on it is betting against Apple.
The second is stripping the attribute directly:
xattr -dr com.apple.quarantine /Applications/Sonar.app
-d deletes the attribute, -r recurses through the bundle. It works, it's the thing every README of every unsigned Mac tool tells you to run, and I'll happily give it to a developer who asked for a build off my laptop.
But be clear-eyed about what you're doing when you put that line in a public README. You are asking a stranger to run a shell command that disables a security check, on an app they downloaded from you, before they have any reason to trust you. You're training them to do it again for the next download, which might not be yours. You're also asking something a non-technical user simply will not do — they'll close the terminal and never come back — and on a managed Mac with MDM policies it may not even be permitted. xattr -dr is a fine thing to hand a colleague and a terrible thing to build a product on.
Three distribution models, and the choice isn't really about signing.
Developer ID + notarization is what everything above describes. You keep your own download page, your own pricing, your own release cadence, no review queue, no commission. You also own your update mechanism — Sparkle is the standard answer, and note that your updater and every binary it installs need signing too, or the first update turns a trusted app into a "damaged" one.
Mac App Store swaps almost all of that out. Different certificates (Apple Distribution, plus a 3rd Party Mac Developer Installer certificate for the .pkg), you upload through Xcode or Transporter, and you don't notarize — Apple's own review and signing pipeline covers it. In exchange: App Sandbox is mandatory, human review has opinions, updates and refunds are handled for you, and Apple takes 15–30%. The sandbox is the part that decides it for most apps. Sonar ping-sweeps a subnet, reads the ARP table and does SSDP and mDNS enumeration; none of that survives the sandbox's networking entitlements. Some apps are simply not App Store apps, and that's a technical fact rather than a business preference.
Unsigned isn't a third model so much as an unfinished second one. It's fine for a personal tool, a build you hand to a teammate, or something distributed through Homebrew where the audience is already comfortable with a terminal.
The bill isn't $99. It's the drop-off. A first-run dialog that says the word "damaged" converts a curious downloader into a deleted file, and you never see it in analytics because they never launched anything to report from. You spend support time explaining that your app is not malware, which is a genuinely awful conversation to have about work you're proud of. Corporate and school Macs under MDM may refuse the workaround entirely, so an entire class of user is unreachable. And every macOS release makes the bypass harder to describe — the instructions you wrote for Ventura were wrong by Sequoia.
Against that: a $99 annual membership and about forty lines of shell, most of which you write once. For anything you want strangers to install, that is not a close call. My rule is simple — if the app has a download page, it gets a Developer ID certificate and a notarized, stapled build. If it's a binary I hand to one person, ad-hoc is fine and xattr -dr is the note that goes with it.
com.apple.quarantine is the whole mechanism — an extended attribute stamped by whatever downloaded your file, which is why an unsigned app runs perfectly on your machine and fails on everyone else's.codesign flag substitutes for it, and ad-hoc signing (--sign -) proves integrity but no accountability, which is the question Gatekeeper is actually asking.--options runtime --timestamp, notarize with xcrun notarytool submit --wait, xcrun stapler staple, then confirm with spctl -a -vvv -t exec until it reports source=Notarized Developer ID.codesign --deep for a bundle with frameworks or helpers — it won't apply your entitlements to nested code; sign inside-out instead, and never let get-task-allow survive into a release build.xattr -dr com.apple.quarantine is an honest favour for one developer and a terrible distribution strategy — it trains users to disable a security check, non-technical users won't do it, and managed Macs may not allow it.The unglamorous truth is that shipping a Mac app is about ninety percent writing the app and ten percent proving to an operating system that you exist. That ten percent is boring, entirely mechanical, and completely unavoidable once real people are downloading your work. Write the script once, put it in CI, and never think about it again — the alternative is explaining the word "damaged" to strangers for the rest of the app's life.