Operating Systems Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · March 03, 2025 · 8 min read · 4 Views
Banner: a macOS application icon blocked by a Gatekeeper shield with a broken signature seal

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 ditto produced the same result.
  • Architecture mismatch. lipo -archs showed x86_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.quarantine on 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 --verify reports 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

  1. 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.
  2. Ship via a device management tool. Packages installed by MDM are not quarantined. Good for managed fleets, but you still want signed builds.
  3. Keep environment config outside the bundle, for example in ~/Library/Application Support or 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 --assess and stapler validate commands 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 .app directory.
  • 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 --assess in CI on the extracted release.
  • Never publish "remove the quarantine attribute" as an installation step.

References

Add a label

#macos #gatekeeper #codesign #notarization #xattr
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.