It’s 2 AM. Your app compiles. You sign it. You package it in a DMG. You run notarytool submit. Apple says “In Progress”. You wait 5 minutes. 10. 20. An hour. Two hours. The submission is still “In Progress”. You go to bed. The next morning: Invalid.

With no more explanation than “The signature of the binary is invalid”. For both architectures. Thanks, Apple. Very helpful.

Notarization is one of those processes that works perfectly… until it doesn’t. And when it fails, it leaves you with a .dmg that Gatekeeper won’t let open and an error that tells you nothing. After fighting with this for a couple of days with Tokamak (my menu bar app for monitoring Claude quota), I decided to document everything I learned and write a linter so I never have to go through this again.

What notarization is (in plain language)

Imagine the Mac App Store is a mall with a security guard. But you don’t want to sell in the mall — you want to distribute your app directly, with your own DMG. A street vendor.

Apple says: “Fine, you can. But first get past the bouncer.”

That bouncer is notarization. It’s Apple’s automated service that scans your signed app, verifies it doesn’t contain known malware, and if everything’s fine, gives you a ticket. You staple that ticket to your DMG (stapler staple) and from then on, when a user downloads it and tries to open it, Gatekeeper sees the ticket and says “go ahead”.

Without that ticket, the user sees this:

“Tokamak.app” can’t be opened because Apple cannot check it for malicious software.

And your app gets left in the dust.

Why Apple does this

There are two reasons. One legitimate. The other… well.

The legitimate one: protecting users. Before notarization (introduced in macOS 10.14.5, 2019), anyone could distribute a .app signed with Developer ID and macOS would open it without question. Code signing verified the developer’s identity, but didn’t scan the content. If your signed app included a keylogger, it would run signed and all.

Notarization adds a layer: Apple scans the binary for known malware and suspicious behavior before it reaches the user. It’s not a human review like the App Store — it’s an automated system. But it’s something.

The other reason: control. Apple wants you to go through App Store Connect for everything. Direct distribution with Developer ID has always been the second-class citizen. Notarization is one more step on that path of “you can distribute outside the App Store, but we’ll make it uncomfortable for you”.

That said, notarization is mandatory since macOS 10.15 for any app distributed outside the App Store. It’s not optional. Your app goes notarized or it doesn’t open. Period.

The 7 errors that will make you lose hours

After collecting my own errors and those from developer forums, these are the ones that hurt the most:

1. Missing --timestamp

This is the classic. Your codesign works perfectly locally, Gatekeeper doesn’t complain, and notarization returns “The signature of the binary is invalid.”

1
2
3
4
5
# BAD — valid signature locally, Apple rejects it
codesign --force --options runtime --sign "Developer ID Application: ..." MyApp.app

# GOOD — with Apple's server timestamp
codesign --force --options runtime --timestamp --sign "Developer ID Application: ..." MyApp.app

The secure timestamp proves the signature was made while the certificate was valid. Without it, Apple doesn’t trust it. It’s like signing a contract without a date — technically valid, but nobody’s going to accept it.

2. Wrong certificate

There are three certificates that sound similar and do different things:

CertificateWhat it’s for
Apple DevelopmentDebug builds (local development)
Apple DistributionApp Store and TestFlight
Developer ID ApplicationDirect distribution + notarization

If you sign with “Apple Development” and try to notarize, Apple sends you packing. You need Developer ID Application. Seems obvious, but when you’ve been trying for three hours, it’s not.

3. Hardened Runtime disabled

Notarization requires your app to use Hardened Runtime. It’s a protection that prevents your app from doing things like injecting code into other processes or disabling memory protections.

1
2
# The --options runtime flag activates Hardened Runtime
codesign --force --options runtime --timestamp --sign "Developer ID Application: ..." MyApp.app

Without --options runtime, the signature is valid but Apple rejects it. It’s like going to a job interview with a perfect resume but no pants.

4. xcodebuild -exportArchive hangs

If you use xcodebuild -exportArchive with method: developer-id, get ready: it hangs. Indefinitely. No output. No error. Just… silence.

The problem is that exportArchive needs Apple ID credentials to contact the distribution servers, and if it doesn’t have them cached (or if there are old corrupt accounts in the Keychain), it waits for interactive authentication that never comes.

The solution: skip exportArchive and do the signing manually.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 1. Copy .app from archive
cp -R build/Tokamak.xcarchive/Products/Applications/Tokamak.app build/export/

# 2. Sign with codesign
codesign --force --options runtime --timestamp \
  --sign "Developer ID Application: Your Name (TEAM_ID)" \
  build/export/Tokamak.app

# 3. Create DMG
hdiutil create -volname "MyApp" -srcfolder build/export/MyApp.app -format UDZO MyApp.dmg

It’s a hack, yes. But it works. And it doesn’t hang.

5. codesign requests Keychain access… silently

The first time you sign with a new certificate, codesign needs access to the private key stored in the Keychain. macOS shows a popup asking for permission.

The problem: if you run codesign from an IDE, a script, or a tool like Claude Code, the popup might not appear. Or appear behind all windows. And codesign hangs waiting for your response to a popup you can’t see.

Solution: the first time, run codesign directly from the terminal. Click “Always Allow” on the popup. From then on, scripts will work without asking.

6. Notarization takes… and takes… and takes

Apple says “most uploads are processed within minutes.” Reality:

  • Normal: 2-15 minutes
  • First time with new Developer ID: up to 1 hour
  • When Apple has problems: hours. Or days. No warning.

In February 2026, there are reports on Apple forums of submissions stuck “In Progress” for over 16 hours. The service was technically “operational”, but many submissions simply weren’t being processed.

There’s nothing you can do except wait and retry. Welcome to Apple’s developer experience.

7. Extended attributes that break the signature

macOS adds extended attributes to downloaded files (the famous com.apple.quarantine). If your build pipeline copies files that have these attributes, the signature becomes invalid.

1
2
# Clean before signing
xattr -cr MyApp.app

It’s a detail nobody tells you until it bites you.

The linter: 25 checks so you’re not exposed

After accumulating all these errors, I wrote a script that verifies 25 notarization conditions — both static (your code and configuration) and dynamic (build artifacts).

It runs like this:

1
bash scripts/lint-notarize.sh

And produces something like:

── CRITICAL ──
✓ PASS  C1: export-direct passes --entitlements to codesign
✗ FAIL  C2: Release CODE_SIGN_IDENTITY is not Developer ID
✓ PASS  C3: ENABLE_HARDENED_RUNTIME = YES in Release

── HIGH ──
✗ FAIL  H1: export-direct does NOT run xattr -cr

── MEDIUM ──
⚠ WARN  M1: DMG is not signed
✓ PASS  M2: LSUIElement declared in project.yml
⚠ WARN  M4: Only stapling DMG, not .app

── CERTIFICATES AND KEYCHAIN ──
✓ PASS  P1: Developer ID Application certificate in Keychain
✓ PASS  P2: notarytool profile 'tokamak-notary' configured

── ARTIFACTS ──
✓ PASS  P13: Universal binary (x86_64 + arm64)
✓ PASS  C2-V: Export signed with Developer ID Application
✓ PASS  C3-V: Hardened runtime active in export signature

── SUMMARY ──
  Total: 25 checks
  ✓ 18 passed
  ✗ 2 failed
  ⚠ 3 warnings
  ⊘ 2 skipped (build artifacts missing)

  ✗ DO NOT NOTARIZE — there are 2 failures to fix first.

The checks are divided into four levels:

CRITICAL — If any fail, notarization will definitely fail:

  • --entitlements in codesign (if you don’t pass them, you lose sandbox and network)
  • CODE_SIGN_IDENTITY = Developer ID in Release
  • Hardened Runtime enabled

HIGH — Will probably fail or cause problems:

  • xattr -cr before signing
  • No debug dylibs in Release

MEDIUM — Might work, but you’re taking risks:

  • DMG signed (not mandatory, but recommended)
  • LSApplicationCategoryType for App Store
  • Version consistency between AppInfo and project.yml

ARTIFACTS — Verifies generated builds:

  • Valid signature with Developer ID
  • Hardened Runtime active in signature
  • Universal binary (x86_64 + arm64)
  • No .DS_Store or symlinks in bundle
  • Correct structure (Contents/{MacOS,Resources,Info.plist})

The script is static for configuration checks (doesn’t need builds), and dynamic for artifacts (you need to have done make archive && make export-direct). So you can run it anytime as a preflight check.

The complete workflow

To give you an idea of the end-to-end process:

flowchart TD
    subgraph build["   🔨 Build   "]
        direction TB
        A["xcodegen generate"] --> B["xcodebuild archive<br/>(Release, signed)"]
    end

    subgraph sign["&nbsp;&nbsp;&nbsp;✍️ Sign&nbsp;&nbsp;&nbsp;"]
        direction TB
        C["Copy .app from archive"] --> D["xattr -cr<br/>(clean attributes)"]
        D --> E["codesign --force<br/>--options runtime<br/>--timestamp<br/>--sign Developer ID"]
    end

    subgraph package["&nbsp;&nbsp;&nbsp;📦 Package&nbsp;&nbsp;&nbsp;"]
        direction TB
        F["hdiutil create<br/>(DMG UDZO format)"]
    end

    subgraph notarize["&nbsp;&nbsp;&nbsp;🍎 Notarization&nbsp;&nbsp;&nbsp;"]
        direction TB
        G["notarytool submit<br/>--wait"] --> H{Accepted?}
        H -->|Yes| I["stapler staple<br/>(staple ticket)"]
        H -->|No| J["notarytool log<br/>(see errors)"]
        J --> K["Fix and<br/>sign again"]
    end

    subgraph lint["&nbsp;&nbsp;&nbsp;🔍 Preflight&nbsp;&nbsp;&nbsp;"]
        direction TB
        L["lint-notarize.sh<br/>(25 checks)"]
    end

    build --> sign
    sign --> lint
    lint -->|✓ All OK| package
    lint -->|✗ Failures| K
    package --> notarize
    K --> sign
    I --> M["✅ DMG ready<br/>for distribution"]

And in commands:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# 1. Build + archive
make archive

# 2. Export + sign (without exportArchive, which hangs)
make export-direct

# 3. Lint (preflight)
bash scripts/lint-notarize.sh

# 4. DMG
make dmg

# 5. Notarize
make notarize
# → xcrun notarytool submit ... --wait
# → xcrun stapler staple ...

# 6. Distribute
# The DMG already has the ticket stapled. Ready to upload.

Lessons learned at 3AM

  1. --timestamp always. Always. No excuse.
  2. First signature, in terminal. So the Keychain popup appears.
  3. Don’t use exportArchive for Developer ID. Copy + manual codesign.
  4. Be patient with Apple. Notarization can take minutes or hours. There’s no SLA.
  5. A linter is worth more than a hundred attempts. The 30 seconds the script takes saves you hours of failed submissions.

Notarization is like a driver’s test: uncomfortable, bureaucratic, and probably necessary. But once you have the process mastered, it becomes just another step in the pipeline. A step that no longer wakes you up at 3AM wondering why Apple says “Invalid” without bothering to explain why.

The complete script is in the Tokamak repo.