Summary
eas build --profile development --platform ios --local fails at the Prepare credentials step on macOS Tahoe (26.x) with Distribution certificate <fingerprint> hasn't been imported successfully, even though the certificate and private key were imported into the build keychain successfully. The failure is in a presence-check that uses security find-identity -v against an ephemeral build keychain that — by design — does not contain the full Apple trust chain required for -v's "validity" judgment.
Symptom
✖ Prepare credentials
Error: Distribution certificate with fingerprint <FINGERPRINT> hasn't been imported successfully
at Keychain.ensureCertificateImported (.../@expo/build-tools/dist/ios/credentials/keychain.js:57:19)
The cert+key are in fact imported. Verified: running the same import_certificate fastlane action manually against the same .p12 produces 1 identity imported. and the fingerprint shows up in security find-identity (without -v) in the build keychain, annotated (CSSMERR_TP_NOT_TRUSTED).
Reproduction
- macOS Tahoe (26.x), Xcode 26.x, fastlane 2.x, eas-cli 18.8+ (any version that pulls
eas-cli-local-build-plugin >= 18.8.0 with @expo/build-tools >= 18.8.0).
- Project configured for an iOS development build (real-device, not simulator).
npx eas-cli build --profile development --platform ios --local.
- EAS downloads the dist .p12 and provisioning profile from Expo's server (or reads from
credentials.json — same downstream path), creates a fresh ephemeral keychain at os.tmpdir()/eas-build-<uuid>.keychain, imports the cert+key into it, and then immediately invokes findIdentitiesByTeamId to verify the import. That call fails.
Root cause
Keychain.findIdentitiesByTeamId (packages/build-tools/src/ios/credentials/keychain.ts#L100-L107):
private async findIdentitiesByTeamId(teamId: string): Promise<string> {
const { output } = await spawn(
'security',
['find-identity', '-v', '-s', `(${teamId})`, this.keychainPath],
{ stdio: 'pipe' }
);
return output.join('');
}
The -v flag means "valid identities only". security's validity judgment requires the full trust chain (dist cert → Apple WWDR Intermediate → Apple Root CA) to resolve from the keychain(s) in the search list. The build keychain created in Keychain.create() only holds the dist cert + private key. Apple Root CA lives in /Library/Keychains/System.keychain and Apple WWDR G3 lives in the user's login keychain.
Critically: passing System.keychain (or any other keychain) as a positional argument to security find-identity does not cause it to aggregate trust resolution across keychains. Trust aggregation across keychains only happens for the user's session-wide search list (security list-keychains -s) — which Keychain.create() deliberately does not modify (and shouldn't, because that change is session-wide and would surprise the user).
So on a fresh ephemeral keychain holding only the cert+key, find-identity -v -s "(<teamId>)" <buildKeychainPath> returns 0 identities, ensureCertificateImported throws, and the build dies — even though the cert+key are present and the build would actually succeed if allowed to proceed (because gym/codesign later perform their own trust resolution via Security.framework, which does aggregate across keychains).
This is fundamentally a misuse of find-identity -v as a presence check. -v's "valid" predicate depends on the whole trust environment, not just whether the cert+key are in the named keychain.
Proposed fix
Drop the -v flag. The remaining flags (-s "(${teamId})" <buildKeychainPath>) still constrain the match to the right team and keychain, so the includes(fingerprint) check in ensureCertificateImported correctly answers "is the cert+key pair present in this keychain?" The cert will appear in the output annotated (CSSMERR_TP_NOT_TRUSTED), but that's expected and ignored — codesign resolves trust correctly downstream via Security.framework.
private async findIdentitiesByTeamId(teamId: string): Promise<string> {
const { output } = await spawn(
'security',
- ['find-identity', '-v', '-s', `(${teamId})`, this.keychainPath],
+ ['find-identity', '-s', `(${teamId})`, this.keychainPath],
{
stdio: 'pipe',
}
);
return output.join('');
}
This is consistent with what the surrounding code is actually trying to do (verify import succeeded), and matches what works in practice on every macOS version I've tested (a dropped--v find-identity reliably reports the imported cert+key, and the build subsequently succeeds end-to-end).
Alternative fix (less preferred)
Inject Apple Root CA + WWDR G3 into the build keychain at creation time. This would let -v continue to make sense semantically. Trade-off: requires bundling/fetching the Apple intermediates and adds a second security call per build. The drop--v fix has none of that overhead, doesn't change observable behavior (codesign still resolves trust), and matches the intent of the call (presence check, not validity check).
Workaround (currently in use)
Manually edit ~/.npm/_npx/<hash>/node_modules/@expo/build-tools/dist/ios/credentials/keychain.js to drop the -v. Fragile — wiped on npx clear-cache, on eas-cli upgrade (different eas-cli-local-build-plugin version → new npx hash → fresh download), and on fresh-Mac setup. Re-applying it programmatically requires a small idempotent shell script that globs the npx cache; we run such a script from our repo's scripts/ directory after every cache invalidation event.
credentialsSource: \"local\" in eas.json does not work around this bug — credentialsSource only changes where eas-cli sources the .p12 (Expo's server vs. local credentials.json); the same manager.js → keychain.js code path runs downstream regardless. We discovered this the hard way after planning a credentials.json migration; the only way forward is dropping -v upstream.
Environment
- macOS 26.0 (Tahoe)
- Xcode 26.x
- Node 24.15.0
- eas-cli 18.x (verified on 18.8.0 and 18.11.0)
eas-cli-local-build-plugin 18.8.0 / @expo/build-tools 18.8.0
- fastlane 2.x
Related code
Happy to send a PR for the one-line fix if it's welcome.
Summary
eas build --profile development --platform ios --localfails at thePrepare credentialsstep on macOS Tahoe (26.x) withDistribution certificate <fingerprint> hasn't been imported successfully, even though the certificate and private key were imported into the build keychain successfully. The failure is in a presence-check that usessecurity find-identity -vagainst an ephemeral build keychain that — by design — does not contain the full Apple trust chain required for-v's "validity" judgment.Symptom
The cert+key are in fact imported. Verified: running the same
import_certificatefastlane action manually against the same.p12produces1 identity imported.and the fingerprint shows up insecurity find-identity(without-v) in the build keychain, annotated(CSSMERR_TP_NOT_TRUSTED).Reproduction
eas-cli-local-build-plugin>= 18.8.0 with@expo/build-tools>= 18.8.0).npx eas-cli build --profile development --platform ios --local.credentials.json— same downstream path), creates a fresh ephemeral keychain atos.tmpdir()/eas-build-<uuid>.keychain, imports the cert+key into it, and then immediately invokesfindIdentitiesByTeamIdto verify the import. That call fails.Root cause
Keychain.findIdentitiesByTeamId(packages/build-tools/src/ios/credentials/keychain.ts#L100-L107):The
-vflag means "valid identities only".security's validity judgment requires the full trust chain (dist cert → Apple WWDR Intermediate → Apple Root CA) to resolve from the keychain(s) in the search list. The build keychain created inKeychain.create()only holds the dist cert + private key. Apple Root CA lives in/Library/Keychains/System.keychainand Apple WWDR G3 lives in the user's login keychain.Critically: passing
System.keychain(or any other keychain) as a positional argument tosecurity find-identitydoes not cause it to aggregate trust resolution across keychains. Trust aggregation across keychains only happens for the user's session-wide search list (security list-keychains -s) — whichKeychain.create()deliberately does not modify (and shouldn't, because that change is session-wide and would surprise the user).So on a fresh ephemeral keychain holding only the cert+key,
find-identity -v -s "(<teamId>)" <buildKeychainPath>returns 0 identities,ensureCertificateImportedthrows, and the build dies — even though the cert+key are present and the build would actually succeed if allowed to proceed (because gym/codesign later perform their own trust resolution viaSecurity.framework, which does aggregate across keychains).This is fundamentally a misuse of
find-identity -vas a presence check.-v's "valid" predicate depends on the whole trust environment, not just whether the cert+key are in the named keychain.Proposed fix
Drop the
-vflag. The remaining flags (-s "(${teamId})" <buildKeychainPath>) still constrain the match to the right team and keychain, so theincludes(fingerprint)check inensureCertificateImportedcorrectly answers "is the cert+key pair present in this keychain?" The cert will appear in the output annotated(CSSMERR_TP_NOT_TRUSTED), but that's expected and ignored — codesign resolves trust correctly downstream viaSecurity.framework.private async findIdentitiesByTeamId(teamId: string): Promise<string> { const { output } = await spawn( 'security', - ['find-identity', '-v', '-s', `(${teamId})`, this.keychainPath], + ['find-identity', '-s', `(${teamId})`, this.keychainPath], { stdio: 'pipe', } ); return output.join(''); }This is consistent with what the surrounding code is actually trying to do (verify import succeeded), and matches what works in practice on every macOS version I've tested (a dropped-
-vfind-identity reliably reports the imported cert+key, and the build subsequently succeeds end-to-end).Alternative fix (less preferred)
Inject Apple Root CA + WWDR G3 into the build keychain at creation time. This would let
-vcontinue to make sense semantically. Trade-off: requires bundling/fetching the Apple intermediates and adds a secondsecuritycall per build. The drop--vfix has none of that overhead, doesn't change observable behavior (codesign still resolves trust), and matches the intent of the call (presence check, not validity check).Workaround (currently in use)
Manually edit
~/.npm/_npx/<hash>/node_modules/@expo/build-tools/dist/ios/credentials/keychain.jsto drop the-v. Fragile — wiped onnpx clear-cache, oneas-cliupgrade (differenteas-cli-local-build-pluginversion → new npx hash → fresh download), and on fresh-Mac setup. Re-applying it programmatically requires a small idempotent shell script that globs the npx cache; we run such a script from our repo'sscripts/directory after every cache invalidation event.credentialsSource: \"local\"ineas.jsondoes not work around this bug —credentialsSourceonly changes where eas-cli sources the .p12 (Expo's server vs. localcredentials.json); the samemanager.js→keychain.jscode path runs downstream regardless. We discovered this the hard way after planning a credentials.json migration; the only way forward is dropping-vupstream.Environment
eas-cli-local-build-plugin18.8.0 /@expo/build-tools18.8.0Related code
packages/build-tools/src/ios/credentials/keychain.ts—findIdentitiesByTeamId,ensureCertificateImportedpackages/build-tools/src/ios/credentials/manager.ts—prepareTargetCredentials(callsensureCertificateImportedafter import)Happy to send a PR for the one-line fix if it's welcome.