In part 2 we got the credentials ready. In this one I look inside what actually happens when Fastlane invokes codesign and notarytool: why the Hardened Runtime is mandatory, which entitlements to enable and which to leave off, and what exactly gets stapled at the end.
None of this is Fastlane-specific. It’s what Apple demands. Fastlane just orchestrates.
What codesign signs
When codesign signs a .app, it doesn’t sign “the binary”. It signs every file inside the bundle and stores all the signatures in a Contents/_CodeSignature/CodeResources file inside the app itself. Then it signs the main executable and includes a hash of that CodeResources as part of the signature.
The result: if anyone changes an image, a localization file, or any resource inside the .app, the resource hash stops matching the one stored in the signature, and Gatekeeper catches it at launch.
You can inspect the signature with:
codesign -dvv --deep /path/to/Pokédex.app
Something like this comes out (exact values depend on your Team ID and the build):
Identifier=com.slekens.PokedexMac
Format=app bundle with Mach-O universal (arm64 x86_64)
CodeDirectory v=20500 size=... flags=0x10000(runtime) hashes=...
Authority=Developer ID Application: Your Name (XXXXXXXXXX)
Authority=Developer ID Certification Authority
Authority=Apple Root CA
TeamIdentifier=XXXXXXXXXX
Runtime Version=14.0.0
Sealed Resources version=2
Two things to notice:
flags=0x10000(runtime)— means the Hardened Runtime is enabled. Without this, Apple rejects notarization.- The
Authoritychain — three links: your Developer ID certificate, Apple’s intermediate CA, Apple’s root. That’s what Gatekeeper validates against its trust store.
The Hardened Runtime
The Hardened Runtime is a set of restrictions Apple requires from notarized apps. It closes common attack vectors: you can’t load libraries not signed by you or Apple, you can’t execute unsigned memory as code, you can’t use JIT without explicitly declaring it.
In the repo’s project.yml it’s enabled with:
settings:
base:
ENABLE_HARDENED_RUNTIME: YES
Xcode takes that flag and passes --options=runtime to codesign. That’s what shows up as flags=0x10000(runtime) in the output above.
The Hardened Runtime isn’t optional for notarization. If you try to submit a .app without it, Apple rejects the package with an explicit message (“The executable does not have the hardened runtime enabled”) and Fastlane’s notarize surfaces it thanks to print_log: true.
Entitlements — Developer ID isn’t sandbox
Another common knot. People see Mac App Store apps have com.apple.security.app-sandbox: true and assume every macOS app needs it. Not so.
Developer ID + notarization does NOT require sandbox. You can enable it if you want, but Apple doesn’t demand it. In the repo I leave it explicitly off:
<!-- PokedexMac/PokedexMac.entitlements (XML equivalent of the YAML in project.yml) -->
<key>com.apple.security.app-sandbox</key>
<false/>
What the Hardened Runtime does require is that you declare any “escape” you need. Everything is off by default. You only enable what you use:
# From the repo's project.yml
com.apple.security.cs.allow-jit: false
com.apple.security.cs.allow-unsigned-executable-memory: false
com.apple.security.cs.disable-library-validation: false
com.apple.security.network.client: true
allow-jit— allows compiling code at runtime. Only if you embed a JavaScript engine or similar.allow-unsigned-executable-memory— unsigned executable memory. Only if you use frameworks that require it.disable-library-validation— load libraries not signed by you. Only if you integrate third-party plugins.network.client— make outgoing requests. I enable this because the app calls the PokeAPI. Strictly speaking, without sandbox the Hardened Runtime doesn’t restrict the network —but I keep it declared for documentation and because it costs nothing. If sandbox ever gets enabled later, this line is already there.
The rule: fewer active entitlements is better. Apple inspects the list during notarization. Every true is one more potential attack surface for them to evaluate.
How notarytool works with an API Key
notarytool is the current official tool (it replaced altool in 2022). It uploads the binary to Apple, waits for the verdict, and returns the result. The direct syntax is:
xcrun notarytool submit /path/to/Pokédex.app \
--key ~/Documents/asc-keys/AuthKey_XXXXXXXXXX.p8 \
--key-id XXXXXXXXXX \
--issuer 12345678-abcd-... \
--wait
With --wait it blocks until Apple responds. Without --wait it returns an ID you can query later with notarytool info.
Fastlane wraps this:
notarize(
package: app_path,
bundle_id: BUNDLE_ID,
api_key_path: ENV["APP_STORE_CONNECT_API_KEY_PATH"],
print_log: true,
verbose: true
)
As mentioned in part 2, if the .p8 keeps Apple’s canonical name AuthKey_<KEY_ID>.p8, Fastlane extracts the Key ID and Issuer ID on its own. print_log: true is helpful: if Apple rejects, it spits out the full JSON log with the exact reason. Without it, you’d have to fetch the log by hand with notarytool log <id>.
A successful submission looks like this in the output (exact details vary by build):
Successfully uploaded file
id: 12345678-abcd-1234-abcd-1234567890ab
path: /Users/.../Pokédex.app
Waiting for processing to complete...
Current status: Accepted
Processing complete
id: 12345678-abcd-1234-abcd-1234567890ab
status: Accepted
It usually takes between 30 seconds and 5 minutes. The first time you submit a new app it can take longer because Apple runs a deeper initial analysis.
A shortcut: storing the profile with store-credentials
Typing the three flags every time you notarize by hand gets tedious fast. notarytool has a mode to store the .p8 + Key ID + Issuer ID combination in the Keychain under a short name, and then reuse just that name.
You do it once:
xcrun notarytool store-credentials "pokedex-notary" \
--key ~/Documents/asc-keys/AuthKey_XXXXXXXXXX.p8 \
--key-id XXXXXXXXXX \
--issuer 12345678-abcd-...
The profile lives in the user’s Keychain (search for notarytool in Keychain Access and you’ll see it). From that point on, every notarytool call gets shorter:
xcrun notarytool submit /path/to/Pokédex.app \
--keychain-profile "pokedex-notary" \
--wait
The same applies to notarytool info, notarytool log and notarytool history — all of them accept --keychain-profile instead of the three separate flags.
A note on Fastlane: the notarize action in the Fastfile still uses api_key_path directly because you hand it the .p8 path via ENV and it builds the call from there. The Keychain profile is most useful for manual notarytool invocations — when you’re debugging a failed notarization and want to reproduce the submit by hand, or check the log of an older build with notarytool log <id>.
Stapling — why it’s not optional
The ticket Apple issues lives on their servers. Gatekeeper can query it online, but we need the app to work without network. That’s why stapler writes the ticket inside the bundle:
xcrun stapler staple /path/to/Pokédex.app
The ticket ends up in Contents/CodeResources as an extra blob. With that, the next time Gatekeeper sees the app it can verify everything locally in milliseconds, without calling Apple.
Fastlane’s notarize action staples automatically when notarization succeeds. That’s why there’s no separate staple step in the repo’s Fastfile —it’s built in. If for any reason you split the steps and notarize without stapling, your end user sees a perceptible delay the first time they open the app while Gatekeeper hits Apple’s server, or the app refuses to open if the user is offline.
You can verify stapling landed correctly:
xcrun stapler validate /path/to/Pokédex.app
# The validate action worked!
Final verification with spctl
spctl (System Policy Control) is what Gatekeeper uses internally. It simulates locally what will happen to the user:
spctl --assess --type execute --verbose /path/to/Pokédex.app
Expected output:
/path/to/Pokédex.app: accepted
source=Notarized Developer ID
Those two lines are the goal of the whole pipeline. If it says accepted with source=Notarized Developer ID, the app opens on any Mac without friction. Anything else (rejected, source=No matching rule found) means something in the pipeline broke and it’s time to investigate.
In the repo’s Fastfile this verification is step 5, right after creating the DMG:
sh "spctl --assess --type execute --verbose '../#{app_path}' 2>&1 || true"
The || true is intentional so the output prints without aborting the lane —sometimes spctl exits non-zero for minor warnings that don’t block distribution. A human reads the output and decides.
What’s next
In part 4 I dive into the pure Fastlane part: how it handles MARKETING_VERSION and CURRENT_PROJECT_VERSION, why there are two lanes (qa and release) and what separates them, and how skip_publish mode lets you rehearse the pipeline without actually publishing.