...
Back

Notarization Is Not a Launch Test

A Mac app can pass notarization, satisfy codesign, be accepted by Gatekeeper, and still be killed by the kernel the moment you double-click it. Here is why, and a three-minute test that tells you before your users do.

Notarization Is Not a Launch Test

Notarization Is Not a Launch Test

We shipped a notarized Mac disk image that could not start.

Not "crashed on launch". Not "showed an error". The kernel refused to execute it, killed the process before a single line of our code ran, and left nothing in the app's own logs because the app never existed long enough to write any.

Every check we had was green:

CheckResult
codesign --verify --deep --strictpassed
spctl -a -t execaccepted
Apple notary serviceAccepted
stapler validateticket valid

The build was signed with a valid Developer ID certificate, hardened runtime on, secure timestamp attached. It sat on our download page for six months. It never launched once.


What the kernel was actually complaining about

The only place the failure surfaces is the system log:

AMFI: When validating /Volumes/…/Contents/MacOS/YourApp:
mac_vnode_check_signature: code signature validation failed fatally
proc NNNNN: load code signature error 4 for file "YourApp"
AMFI: hook..execve() killing xpcproxy (pid NNNNN):
      Attempt to execute completely unsigned code (must be at least ad-hoc signed).

"Completely unsigned code" is a lie, or rather a very unhelpful generalisation. The binary was signed. What actually happened is that the app requested a restricted entitlement that nothing authorised it to have.

Most entitlements are just claims. A few are restricted: the kernel will only honour them if a provisioning profile embedded in the app grants them. Ask for one without that backing and AMFI does not strip the entitlement or warn you. It refuses to execute the binary at all.

In our case the entitlements file carried:

<key>com.apple.developer.networking.networkextension</key>
<array>
  <string>packet-tunnel-provider</string>
</array>

inherited from the App Store build, where a provisioning profile did grant it. The Developer ID build deliberately strips embedded profiles, because a directly distributed app does not need one — unless it asks for something restricted, which this one did.


Why none of the usual checks catch it

This is the part worth internalising, because it is not obvious:

  • codesign --verify does not check entitlement authorisation. It verifies that the signature is intact and the seal matches. Whether the signer is allowed to request a given entitlement is a runtime policy question, and codesign is not the runtime.
  • Notarization does not run your app. It scans for malware, checks that binaries are signed with a Developer ID certificate, that the hardened runtime is enabled, and that a secure timestamp is present. Those are packaging properties. Apple's notary service is not going to boot your app to see whether AMFI will have it.
  • spctl answers a different question. It tells you whether Gatekeeper will permit the launch. It will happily say accepted for a binary the kernel then refuses to load, because the two are separate gates and only one of them has run.

So the entire toolchain agrees the artifact is fine, and the artifact is unlaunchable. There is no warning anywhere in the pipeline.


A three-minute test that does catch it

You do not need the real app to find out whether an entitlement is restricted. Sign a trivial binary with only that entitlement and try to run it.

cat > probe.c <<'EOF'
#include <stdio.h>
int main(void){ printf("PROBE_RUNNING\n"); return 0; }
EOF
clang -o probe probe.c
 
cat > ent.plist <<'EOF'
<?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.developer.networking.networkextension</key>
  <array><string>packet-tunnel-provider-systemextension</string></array>
</dict>
</plist>
EOF
 
codesign --force --options runtime \
  --sign "Developer ID Application: Your Team (TEAMID)" \
  --entitlements ent.plist probe
 
./probe

Two outcomes, and they are unambiguous:

  • PROBE_RUNNING — the entitlement is unrestricted. Safe to request without a profile.
  • Killed: 9 and nothing else — restricted. It must be granted by an embedded provisioning profile, or the app will not start.

Run it once against a bare signature as a control, so you know the probe itself launches. Then run it per entitlement you are unsure about.

We used exactly this to establish that both com.apple.developer.networking.networkextension and com.apple.developer.system-extension.install are restricted, and that the com.apple.security.cs.* hardened-runtime exceptions are not.


The signature-level fingerprint

There is a second tell, useful when you have a build in hand and want to know before you run it. Ask codesign about a specific architecture:

codesign -dv --arch arm64 /path/to/YourApp.app/Contents/MacOS/YourApp

On a healthy universal binary this prints the usual authority chain. On the broken one it printed no Authority lines at all, while the same command without --arch printed a perfectly good chain. That asymmetry is the fingerprint. If the fat file looks signed but no slice does, you are looking at a binary the loader will reject.


What we changed

Two things, and only the second is interesting.

The immediate fix was to stop requesting what we could not back. The app in question runs its tunnel in-process and had no NetworkExtension target at all; the entitlement was vestigial, copied from a sibling target years earlier and never questioned because nothing ever failed loudly. Removing it made the app launch.

The structural fix was to stop trusting the pipeline's silence. A release that is signed, notarized, stapled and never once executed is not a tested release. Our packaging script now mounts the finished image and launches the app out of it before the artifact is allowed near a download page. It costs ten seconds and it is the only check in the entire chain that would have caught this.

If you are shipping a Mac app outside the App Store and it requests anything under com.apple.developer.*, assume it is restricted until a probe proves otherwise. The cost of being wrong is an artifact that passes every automated gate and works for nobody.