A comprehensive deep-dive into how Apple's code signing system actually works, why it exists, and how to wield it confidently as an iOS engineer.
.app Bundlecodesign)If you've shipped any iOS app, you've stared at one of these in Xcode:
error: No profiles for 'com.example.MyApp' were found: Xcode couldn't find any iOS App Development provisioning profiles matching 'com.example.MyApp'.
error: Provisioning profile "iOS Team Provisioning Profile: com.example.MyApp" doesn't include signing certificate "Apple Development: Jane Doe (ABCD1234EF)".
error: A signed resource has been added, modified, or deleted.
The temptation is to click "Try Again" until it works. That works until it doesn't — and when it doesn't, you're staring at a build failure five minutes before a release deadline with no idea where to look.
This guide exists to make you the person on your team who knows what's going on under the hood. After reading it, you will:
This is not a "click here, then click there" tutorial. It's a systems guide. Where I show clicks, I also show what those clicks do under the hood.
Before we dive in, lock this mental model in your head. Code signing on iOS is the answer to a single question:
"Should this device run this binary?"
For the answer to be yes, four things must agree at install time and at every launch:
aps-environment = production into your binary and start receiving push notifications — Apple has to have agreed to that.Everything else — certificates, profiles, build settings, codesign, the dance Xcode does on every build — is machinery in service of those four checks.
Keep that mental model running. When something breaks, ask: which of those four agreements is failing?
Code signing is built on public-key cryptography (also called asymmetric cryptography). If you already know RSA / ECDSA, skim this; if not, internalize it because it shows up everywhere.
You generate two mathematically linked keys:
The math (RSA modular arithmetic, or elliptic curve operations for ECDSA) ensures that you cannot derive the private key from the public key in any reasonable amount of time.
For code signing, the relevant operation is signing:
SHA-256 is what Apple uses today).When you "request a certificate" from Apple, you generate a key pair locally. Your private key never leaves your Mac (well — it lives in the Keychain). What goes to Apple is a Certificate Signing Request (CSR), which contains your public key and some identity metadata.
Xcode hides this, but you can do it yourself:
# Generate a 2048-bit RSA private key
openssl genrsa -out private.key 2048
# Generate a CSR using that private key
openssl req -new -key private.key -out request.csr \
-subj "/emailAddress=jane@example.com/CN=Jane Doe/C=US"
The .csr file is what you'd upload to the Apple Developer portal under Certificates → +. Apple takes your public key from the CSR, packages it with metadata about you (your team, validity period, your role), signs that whole package with Apple's private key, and gives you back a .cer file. That .cer is your certificate.
This is the single most-confused point in iOS code signing.
.cer Apple gives you is public information. It contains your public key plus Apple's signature attesting to your identity. Losing it is fine — you can always re-download it.When you see the keychain entry like this:
▶ Apple Development: Jane Doe (ABCD1234EF)
▶ private key
The disclosure triangle expanding to reveal a private key is the sign that this Mac can actually sign builds with that certificate. Without that private-key child entry, the certificate is a paperweight on this machine.
This is also why you can Export → Personal Information Exchange (.p12) from Keychain Access — the .p12 is the bundle of certificate + private key, encrypted under a password you choose. Backing up that .p12 is how teams share signing identities across Macs without trusting each Mac to generate its own certificate.
Every bundled resource — your Info.plist, your asset catalog, your Mach-O executable, every framework — gets hashed during signing. The hashes are stored in a file called CodeResources inside _CodeSignature/ in the app bundle. The signature covers those hashes.
The implication: if anything in the bundle changes after signing, the signature breaks. This is why you cannot just edit a plist inside a signed .ipa. It's why "A sealed resource is missing or invalid" errors appear when you accidentally drop a .DS_Store into a built .app.
Your certificate isn't trusted in a vacuum. It's trusted because Apple says so, and Apple's say-so is itself anchored in a root certificate that ships in iOS.
Apple Inc. Root CA (self-signed, baked into iOS)
│
▼
Apple Worldwide Developer
Relations Certification Authority (the "WWDR" intermediate)
│
▼
Apple Development: Jane Doe (...) (your certificate)
│
▼
your signed app
When iOS verifies a binary, it walks this chain upward. Each link is a certificate signed by the link above it. The walk terminates at the Apple Root CA, which iOS trusts unconditionally because it's burned into the OS image.
Apple periodically rotates its WWDR intermediate certificate. When the old one expires (or when a new device or fresh macOS install lacks the new one), you'll see:
This certificate was signed by an unknown authority.
Fix: download the current WWDR intermediate from https://www.apple.com/certificateauthority/ and double-click it to install into the System keychain.
You can verify the chain on your own machine:
# List all WWDR-related entries in your keychain
security find-certificate -a -c "Apple Worldwide Developer Relations" \
/Library/Keychains/System.keychain
# Inspect the validity period of one
security find-certificate -c "Apple Worldwide Developer Relations" -p \
| openssl x509 -text -noout | grep -A 2 Validity
Apple actually issues your certificate from one of several intermediates depending on what you're signing:
Apple Worldwide Developer Relations Certification Authority — used for general developer certificates (Development, Distribution).Apple Worldwide Developer Relations Certification Authority – G3 / G4 / G6 — newer generations, currently active. The number after G increments as Apple rotates.The takeaway: when you see "G6" or "G3" in a cert name, don't panic — it's the same chain, just a newer link.
Apple issues several types of certificates. The names changed a few years ago, so you'll see both old and new in tutorials and forums.
| Modern Name | Old Name | What It Signs | Where It Comes From |
|---|---|---|---|
| Apple Development | iOS Development | Builds destined for your registered devices, including Simulator-on-device parity testing | Developer portal, automatic via Xcode |
| Apple Distribution | iOS Distribution | Builds destined for App Store, TestFlight, or Ad Hoc | Developer portal, requires team agent or admin |
| Apple Push Services | n/a | Token-based or certificate-based push notifications (server-side, not the app itself) | Developer portal, used by your backend |
| Mac Installer Distribution | n/a | .pkg installers for Mac App Store |
macOS only — out of scope here |
| Developer ID Application | n/a | Direct distribution outside the Mac App Store | macOS only — out of scope here |
For a typical iOS team, you only deal with Apple Development (for xcodebuild to local devices) and Apple Distribution (for releases).
.cerA .cer is an X.509 certificate. You can dump its contents:
openssl x509 -inform der -in ios_development.cer -text -noout
Output (abbreviated):
Certificate:
Data:
Version: 3 (0x2)
Serial Number: 6F:..:..:..
Signature Algorithm: sha256WithRSAEncryption
Issuer: CN = Apple Worldwide Developer Relations Certification Authority,
OU = G3, O = Apple Inc., C = US
Validity
Not Before: Mar 15 09:00:00 2025 GMT
Not After : Mar 15 09:00:00 2026 GMT
Subject: UID = ABCD1234EF, CN = Apple Development: Jane Doe (XYZ987QWER),
OU = TEAMID1234, O = Jane Doe, C = US
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (2048 bit)
...
X509v3 extensions:
X509v3 Extended Key Usage: critical
Code Signing
...
Things to notice:
Subject CN — Apple Development: Jane Doe (XYZ987QWER). The trailing 10-character string is the certificate identity ID (different from the team ID). This is what build settings refer to when you set CODE_SIGN_IDENTITY.Subject OU — your Team ID (also 10 characters but globally unique to your team, not your individual cert).Validity — 1 year for development certs, 1 year for distribution certs.Extended Key Usage: Code Signing — what flags this as a code-signing certificate as opposed to, say, an SSL or email certificate.This is a setup for confusion, so commit it now:
TEAMID1234. One per Apple Developer account. Identifies your team across all of Apple's systems. Visible in App Store Connect, in profile contents, in entitlements, in App Group identifiers, etc. Unchanging.XYZ987QWER. One per certificate. Embedded in the certificate's CN. Changes when you regenerate the certificate.When you read profile or build-setting metadata, look at which 10-character ID is being referenced.
Pre-Xcode 11, you had separate iOS Development and iOS Distribution certificates. Apple has since unified things:
This is why if you generate a new cert today, you don't see "iOS Development" anymore. Old profiles that reference iOS-only certs still exist, which can cause subtle "no signing certificate" errors when mixing old and new.
# All identities valid for code signing on this Mac
security find-identity -v -p codesigning
# Output looks like:
# 1) F8C9...A1 "Apple Development: Jane Doe (XYZ987QWER)"
# 2) 3DA2...0B "Apple Distribution: Acme Inc. (TEAMID1234)"
# 2 valid identities found
The hex prefix is the SHA-1 fingerprint of the certificate. Some tools (especially older Fastlane plugins) want this fingerprint instead of the human name. The 40-character hex is unique to this exact certificate on this exact Mac.
An App ID is Apple's record of "this bundle identifier exists, and it's allowed to use these capabilities." It lives on Apple's developer portal, not on your machine.
Two flavors:
com.acme.MyApp. Required for any app that uses capabilities Apple gates: Push Notifications, App Groups, iCloud, Sign in with Apple, In-App Purchase, etc.com.acme.*. Cannot use most capabilities. Useful for prototyping or for utility apps (debug-only tools) that share a profile.Rule of thumb: the moment you add any capability, you need an explicit App ID.
Your Info.plist ships with CFBundleIdentifier, which Xcode populates from the build setting PRODUCT_BUNDLE_IDENTIFIER:
<!-- Inside the built Info.plist -->
<key>CFBundleIdentifier</key>
<string>com.acme.MyApp</string>
// In the .xcodeproj's build settings
PRODUCT_BUNDLE_IDENTIFIER = com.acme.MyApp
For code signing to succeed, this bundle ID must be a substring-or-exact match against the App ID registered in the provisioning profile. Specifically:
com.acme.MyApp, then CFBundleIdentifier must equal com.acme.MyApp exactly.com.acme.*, then CFBundleIdentifier must match the pattern.Bundle IDs use reverse-DNS notation by convention (com.acme.MyApp, not MyApp). This isn't enforced by the tooling, but Apple's portal expects it and many tools assume it. Pick a domain you control as the prefix.
For app extensions (Today widgets, Share extensions, Notification Service Extensions, etc.), the bundle ID must be prefixed by the host app's bundle ID:
Host app: com.acme.MyApp
Widget: com.acme.MyApp.Widget ✓
Widget: com.acme.WidgetForApp ✗ (not a valid extension bundle ID)
Each extension needs its own App ID and its own provisioning profile. We'll cover this in section 19.
For development and Ad Hoc builds, every device that will run the build must be in the App ID's profile by UDID (Unique Device Identifier).
Three ways:
idevice_id:brew install libimobiledevice
idevice_id -l # lists UDIDs of connected devices
A UDID is a 25-character or 40-character hex string (Apple changed the format with newer devices). Example: 00008101-001A2B3C4D5E001E.
In the Apple Developer portal: Devices → +. Paste the UDID, give it a label.
A team has a per-membership-year limit of devices per platform: 100 iPhones, 100 iPads, 100 Macs, 100 Apple TVs, 100 Apple Watches per membership year. Devices can be disabled at any time, but the slot only frees up at annual renewal — important to remember when burning through slots in a large QA org.
Xcode 11+ can register a connected device automatically if you're using automatic signing.
This is the gotcha: a provisioning profile is a snapshot of the device list at the moment the profile was generated. If you add a new device to your team after generating the profile, that device is not yet authorized — you must regenerate (or, in Xcode automatic mode, re-fetch) the profile.
This is why CI builds for new beta testers fail with "device not in profile" until someone re-runs match or re-downloads.
A provisioning profile (.mobileprovision) is the central artifact that ties everything together. It's a signed property list that says, in effect:
"This App ID, signed by these certificates, may run on these devices, with these entitlements, between these dates."
| Type | Purpose | Devices | Distributed Via |
|---|---|---|---|
| iOS App Development | Run builds on registered dev/test devices | Listed in profile | xcodebuild, Xcode "Run" |
| Ad Hoc | Distribute to ≤100 registered testers per year, outside TestFlight | Listed in profile | .ipa to install via tools (Xcode, Apple Configurator, Diawi) |
| App Store | Submit to App Store / TestFlight | None — gated by App Store Connect | Upload via Transporter / xcodebuild -exportArchive |
| In-House (Enterprise) | Distribute internally to any employee device | Any device that trusts your org | MDM or direct .ipa |
A .mobileprovision file is a CMS (PKCS#7) signed plist. The signature wrapper proves Apple issued it. The plist inside contains the actual policy.
To inspect one yourself:
security cms -D -i MyApp_Development.mobileprovision
Output (abbreviated):
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>AppIDName</key>
<string>MyApp</string>
<key>ApplicationIdentifierPrefix</key>
<array><string>TEAMID1234</string></array>
<key>CreationDate</key>
<date>2026-01-15T10:00:00Z</date>
<key>ExpirationDate</key>
<date>2027-01-15T10:00:00Z</date>
<key>Name</key>
<string>iOS Team Provisioning Profile: com.acme.MyApp</string>
<key>TeamIdentifier</key>
<array><string>TEAMID1234</string></array>
<key>TeamName</key>
<string>Acme Inc.</string>
<key>Platform</key>
<array><string>iOS</string></array>
<key>UUID</key>
<string>F8E9D7C6-1234-5678-90AB-CDEF12345678</string>
<key>DeveloperCertificates</key>
<array>
<data>MIIFkzC...base64-encoded DER of cert 1...</data>
<data>MIIFkzC...base64-encoded DER of cert 2...</data>
</array>
<key>Entitlements</key>
<dict>
<key>application-identifier</key>
<string>TEAMID1234.com.acme.MyApp</string>
<key>com.apple.developer.team-identifier</key>
<string>TEAMID1234</string>
<key>get-task-allow</key>
<true/>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array><string>*</string></array>
</dict>
<key>ProvisionedDevices</key>
<array>
<string>00008101-001A2B3C4D5E001E</string>
<string>00008110-000234567890ABCD</string>
</array>
</dict>
</plist>
Field-by-field translation:
AppIDName — human label of the App ID.ApplicationIdentifierPrefix — the team ID, used as a prefix in entitlements.UUID — the profile's globally unique ID. This is what you reference in build settings (PROVISIONING_PROFILE_SPECIFIER accepts either name or UUID).DeveloperCertificates — base64'd DER of every cert that's allowed to sign with this profile. The signing cert must match one of these byte-for-byte. This is why "profile doesn't include certificate" errors happen — your local cert doesn't appear in this array.Entitlements — the maximum set of entitlements your binary may claim. Your binary's embedded entitlements must be a subset.ProvisionedDevices — UDIDs allowed to run this build. Absent for App Store profiles.get-task-allow — true for development (allows attaching debugger), false for distribution. Setting it incorrectly is a common reason TestFlight uploads fail.aps-environment — development or production. The push environment your binary targets.On a Mac, downloaded profiles live here:
# Modern macOS (Xcode 16+)
~/Library/Developer/Xcode/UserData/Provisioning Profiles/
# Older macOS
~/Library/MobileDevice/Provisioning Profiles/
Each file is named by its UUID:
F8E9D7C6-1234-5678-90AB-CDEF12345678.mobileprovision
Xcode reads this directory to populate the "Provisioning Profile" picker. You can copy a .mobileprovision here to make it available, or delete one to invalidate it locally.
Quick inspection script you'll want in your ~/.zshrc:
function profile() {
for f in ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/*.mobileprovision \
~/Library/MobileDevice/Provisioning\ Profiles/*.mobileprovision 2>/dev/null; do
[[ -f "$f" ]] || continue
name=$(security cms -D -i "$f" 2>/dev/null \
| /usr/libexec/PlistBuddy -c "Print :Name" /dev/stdin)
expiry=$(security cms -D -i "$f" 2>/dev/null \
| /usr/libexec/PlistBuddy -c "Print :ExpirationDate" /dev/stdin)
appid=$(security cms -D -i "$f" 2>/dev/null \
| /usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" /dev/stdin)
echo "$(basename $f .mobileprovision) $appid ($name) expires $expiry"
done
}
Run profile and you'll see every cached profile, what it's for, and when it expires.
When Xcode builds and signs your .app, it copies the active profile into the bundle as embedded.mobileprovision:
MyApp.app/embedded.mobileprovision
At install time, iOS reads this embedded profile, checks the device's UDID against ProvisionedDevices, and stores the profile in the device's profile database. From then on, whenever your app launches, iOS verifies the binary against the rules in that profile.
You can view profiles installed on a device under Settings → General → VPN & Device Management → Provisioning Profiles (the menu has shuffled around iOS versions, but it's always under General).
This is why teams sometimes find that yesterday's TestFlight build no longer launches: the profile actually expired, and the embedded profile has hit its ExpirationDate.
Entitlements are the system that links your App ID's authorized capabilities to what your binary actually claims. They're the "what is this app allowed to do?" answer.
.entitlements FileXcode generates a MyApp.entitlements file when you add your first capability. It's a plist:
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:acme.com</string>
<string>webcredentials:acme.com</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.acme.shared</string>
</array>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.acme.MyApp</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.acme.MyApp</string>
</array>
</dict>
</plist>
The build setting CODE_SIGN_ENTITLEMENTS points at this file:
CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements
During the Sign build phase, codesign reads the entitlements file and embeds the entitlements into the Mach-O LC_CODE_SIGNATURE load command of your executable.
This is the rule:
Your binary's embedded entitlements must be a subset of the profile's entitlements, which must be a subset of the App ID's enabled capabilities.
If you write aps-environment = production in your .entitlements but the profile only grants aps-environment = development, the build fails:
error: Provisioning profile "..." doesn't include the aps-environment entitlement.
If you add an entitlement Xcode hasn't surfaced as a capability — say, by hand-editing the plist — the build fails the same way. The fix is to enable the capability on the App ID (which updates what entitlements the profile grants) and then regenerate the profile.
| Key | Meaning |
|---|---|
application-identifier |
TEAMID.bundleid — every app gets this automatically |
com.apple.developer.team-identifier |
Your team ID — automatic |
get-task-allow |
true permits debugger attach; must be false for App Store / TestFlight |
aps-environment |
development or production for push notifications |
com.apple.security.application-groups |
App Group IDs for sharing data between extensions/apps |
com.apple.developer.associated-domains |
Universal Links and Web Credentials domains |
com.apple.developer.icloud-services |
iCloud capability |
com.apple.developer.icloud-container-identifiers |
iCloud container IDs |
keychain-access-groups |
Keychain sharing group(s) |
com.apple.developer.in-app-payments |
Apple Pay merchant IDs |
com.apple.developer.applesignin |
Sign in with Apple |
com.apple.developer.networking.wifi-info |
Read current Wi-Fi SSID |
com.apple.developer.networking.HotspotConfiguration |
Configure Wi-Fi hotspot |
com.apple.developer.kernel.increased-memory-limit |
Higher RAM ceiling for media apps |
The $(AppIdentifierPrefix) variable used in keychain-access-groups is interesting — Xcode rewrites it at build time to the team identifier prefix TEAMID1234.. This means the entitlement value differs between teams, which is why apps signed by Team A can't read Team B's keychain group.
codesign -d --entitlements :- MyApp.app
The :- says "print as XML to stdout, no header." Output is the same plist that's embedded in the Mach-O.
For just the executable inside the bundle:
codesign -d --entitlements :- MyApp.app/MyApp
This is gold for debugging "why is my app not getting push notifications?" — check whether aps-environment is even there.
.app BundleLet's open up a built, signed .app and walk through it.
MyApp.app/
├── MyApp # the Mach-O executable (signed)
├── Info.plist # bundle metadata
├── PkgInfo # legacy (4-char type + 4-char creator)
├── embedded.mobileprovision # the profile, copied in
├── _CodeSignature/
│ └── CodeResources # plist mapping every file to its hash
├── Frameworks/ # any embedded frameworks
│ └── Alamofire.framework/
│ ├── Alamofire # signed
│ └── _CodeSignature/
│ └── CodeResources
├── PlugIns/ # app extensions
│ └── MyAppWidget.appex/
│ ├── MyAppWidget # signed
│ ├── Info.plist
│ ├── embedded.mobileprovision # extension's own profile
│ └── _CodeSignature/
│ └── CodeResources
├── Assets.car # compiled asset catalog
├── Base.lproj/ # localized resources
│ ├── LaunchScreen.storyboardc
│ └── Main.storyboardc
├── en.lproj/
│ └── Localizable.strings
└── icons / images / fonts ...
Key observations:
embedded.mobileprovision. Extensions have their own bundle ID, App ID, and profile._CodeSignature/CodeResources is the integrity manifest. It's a plist that maps every file path in the bundle to a SHA-256 hash. The signature on the executable covers this file too, transitively covering every resource..frameworks from third parties are usually re-signed by Xcode during the Embed Frameworks phase using your team's identity.plutil -p MyApp.app/_CodeSignature/CodeResources | head -30
{
"files" => {
"Assets.car" => {data: <hash>},
"Info.plist" => {data: <hash>, hash2: <hash>},
"Base.lproj/Main.storyboardc/Info.plist" => {data: <hash>},
...
}
"files2" => {
...
}
"rules" => { ... }
"rules2" => { ... }
}
files and rules are the v1 manifest; files2 and rules2 are v2 (post-iOS 9). Both are present for compatibility.
The signature itself is a load command appended to the Mach-O binary. Inspect it with:
codesign -dvvv MyApp.app
Executable=/path/MyApp.app/MyApp
Identifier=com.acme.MyApp
Format=app bundle with Mach-O thin (arm64)
CodeDirectory v=20500 size=12345 flags=0x0(none) hashes=384+7 location=embedded
Hash type=sha256 size=32
CandidateCDHash sha256=...
Signature size=4567
Authority=Apple Development: Jane Doe (XYZ987QWER)
Authority=Apple Worldwide Developer Relations Certification Authority
Authority=Apple Root CA
Signed Time=Jan 15, 2026, 10:30:00 AM
Info.plist entries=42
TeamIdentifier=TEAMID1234
Sealed Resources version=2 rules=...
Internal requirements count=1 size=...
Useful signals:
Authority= lines show the trust chain. If any are missing, the chain is broken on this machine.TeamIdentifier confirms which team signed it.CodeDirectory describes the integrity hash structure.flags= can include things like runtime (hardened runtime, mostly macOS), library-validation, kill, etc.Now let's connect everything to what happens when you press ⌘B.
┌─ Resolve dependencies (SPM, Cocoapods, Carthage)
├─ Run "Pre-action" scripts (if any)
├─ For each target, in dependency order:
│ ├─ Compile Sources (Swift / Obj-C → .o)
│ ├─ Compile Asset Catalogs (Assets.xcassets → Assets.car)
│ ├─ Compile Storyboards (.storyboard → .storyboardc)
│ ├─ Process Info.plist
│ ├─ Link (.o + libs → Mach-O)
│ ├─ Copy Bundle Resources (everything else)
│ ├─ Embed Frameworks (third-party + dynamic)
│ ├─ Embed App Extensions (.appex bundles)
│ └─ CodeSign (the part we care about)
├─ Strip debug symbols (Release)
├─ Run "Post-action" scripts (if any)
└─ Final .app or .xcarchive
The "CodeSign" phase happens bottom-up: frameworks first, then extensions, then the main app. This ordering matters because the outer signature seals the hashes of the inner signed contents.
For each item to be signed:
CODE_SIGN_IDENTITY build setting. Look up matching certificate in keychain.PROVISIONING_PROFILE_SPECIFIER. Look up matching profile in ~/Library/Developer/Xcode/UserData/Provisioning Profiles/.DeveloperCertificates array.CODE_SIGN_ENTITLEMENTS. Check every entry is permitted by the profile.MyApp.app/embedded.mobileprovision._CodeSignature/), hash each, write _CodeSignature/CodeResources.This is exactly what the codesign command-line tool does. Xcode just orchestrates it across the bundle.
In Xcode's Report Navigator (⌘9), expand the build log for a fresh build. Find the "CodeSign" entries. They look like:
CodeSign /Users/jane/Library/Developer/Xcode/DerivedData/.../MyApp.app
cd /Users/jane/Projects/MyApp
export CODESIGN_ALLOCATE=/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/codesign_allocate
Signing Identity: "Apple Development: Jane Doe (XYZ987QWER)"
Provisioning Profile: "iOS Team Provisioning Profile: com.acme.MyApp"
(F8E9D7C6-1234-5678-90AB-CDEF12345678)
/usr/bin/codesign --force --sign F8C9...A1 \
--entitlements /...DerivedData/.../MyApp.app.xcent \
--timestamp=none --generate-entitlement-der \
/...DerivedData/.../MyApp.app
Observe: Xcode invokes /usr/bin/codesign directly. If you replicate that command in your terminal, you sign the same way Xcode does. This is the seam to inject for CI builds, custom signing flows, or post-build re-signing.
xcent FileYou'll notice MyApp.app.xcent referenced. This is a binary plist that Xcode writes during the build by combining your .entitlements file with build-setting overrides (especially the application-identifier and team identifier, which depend on the active profile). It's the actual entitlements that get embedded — your source .entitlements is just an input.
You can read it:
plutil -convert xml1 -o - /path/to/MyApp.app.xcent
When debugging "wrong entitlements" errors, the .xcent is the source of truth, not the source .entitlements file.
codesign)The codesign tool is your friend. Learn it.
# Quick check
codesign -v MyApp.app
# Verbose
codesign -vvv --deep --strict MyApp.app
# Verify against a particular requirement
codesign -v -R="anchor apple generic" MyApp.app
Exit code 0 = valid. Anything else = something is wrong, and -vvv will tell you what.
codesign -dvvv MyApp.app
Shows the team ID, signing authority chain, code directory hash, sealed resources count, etc. (Same output we covered in 10.2.)
# Old-style XML output (works everywhere)
codesign -d --entitlements :- MyApp.app
# DER output (newer iOS versions actually use DER)
codesign -d --entitlements - --xml MyApp.app
.appThis is what happens when you take an Ad Hoc .ipa and want to re-sign it for a different team or different profile (CI pipelines, white-label distribution, post-build patches):
# 1. Unzip the .ipa
unzip MyApp.ipa
# Now you have Payload/MyApp.app
# 2. Replace the embedded profile
cp NewProfile.mobileprovision Payload/MyApp.app/embedded.mobileprovision
# 3. Re-sign all embedded frameworks first (bottom-up!)
for fw in Payload/MyApp.app/Frameworks/*.framework; do
codesign --force --sign "Apple Distribution: Acme Inc. (TEAMID1234)" "$fw"
done
# 4. Re-sign any extensions
for ext in Payload/MyApp.app/PlugIns/*.appex; do
codesign --force --sign "Apple Distribution: Acme Inc. (TEAMID1234)" \
--entitlements ext.entitlements "$ext"
done
# 5. Re-sign the main app
codesign --force --sign "Apple Distribution: Acme Inc. (TEAMID1234)" \
--entitlements app.entitlements Payload/MyApp.app
# 6. Repackage
zip -qr MyApp-resigned.ipa Payload
Critical detail: bottom-up signing. If you sign the main app first, then change a framework, the main app's signature is invalidated.
codesign Flags Cheat Sheet| Flag | Purpose |
|---|---|
-s <identity> |
Sign with the given identity (CN or SHA-1) |
-f / --force |
Replace existing signature |
-d |
Display info (doesn't sign) |
-v |
Verify |
-vvv |
Verbose verify |
--deep |
Recurse into bundle (frameworks, extensions); largely deprecated in favor of explicit nested signing |
--entitlements <path> |
Use given entitlements file |
--timestamp=none |
Skip timestamping (iOS doesn't require it) |
--preserve-metadata=identifier,entitlements |
Re-sign without replacing entitlements |
--strict |
Stricter verification rules |
--remove-signature |
Strip a signature off (useful for lipo-edited binaries) |
These are the build settings code signing actually depends on. You can find each by typing its name into the search field on the Build Settings tab.
DEVELOPMENT_TEAM = TEAMID1234
Your team ID. Required for both automatic and manual signing.
CODE_SIGN_IDENTITY = "Apple Development"
CODE_SIGN_IDENTITY[sdk=iphoneos*] = "Apple Distribution: Acme Inc. (TEAMID1234)"
The identity to sign with. The [sdk=...] conditional lets you sign differently per SDK — typical for distribution. Note: "Apple Development" (with no specific name) tells Xcode "any Apple Development cert in my keychain" — useful for shared projects.
CODE_SIGN_STYLE = Automatic | Manual
Whether Xcode manages profiles or you do.
PROVISIONING_PROFILE_SPECIFIER = "iOS Team Provisioning Profile: com.acme.MyApp"
What profile to use. Accepts a profile name or a UUID. Names are nicer in source control (UUIDs change when profiles are regenerated).
PROVISIONING_PROFILE = F8E9D7C6-1234-5678-90AB-CDEF12345678
Deprecated — use the specifier. Still seen in older .pbxprojs.
CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements
Path to the entitlements file. Different per target — extensions have their own.
PRODUCT_BUNDLE_IDENTIFIER = com.acme.MyApp
The bundle ID. Must match the App ID referenced by the profile.
ENABLE_BITCODE = NO
Bitcode is deprecated as of Xcode 14. Set to NO; older projects often still have YES, which can cause distribution failures.
STRIP_INSTALLED_PRODUCT = YES
DEBUG_INFORMATION_FORMAT = dwarf-with-dsym (Release)
DEBUG_INFORMATION_FORMAT = dwarf (Debug)
Release builds need dSYM for crash symbolication; signing happens after stripping symbols.
Almost every build setting can vary by configuration. The typical layout:
| Setting | Debug | Release |
|---|---|---|
CODE_SIGN_IDENTITY |
Apple Development |
Apple Distribution: Acme Inc. |
PROVISIONING_PROFILE_SPECIFIER |
dev profile | distribution profile |
CODE_SIGN_ENTITLEMENTS |
MyApp.entitlements (with aps-environment = development) |
MyApp.Release.entitlements (with aps-environment = production) |
SWIFT_OPTIMIZATION_LEVEL |
-Onone |
-O |
DEBUG_INFORMATION_FORMAT |
dwarf |
dwarf-with-dsym |
# All settings for a target/configuration
xcodebuild -project MyApp.xcodeproj -target MyApp \
-configuration Release -showBuildSettings
# Filter to signing
xcodebuild -project MyApp.xcodeproj -target MyApp \
-configuration Release -showBuildSettings \
| grep -iE "code_sign|provisioning|team|entitle"
This is the single most useful command for debugging "Xcode is signing with the wrong cert". The output is the effective setting after all overrides — including from .xcconfig files, target settings, and project settings.
.xcconfig for SanityA .xcconfig file is a flat key-value file referenced by build configurations. They're fantastic for keeping signing config out of the (auto-generated, hard-to-merge) project.pbxproj:
// Signing.Release.xcconfig
DEVELOPMENT_TEAM = TEAMID1234
CODE_SIGN_STYLE = Manual
CODE_SIGN_IDENTITY = Apple Distribution
PROVISIONING_PROFILE_SPECIFIER = match AppStore com.acme.MyApp
CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.Release.entitlements
PRODUCT_BUNDLE_IDENTIFIER = com.acme.MyApp
Set this as the configuration file for your Release configuration in Xcode → Project → Info → Configurations. Now your signing config lives in version control as plain text, mergeable like any source file.
When you check "Automatically manage signing" in the target's Signing & Capabilities tab, Xcode does the following on every build:
PRODUCT_BUNDLE_IDENTIFIER and the entitlements file.~/Library/Developer/Xcode/UserData/Provisioning Profiles/.PROVISIONING_PROFILE_SPECIFIER and CODE_SIGN_IDENTITY accordingly.This is fantastic for solo developers and quick prototyping. It's terrible for teams because:
You explicitly set CODE_SIGN_IDENTITY and PROVISIONING_PROFILE_SPECIFIER. Xcode reads them, looks them up, fails loudly if anything's missing.
Manual signing is the right default for any team larger than one. Combined with Fastlane Match (covered later), it gives you reproducible, version-controlled signing.
A common pragmatic setup:
This is configurable per-configuration in the Signing & Capabilities tab (the "Automatically manage signing" checkbox is per-configuration once you click the chevron next to Debug/Release).
You have four distribution paths off the iOS platform. Each has its own profile type and its own quirks.
xcodebuild → connected device)The base case. You hit Run in Xcode with a device plugged in.
get-task-allow = true (so Xcode can attach the debugger)Distribute outside the App Store / TestFlight to specific testers. Less common now that TestFlight is good, but useful when:
You need to ship a build without going through Apple review.
You're handing builds to clients before App Store Connect access is sorted.
You need to test on devices that aren't part of TestFlight invitations.
Profile type: Ad Hoc
Devices: must be in profile (counts against your 100/year limit)
Cert: Apple Distribution
Entitlements: get-task-allow = false
Distribution mechanism: .ipa shared via tools like Diawi, Firebase App Distribution, raw web link, MDM, Apple Configurator
To export an Ad Hoc IPA from an archive:
xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportPath build/AdHoc \
-exportOptionsPlist ExportOptions-AdHoc.plist
ExportOptions-AdHoc.plist:
<dict>
<key>method</key>
<string>ad-hoc</string>
<key>teamID</key>
<string>TEAMID1234</string>
<key>signingStyle</key>
<string>manual</string>
<key>provisioningProfiles</key>
<dict>
<key>com.acme.MyApp</key>
<string>match AdHoc com.acme.MyApp</string>
</dict>
</dict>
The default release path.
get-task-allow = false, aps-environment = production.ipa to App Store Connect via Transporter, Xcode Organizer, or xcodebuildWhen you upload, Apple strips your signature and re-signs your binary with their own distribution certificate before delivering to users. This is why an App Store build's signature changes after upload — it's not the same bytes you uploaded. (A reason DRM-style fingerprinting based on signature hashes won't work post-distribution.)
The "FairPlay" wrapper is also added during App Store distribution — your executable section is encrypted, and only decrypts at runtime on the user's device, tied to their Apple ID. This is why you cannot statically analyze a third-party App Store binary with otool.
ExportOptions-AppStore.plist:
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>TEAMID1234</string>
<key>signingStyle</key>
<string>manual</string>
<key>uploadSymbols</key>
<true/>
<key>provisioningProfiles</key>
<dict>
<key>com.acme.MyApp</key>
<string>match AppStore com.acme.MyApp</string>
<key>com.acme.MyApp.Widget</key>
<string>match AppStore com.acme.MyApp.Widget</string>
</dict>
</dict>
Note: method was renamed from app-store to app-store-connect in Xcode 15+. Older .plists with app-store still work.
Apple Developer Enterprise Program ($299/year, separate from the standard $99 program). Lets you distribute internally to employees without a device list and without App Store review.
Enterprise builds are only for distributing to your own employees. Apple will revoke your enterprise cert (and brick all your apps) if they catch you using it for the public. They're aggressive about this — there have been high-profile revocations of Facebook and Google's enterprise certs for misuse.
| Development | Ad Hoc | App Store | Enterprise | |
|---|---|---|---|---|
| Cert | Apple Development | Apple Distribution | Apple Distribution | Apple Distribution (Enterprise) |
| Profile | iOS Dev | Ad Hoc | App Store | In-House |
| Devices needed? | Yes (≤100) | Yes (≤100) | No | No |
get-task-allow |
true | false | false | false |
| Reaches public? | No | No | Yes | No (employees only) |
| Reviewed by Apple? | No | No | Yes | No |
| Apple re-signs at delivery? | No | No | Yes | No |
| Cost | $99/yr | $99/yr | $99/yr | $299/yr |
TestFlight is technically a distribution mechanism, not a profile type. Builds uploaded to App Store Connect can be released via:
All of these use the App Store profile type. The decision of which channel a build goes to happens in App Store Connect, not in your build settings.
When you upload an .ipa to App Store Connect (via xcrun altool legacy, xcrun notarytool for macOS, or the modern xcodebuild -exportArchive + xcrun altool flow), Apple:
get-task-allow = false.aps-environment = production (if push is enabled).LC_VERSION_MIN_IPHONEOS (deployment target) is ≥ Apple's current minimum (currently iOS 15 for new submissions, 18 for some advanced features)..ipa and triggers TestFlight processing.After processing, the build appears in App Store Connect → TestFlight tab. Common failure modes:
The error is always emailed to the team agent — a step many people miss.
Crash reports come back symbolicated only if you uploaded the dSYM. Either:
uploadSymbols = true in your export options (Xcode does this for you when archiving via Organizer), orFor Crashlytics or other third-party crash reporters, you'll generally upload the dSYM to them via a script run phase using their CLI tool.
Once you've internalized everything above, Fastlane Match stops feeling like magic and starts feeling like a sensible automation of what you already understand.
Match's core idea:
The result: every machine in your team uses the same signing identity. No more "works on my machine, fails on CI" because of mismatched profiles.
# Install
brew install fastlane
# In your project root
fastlane match init
This walks you through choosing storage (Git is most common). Creates Matchfile:
git_url("git@github.com:acme/certificates.git")
storage_mode("git")
type("development") # default; can be overridden per-lane
app_identifier(["com.acme.MyApp", "com.acme.MyApp.Widget"])
username("ci@acme.com")
team_id("TEAMID1234")
Then run, separately for each profile type you need:
# Generates dev cert + dev profiles for all bundle IDs
fastlane match development
# Distribution cert + App Store profiles
fastlane match appstore
# Distribution cert (same as appstore) + Ad Hoc profiles
fastlane match adhoc
The first run prompts for a passphrase. Save that passphrase in 1Password / your secrets manager. Without it, the encrypted Git contents are useless.
Look at the certificates Git repo after running Match:
certificates/
├── README.md
├── certs/
│ ├── development/
│ │ ├── ABCD1234EF.cer # public cert
│ │ └── ABCD1234EF.p12 # encrypted cert + private key
│ └── distribution/
│ ├── EFGH5678IJ.cer
│ └── EFGH5678IJ.p12
└── profiles/
├── development/
│ ├── Development_com.acme.MyApp.mobileprovision
│ └── Development_com.acme.MyApp.Widget.mobileprovision
└── appstore/
├── AppStore_com.acme.MyApp.mobileprovision
└── AppStore_com.acme.MyApp.Widget.mobileprovision
The .p12 is encrypted with your match passphrase. The .cer is just the public certificate (already public, no need to encrypt). The .mobileprovision files are encrypted too.
After running fastlane match appstore, your Xcode project should be set to manual signing with:
PROVISIONING_PROFILE_SPECIFIER = match AppStore com.acme.MyApp
CODE_SIGN_IDENTITY = Apple Distribution
DEVELOPMENT_TEAM = TEAMID1234
The format match AppStore com.acme.MyApp is just the profile name match generates. You can write it explicitly in .xcconfig and forget about it.
fastlane/Fastfile:
default_platform(:ios)
platform :ios do
desc "Run unit tests"
lane :test do
run_tests(
scheme: "MyApp",
device: "iPhone 15"
)
end
desc "Sync development signing"
lane :sync_dev_signing do
match(type: "development", readonly: is_ci)
end
desc "Sync release signing"
lane :sync_release_signing do
match(type: "appstore", readonly: is_ci)
end
desc "Build and upload to TestFlight"
lane :beta do
sync_release_signing
increment_build_number(xcodeproj: "MyApp.xcodeproj")
build_app(
scheme: "MyApp",
configuration: "Release",
export_method: "app-store",
export_options: {
provisioningProfiles: {
"com.acme.MyApp" => "match AppStore com.acme.MyApp",
"com.acme.MyApp.Widget" => "match AppStore com.acme.MyApp.Widget"
}
}
)
upload_to_testflight(
skip_waiting_for_build_processing: true,
api_key_path: "fastlane/AppStoreConnectAPI.json"
)
end
end
Key design choices:
readonly: is_ci — on CI, never let Match generate new certs (which would break local devs). Local devs run with readonly: false and can regenerate when needed.upload_to_testflight — replaces 2FA-prone username/password. Generate at App Store Connect → Users and Access → Keys.Apple's modern auth for CI:
.p8 file (only once — re-download is impossible).AppStoreConnectAPI.json:
{
"key_id": "ABC123DEFG",
"issuer_id": "12345678-1234-1234-1234-123456789abc",
"key": "-----BEGIN PRIVATE KEY-----\nMIGTA...\n-----END PRIVATE KEY-----",
"in_house": false
}
Store this whole JSON as a CI secret (GitHub Actions: encrypted secret; GitLab: protected variable).
.github/workflows/beta.yml:
name: Beta Build
on:
push:
branches: [release/*]
jobs:
build:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_16.0.app
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Set up SSH for match repo
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.MATCH_SSH_KEY }}
- name: Write App Store Connect API key
run: |
echo '${{ secrets.ASC_API_KEY_JSON }}' \
> fastlane/AppStoreConnectAPI.json
- name: Beta lane
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_KEYCHAIN_NAME: ci-keychain
MATCH_KEYCHAIN_PASSWORD: ${{ secrets.CI_KEYCHAIN_PASSWORD }}
run: bundle exec fastlane beta
Two things worth understanding:
MATCH_KEYCHAIN_NAME setup. On CI, you don't want Match installing certs into the default login.keychain. You create a fresh, ephemeral keychain that exists only for this build, install certs there, set it as the default, and let codesign find them. Match has create_keychain action helpers; many CI setups roll their own.MATCH_PASSWORD is the encryption passphrase you set during fastlane match init.Real-world errors and what they mean.
Cause: there's no profile in ~/Library/Developer/Xcode/UserData/Provisioning Profiles/ whose application-identifier entitlement matches TEAMID.com.acme.MyApp.
Fixes (in order of likelihood):
fastlane match development --readonly, or hit "Download Manual Profiles" in Xcode → Settings → Accounts → Manage Certificates.ExpirationDate of the file. Regenerate.PRODUCT_BUNDLE_IDENTIFIER matches the App ID exactly (case-sensitive).Cause: the cert in your keychain isn't in the profile's DeveloperCertificates.
Fixes:
security find-identity -v -p codesigning — pick one and pin it via CODE_SIGN_IDENTITY.match nuke development && match development.Cause: your .entitlements file requests a capability the profile doesn't grant.
Fixes:
aps-environment = development; release profile grants production. If you build Release with a dev profile, this fires.Cause: something altered the bundle after signing. Common culprits:
.DS_Store or other cruft that snuck in.Fix: examine your build phases. Anything that touches the bundle must run before the Sign phase. Drag phases into correct order in the target's Build Phases tab.
Cause: a framework or extension is signed with cert A, the main app with cert B.
Fix:
CODE_SIGN_IDENTITY and DEVELOPMENT_TEAM..framework bundles, ensure "Code Sign On Copy" is checked in Embed Frameworks. This re-signs them with your identity during embed.Often a device-side complaint, less informative. Try:
# Xcode → Window → Devices and Simulators → device → "View Device Logs"
# Or:
log stream --device --predicate 'subsystem == "com.apple.MobileInstallation"'
The MobileInstallation logs spell out exactly what failed: profile not on device, UDID not authorized, entitlement mismatch, etc.
Cause: that bundle ID is taken (in another team) or already exists in your team but you don't have permission.
Fix: pick a different bundle ID, or contact your team admin.
When in doubt, run the four commands:
# 1. What identities are on this machine?
security find-identity -v -p codesigning
# 2. What profiles are cached?
ls -la ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/
# 3. What's the effective signing setup?
xcodebuild -project MyApp.xcodeproj -target MyApp \
-configuration Release -showBuildSettings \
| grep -iE "code_sign|provisioning|team"
# 4. Inspect the active profile
security cms -D -i ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/<UUID>.mobileprovision
90% of signing problems become obvious from those four outputs.
Every extension (Today widget, Share extension, Notification Service Extension, Notification Content Extension, Intents extension, WidgetKit extension, Network Extension, etc.) is a separate target with:
Build settings will look like:
# Host app
PRODUCT_BUNDLE_IDENTIFIER = com.acme.MyApp
PROVISIONING_PROFILE_SPECIFIER = match AppStore com.acme.MyApp
# Widget extension
PRODUCT_BUNDLE_IDENTIFIER = com.acme.MyApp.Widget
PROVISIONING_PROFILE_SPECIFIER = match AppStore com.acme.MyApp.Widget
When archiving, Xcode signs the extension first, embeds it into the host app's PlugIns/ directory, then signs the host app — the host's signature seals the (already-signed) extension. The chain validates because the extension's own signature is also valid.
In match, you list every bundle ID:
app_identifier([
"com.acme.MyApp",
"com.acme.MyApp.Widget",
"com.acme.MyApp.NotificationService",
"com.acme.MyApp.ShareExtension"
])
Match generates a separate profile for each.
App Groups let your host app and extensions share UserDefaults, files (via FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:)), and Keychain items.
Setup:
Developer portal → Identifiers → App Groups → + → register group.com.acme.shared.
Enable App Groups capability on each App ID, select the group.
In Xcode, on each target's Signing & Capabilities tab → + Capability → App Groups → check the group.
Xcode adds to each .entitlements:
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.acme.shared</string>
</array>
Regenerate profiles (Match: fastlane match nuke development && fastlane match development).
In code:
// Shared UserDefaults
let shared = UserDefaults(suiteName: "group.com.acme.shared")
shared?.set("hello", forKey: "greeting")
// Shared file container
let url = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.acme.shared")!
.appendingPathComponent("data.json")
The two targets — host app and extension — must both have the App Group entitlement, and both must have a profile granting it. If only one does, the other's containerURL(...) returns nil.
Two pieces:
App side (entitlement):
<key>aps-environment</key>
<string>development</string> <!-- or "production" for App Store / TestFlight -->
Server side (auth to APNs): two options.
Generate an APNs Authentication Key at App Store Connect → Users and Access → Keys → APNs. This gives a single .p8 that works for any of your apps (per team). Your server signs short-lived JWTs with this key to authenticate to APNs.
# Pseudo-code: server-side JWT for APNs
jwt = encode_jwt(
headers={"alg": "ES256", "kid": APNS_KEY_ID},
payload={"iss": TEAM_ID, "iat": int(time.time())},
key=APNS_KEY_PRIVATE_BYTES
)
# Send notification with this JWT in Authorization: bearer <jwt>
You generate an Apple Push Services certificate per app on the developer portal. Export it as .p12 and load on your server. Each app needs its own cert; certs expire annually.
Token-based is the modern path; cert-based is still supported but you should not be choosing it for new projects.
In your .entitlements:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:acme.com</string>
<string>applinks:www.acme.com</string>
<string>webcredentials:acme.com</string>
</array>
Server side: host an apple-app-site-association JSON file at https://acme.com/.well-known/apple-app-site-association (no .json extension):
{
"applinks": {
"details": [{
"appIDs": ["TEAMID1234.com.acme.MyApp"],
"components": [
{ "/": "/products/*" },
{ "/": "/account/*" }
]
}]
},
"webcredentials": {
"apps": ["TEAMID1234.com.acme.MyApp"]
}
}
Apple's CDN (in iOS 14+) caches this file, fetched at app install time. Bugs in this JSON are silent — the entitlement appears correct but links don't open in your app. Test by tapping the link from Notes, or use Settings → Developer → Universal Links Diagnostics on a developer-mode device.
iOS apps run with a hardened runtime by default — you don't toggle it. But certain entitlements (debugger attach, JIT, dyld variables) require explicit opt-in:
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
These are rare on iOS — you'll see them most often when shipping JS-engine apps (React Native dev menu, advanced WebKit usage) or DRM-protected media. If you're using them, you should know exactly why.
If you have a built .ipa and need to ship it to a different team (white-label scenarios, IT redistribution):
# 1. Unzip
unzip -q MyApp.ipa -d MyApp_unzipped
cd MyApp_unzipped
# 2. Replace embedded profile
cp /path/to/NewTeam_AdHoc.mobileprovision Payload/MyApp.app/embedded.mobileprovision
# 3. Extract the entitlements from the new profile to use during re-sign
security cms -D -i Payload/MyApp.app/embedded.mobileprovision \
> /tmp/profile.plist
/usr/libexec/PlistBuddy -x -c "Print :Entitlements" /tmp/profile.plist \
> /tmp/entitlements.plist
# 4. Re-sign frameworks (bottom-up!)
for fw in Payload/MyApp.app/Frameworks/*.framework \
Payload/MyApp.app/Frameworks/*.dylib; do
codesign --force --sign "Apple Distribution: NewTeam (NEWTEAMID12)" \
"$fw"
done
# 5. Re-sign extensions
for ext in Payload/MyApp.app/PlugIns/*.appex; do
# Extract that extension's entitlements similarly, sign with them
codesign --force --sign "Apple Distribution: NewTeam (NEWTEAMID12)" \
--entitlements /tmp/ext-entitlements.plist \
"$ext"
done
# 6. Re-sign main app
codesign --force --sign "Apple Distribution: NewTeam (NEWTEAMID12)" \
--entitlements /tmp/entitlements.plist \
Payload/MyApp.app
# 7. Verify
codesign -vvv --deep --strict Payload/MyApp.app
# 8. Repackage
zip -qr MyApp_resigned.ipa Payload
There's also a popular wrapper called iresign (and various open-source tools like iOS-Resign-Tool) that automates this dance.
Mentioned for completeness: macOS apps distributed outside the Mac App Store must be notarized — submitted to Apple's notary service, which scans for malware and returns a "ticket" that gets stapled to the app. iOS has no equivalent because all iOS apps are gated through App Store / TestFlight (or Enterprise / Ad Hoc) anyway.
The signing model is identical across Apple's platforms:
The unified "Apple Development" / "Apple Distribution" certs (from section 5) work for all of them. Only the profile types are platform-specific.
A few rules of thumb earned from blood-on-the-floor lessons:
The moment more than one person — or one machine — builds your app, switch to manual signing and Match (or an equivalent). Automatic signing is fine for solo prototyping; it's an active source of pain on teams.
.xcconfigThe project.pbxproj is auto-generated and merge-conflicts hell. Move signing-related build settings into .xcconfig files in version control. Plain text, mergeable, reviewable.
.p12 Files into Source Control UnencryptedEven private repos. A leaked private signing key means anyone can ship malware as your team. Match encrypts everything — use it.
2FA-protected Apple IDs break CI flows. Get an API key, store it as a CI secret, never look back.
Add a calendar reminder for ~3 weeks before your profile expires. A stale profile in production is fine if your app's already on user devices via App Store; it bites in TestFlight (where users get "this beta has expired"). The trick: ship a fresh build before the expiration date.
Spin up a new Mac (cloud Mac, fresh CI runner, restored backup) and try to build your app from scratch using only the docs in your repo. If it doesn't work, your docs are wrong. This is the litmus test for whether new hires can actually start contributing on day one.
Don't skim. The exact error text matters. The five-line context matters. Searching the exact error string on Apple Developer Forums almost always finds your problem. Most signing issues have a known cause; the trick is matching the symptom precisely.
If your match Git repo is destroyed or its credentials are lost, your team can no longer ship. Mirror it. Back up the .p12s elsewhere. Document the passphrase in a way two people can recover it. This is your business-continuity asset.
nukefastlane match nuke development and fastlane match nuke distribution revoke and delete every cert and profile in their category. Sometimes you've drifted so far that the cleanest path forward is a clean slate. The cost is ~5 minutes of regeneration. Don't fear it.
ExportOptions.plistThe export options are part of how you distribute, and reviewing changes to them in PR is valuable. Keep them in fastlane/, not in scratch directories.
A reference to refer back to.
com.acme.MyApp. The unique reverse-DNS identifier of your app, set in Info.plist as CFBundleIdentifier..cer file. Public information.developer.apple.com/account — where Apple Developer Program members manage certs, App IDs, devices, and profiles.codesign.Info.plist — Bundle metadata: bundle ID, version, required device capabilities, etc..ipa — iOS application archive — a zip of Payload/MyApp.app/..mobileprovision — Provisioning profile file. CMS-signed plist..xcent — Compiled entitlements file Xcode generates during build..xcconfig — Plain-text build configuration file. Use these.Code signing on iOS feels like alchemy until you understand the four agreements from section 2:
Every error message is one of those four agreements failing. Once you can read a .mobileprovision by hand, every signing failure becomes a structured debugging exercise rather than a roll of the dice.
Save this guide. Refer back to it the next time someone on your team asks why their TestFlight build won't install. You'll be the one who actually knows.
Now go ship.