macOS says our internal app "is damaged and can't be opened": quarantine and notarization
Every engineer who downloaded our internal deploy helper got a "damaged" dialog. The app was fine; a CI step modified the bundle after signing and Gatekeeper checked the quarantined copy. We now sign, notarize and staple in the right order.
Our platform team ships a small menu bar app that engineers use to open deploy dashboards and request temporary database access. For a year it was distributed as a zip on the internal releases page. After we rebuilt the CI pipeline in February, every new download failed on launch with the same dialog:
"Deploy Helper" is damaged and can't be opened. You should move it to the Trash.
The same build launched fine for the person who built it locally, and fine when copied over AirDrop to some machines. It failed for everyone who downloaded it in a browser, on both Intel and Apple silicon Macs running macOS 15 Sequoia.
Reproducing it
The browser download path was the trigger, so we checked the file's extended attributes right after unzipping:
xattr -l "Deploy Helper.app"
com.apple.quarantine: 0083;67c1a2b4;Chrome;5C1E0D7A-1F3B-4E8A-9C77-1B2E3D4F5A6B
Browsers add com.apple.quarantine to downloaded files, and Archive Utility propagates it to the
extracted app. A quarantined app is checked by Gatekeeper on first launch; an app copied from a
build directory is not. That explained why it "worked on my machine".
Then we asked the code signing tools what they thought of the bundle:
codesign --verify --deep --strict --verbose=2 "Deploy Helper.app"
Deploy Helper.app: a sealed resource is missing or invalid
file added: /Users/dev/Downloads/Deploy Helper.app/Contents/Resources/endpoints.json
What we ruled out
- A corrupted download. The zip's SHA-256 matched the CI artifact, and unzipping with
dittoproduced the same result. - Architecture mismatch.
lipo -archsshowedx86_64 arm64, and the failure happened on both kinds of Mac. - An expired certificate. The pipeline was not using our Developer ID certificate at all anymore; the old pipeline had, and that was part of the story.
Root cause
Two changes in the new pipeline combined. First, the signing step only applied the ad-hoc
signature the linker adds on Apple silicon builds; the Developer ID signing and notarization steps
had been left out during the migration. Second, a later step copied an environment-specific
endpoints.json into Contents/Resources after the bundle was signed, which invalidated the
signature's sealed resources. Gatekeeper, checking a quarantined app with a broken signature,
reports it as "damaged" rather than "from an unidentified developer", and offers no way to open it.
What is happening under the hood
Three mechanisms are involved:
- Quarantine. Apps that download files (browsers, mail clients, chat apps) set
com.apple.quarantineon them. Only quarantined items get the full Gatekeeper assessment on first launch. - Code signing. A signature covers the executable and, through a "seal", every file in the
bundle's resources. Adding, removing or editing any of those files after signing breaks the seal,
and
codesign --verifyreports it. - Notarization. Apple scans a Developer ID signed build and issues a ticket. Gatekeeper requires that ticket for downloaded software. Stapling attaches the ticket to the app so it can be checked offline.
How Gatekeeper reacts depends on what it finds:
| What Gatekeeper sees on a quarantined app | Result |
|---|---|
| Developer ID signed, notarized, signature intact | Opens after a one-time confirmation |
| Developer ID signed, not notarized | Blocked; can be allowed in System Settings |
| Unsigned or ad-hoc signed, signature intact | Blocked as unverified; can be allowed in System Settings |
| Signature present but invalid (modified bundle) | "is damaged and can't be opened" |
On macOS Sequoia the old Control-click and Open shortcut no longer bypasses these warnings. Users have to go to System Settings → Privacy & Security and choose Open Anyway, which is exactly the kind of instruction you do not want to normalise across a company.
Environment
| Component | Version / value |
|---|---|
| Client OS | macOS 15.3 Sequoia, Intel and Apple silicon |
| Build host | macOS 15 CI runner, Xcode 16.2 |
| App | Swift menu bar app, universal binary |
| Distribution | Zip on an internal web page, downloaded with Chrome and Safari |
| Signing before fix | Linker ad-hoc signature only, resources modified afterwards |
Minimal reproduction
# Build and sign any app, then change a resource and quarantine it.
codesign --force --sign - Demo.app # ad-hoc signature
echo '{}' > Demo.app/Contents/Resources/extra.json
xattr -w com.apple.quarantine "0083;$(printf '%x' "$(date +%s)");Safari;" Demo.app
open Demo.app # "Demo" is damaged and can't be opened.
Who is affected
Anyone distributing macOS apps or command-line tools outside the App Store: internal tools, open
source releases, Electron or Tauri apps. Copying a bundle between machines with scp or a USB
drive usually does not set quarantine, so testers often never see the problem that every download
user hits.
The fix
We restored proper signing and moved every bundle modification before it. The release job now runs these steps in order:
# 1. Finish the bundle: all resources in place before signing.
cp "config/${ENV}/endpoints.json" "build/Deploy Helper.app/Contents/Resources/"
# 2. Sign with Developer ID, hardened runtime and a secure timestamp.
codesign --force --options runtime --timestamp \
--sign "Developer ID Application: Example Corp (ABCDE12345)" \
"build/Deploy Helper.app"
# 3. Zip with ditto (keeps the bundle structure and metadata intact) and notarize.
ditto -c -k --keepParent "build/Deploy Helper.app" DeployHelper.zip
xcrun notarytool submit DeployHelper.zip --keychain-profile "ci-notary" --wait
# 4. Staple the ticket to the app and zip again for distribution.
xcrun stapler staple "build/Deploy Helper.app"
ditto -c -k --keepParent "build/Deploy Helper.app" DeployHelper.zip
The keychain profile is created once on the runner with xcrun notarytool store-credentials, using
an App Store Connect API key kept in the CI secret store, so no password appears in logs.
If an app embeds helpers or frameworks, sign those first and the outer bundle last. Apple
recommends signing each nested item explicitly rather than relying on --deep.
Alternatives and trade-offs
- Remove the quarantine attribute with
xattr -dr com.apple.quarantine "/Applications/Deploy Helper.app". It works for one person who trusts the file, but it switches off the check that would catch a tampered binary. We document it only for local builds a developer made themselves. - Ship via a device management tool. Packages installed by MDM are not quarantined. Good for managed fleets, but you still want signed builds.
- Keep environment config outside the bundle, for example in
~/Library/Application Supportor fetched at runtime. This removes the temptation to modify a signed bundle at all, and it is the direction we are moving in.
How to test the fix
Gatekeeper's assessment can be run without a GUI:
codesign --verify --strict --verbose=2 "Deploy Helper.app"
spctl --assess --type execute --verbose=4 "Deploy Helper.app"
xcrun stapler validate "Deploy Helper.app"
Deploy Helper.app: valid on disk
Deploy Helper.app: satisfies its Designated Requirement
Deploy Helper.app: accepted
source=Notarized Developer ID
origin=Developer ID Application: Example Corp (ABCDE12345)
The validate action worked!
Before notarization, spctl reported source=Unnotarized Developer ID, which is a useful
intermediate check in CI.
Confirming in production
We asked six engineers on different machines to download the new zip with Safari and Chrome. All six saw the normal "downloaded from the Internet" confirmation once, then the app opened. Our internal help channel had logged 23 "damaged app" questions in the two weeks before the fix and none in the month after.
We also confirmed that the notarization log for the submission had no warnings:
xcrun notarytool log <submission-id> --keychain-profile "ci-notary"
Regression guard
- The release job runs the
codesign --verify,spctl --assessandstapler validatecommands above on the final zip after extracting it to a temporary folder, and fails the release on any non-zero exit. - A CI check fails if any step after signing writes into the
.appdirectory. - The certificate's expiry date is tracked in our secrets inventory with a 60-day reminder.
Security implications
This bug pushed people toward unsafe habits: several engineers had already found the xattr
workaround and were sharing it. Running unsigned internal tools trains users to bypass Gatekeeper
for anything that looks internal, which is exactly what a phishing payload would imitate. A
correctly signed and notarized build restores a meaningful check: if the app is modified after we
ship it, macOS refuses it. Keep the Developer ID private key and notarization API key in the CI
secret store only, with access limited to the release job.
Prevention checklist
- Finish every change to the bundle before signing; treat a signed bundle as read-only.
- Sign with Developer ID and hardened runtime, notarize, staple, then package.
- Test the downloaded artifact, not the build folder, so quarantine is part of the test.
- Run
spctl --assessin CI on the extracted release. - Never publish "remove the quarantine attribute" as an installation step.
References
- Apple Developer, Notarizing macOS software before distribution — https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution
- Apple Developer, Customizing the notarization workflow — https://developer.apple.com/documentation/security/customizing-the-notarization-workflow
- Apple Developer, Resolving common notarization issues — https://developer.apple.com/documentation/security/resolving-common-notarization-issues
- Apple Support, Safely open apps on your Mac — https://support.apple.com/en-us/102445
- Apple Platform Security, Gatekeeper and runtime protection — https://support.apple.com/guide/security/gatekeeper-and-runtime-protection-sec5599b66df/web
Add a label
Embed this post
<iframe src="https://hibug.me/macos-app-is-damaged-quarantine-notarization" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.