Build/Submit details page URL
No response
Summary
eas submit -p ios cannot use a valid Admin-role App Store Connect API key when the Apple ID that eas submit signs in with is not an Admin on the team. The submit flow forces an Apple ID login to look up the app (there is no way to decline it unless ascAppId is in the profile), then reuses that session to validate the key against GET /iris/v1/apiKeys/{id}, an endpoint App Store Connect gates on the signed-in user's team role. The 403 from that call is thrown, so the submission dies before the key is ever used. The same key works fine through eas credentials (where the Apple login can be declined) and through ascApiKeyPath / ascApiKeyId / ascApiKeyIssuerId in eas.json, because neither path takes a session and therefore neither validates.
Expected: what the docs say (Apple Developer Program roles): "Once an Admin or Account Holder has created the key and downloaded the .p8 file, any authorized developer on the Expo project can configure it using eas credentials or define it directly via eas.json fields". A key validation the user's role does not permit should degrade to the same "proceed anyway?" prompt that the unauthenticated path already has, not abort the submission.
This is the mechanism behind #2136 (closed). The last comment there (2026-09-09) reached the same conclusion from the outside (the user's own membership role gates it, not the key's role); this report pins it to the code.
Managed or bare?
Managed (continuous native generation; ios/ and android/ are gitignored prebuild output, which is why expo-env-info prints Expo Workflow: bare below).
Environment
eas-cli/24.0.0 darwin-arm64 node-v20.19.4 (global install via bun; expo-env-info does not see the bun global registry, so its npmGlobalPackages line is corrected by hand below).
expo-env-info 2.1.0 environment info:
System:
OS: macOS 26.5.2
Shell: 5.9 - /bin/zsh
Binaries:
Node: 20.19.4 - ~/.nvm/versions/node/v20.19.4/bin/node
Yarn: 1.22.22 - ~/.nvm/versions/node/v20.19.4/bin/yarn
npm: 10.8.2 - ~/.nvm/versions/node/v20.19.4/bin/npm
Managers:
CocoaPods: 1.16.2 - /opt/homebrew/bin/pod
SDKs:
iOS SDK:
Platforms: DriverKit 25.5, iOS 26.5, macOS 26.5, tvOS 26.5, visionOS 26.5, watchOS 26.5
IDEs:
Android Studio: 2025.2 AI-252.25557.131.2521.14344949
Xcode: 26.5/17F42 - /usr/bin/xcodebuild
npmPackages:
expo: ~57.0.20 => 57.0.20
expo-router: ~57.0.19 => 57.0.19
react: 19.2.3 => 19.2.3
react-dom: 19.2.3 => 19.2.3
react-native: 0.86.3 => 0.86.3
react-native-web: ~0.21.0 => 0.21.2
npmGlobalPackages:
eas-cli: 24.0.0 (bun global install)
Expo Workflow: bare <- see above; the project is managed
Error output
eas submit --platform ios --profile <profile>, submit profile {}, Apple ID login accepted when prompted (the Apple ID has a non-Admin role on the team), then "Add a new ASC API Key" with the path to an Admin-role .p8 and its key ID (with a session present the CLI does not ask for the issuer ID; it goes to fetch it):
- Fetching App Store Connect API Key.
Error: Apple 403 detected - Access forbidden.
This request is forbidden for security reasons - The API key in use does not allow this request
✖ Failed to fetch App Store Connect API Key.
Apple 403 detected - Access forbidden.
This request is forbidden for security reasons - The API key in use does not allow this request
d [Error]: Apple 403 detected - Access forbidden.
This request is forbidden for security reasons - The API key in use does not allow this request
at t.getAppleResponseError (.../@expo/apple-utils/build/index.js:1:991954)
at block (.../@expo/apple-utils/build/index.js:1:999457)
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async O (.../@expo/apple-utils/build/index.js:1:999926)
at async I (.../@expo/apple-utils/build/index.js:1:998852)
at async t.requestAsync (.../@expo/apple-utils/build/index.js:1:997462)
at async t.providerRequestAsync (.../@expo/apple-utils/build/index.js:1:841975)
at async a.requestAsync (.../@expo/apple-utils/build/index.js:1:848367)
at async a.requestAndParseAsync (.../@expo/apple-utils/build/index.js:1:849063)
at async a.fetchSingleModelAsync (.../@expo/apple-utils/build/index.js:1:848829) {
response: {
status: 403,
statusText: 'Forbidden',
config: {
url: 'https://appstoreconnect.apple.com/iris/v1/apiKeys/XXXXXXXXXX',
method: 'get',
withCredentials: true,
baseURL: 'https://appstoreconnect.apple.com/iris/v1/',
...
},
request: {
_header: 'GET /iris/v1/apiKeys/XXXXXXXXXX?include=createdBy%2CrevokedBy%2Cprovider HTTP/1.1\r\n' +
'Cookie: myacinfo=...; itctx=...; ...' +
...
}
}
}
Error: submit command failed.
Note the request: it is the private iris/v1 web API, authenticated with the Apple ID session cookies, not the public ASC API with the .p8. The key was never exercised. The key itself is fine: the same file, key ID and issuer ID, set up through eas credentials with the Apple login declined, submitted the same build to TestFlight without any change to eas.json.
Where it happens (eas-cli main)
packages/eas-cli/src/submit/ios/IosSubmitCommand.ts, resolveAscAppIdentifierAsync (L172-L194): without ascAppId in the profile, ensureAppStoreConnectAppExistsAsync runs, and that requires the Apple ID login. This is the login the user cannot decline in eas submit.
packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts, promptForAscApiKeyPathAsync (L37-L47): after the .p8 path and key ID prompts it calls getBestEffortIssuerIdAsync (L184-L192). With ctx.appStore.authCtx set by step 1, that is ctx.appStore.getAscApiKeyAsync(keyId), i.e. GET /iris/v1/apiKeys/{id} through the session. This is the Fetching App Store Connect API Key spinner in the output, and it is where the 403 is raised. Without a session the function returns null and the CLI asks for the issuer ID instead; that prompt is the fallback the function's name promises, and a 403 never reaches it because the exception propagates.
- App Store Connect gates
/apiKeys on the signed-in user's membership role (Admin / Account Holder); a Developer or App Manager gets 403 regardless of the key's own role.
- Had step 2 survived, the same session would gate the next step too:
provideOrGenerateAscApiKeyAsync (L88-L97) calls isAscApiKeyValidAndTrackedAsync (packages/eas-cli/src/credentials/ios/validators/validateAscApiKey.ts L10, the same iris call) whenever authCtx is set, and only the false return reaches the Proceed anyway? confirm at L97; an exception aborts. SetUpAscApiKey.ts, doBestEffortAutoselectAsync (L82-L94) has the same shape for the "choose an existing key" path via listAscApiKeysAsync (validateAscApiKey.ts L18).
So a session taken for one purpose (find the app id) is reused to gate another (validate the key) at a permission level the first purpose never needed.
Reproducible demo or steps to reproduce from a blank project
- On an Apple Developer team, have an Admin create an App Store Connect API key with the Admin role and download the
.p8. Be a member of that team with the Developer or App Manager role (not Admin).
npx create-expo-app repro && cd repro && eas init && eas build:configure. Set ios.bundleIdentifier to an app that exists on that team. Add "submit": { "production": {} } (no ascAppId, no ascApiKey* fields).
eas build -p ios --profile production (or any store build).
eas submit -p ios --profile production --latest. When asked to log in to your Apple account, log in with the non-Admin Apple ID. When asked for the key, choose "Add a new ASC API Key" and give the .p8 path and key ID (the issuer ID is not asked for on this path).
- Observe
Fetching App Store Connect API Key and the 403 above.
Either of these makes the same key work, which is what isolates the cause to the validation call:
eas credentials -p ios, decline the Apple login, set the key up for EAS Submit, then run step 4 again (the key is now on the credentials service and SetUpAscApiKey returns early).
- Put
ascApiKeyPath, ascApiKeyId, ascApiKeyIssuerId and ascAppId in the submit profile and run step 4 again (no login, no validation).
Suggested fix
Any of these would restore the documented behaviour:
- Make
getBestEffortIssuerIdAsync actually best-effort: catch the Apple error and return null, so the existing issuer-ID prompt takes over, exactly as it does without a session.
- Likewise catch the validation error in
provideOrGenerateAscApiKeyAsync and doBestEffortAutoselectAsync and fall through to the existing Proceed anyway? confirm (or the "you are not authenticated" warning) instead of aborting. A user who is not allowed to read the team's key list still has a perfectly valid key in hand.
- Or do not validate through the Apple ID session at all when the session was only taken for the app lookup: validate the key by using it (one call to the public ASC API with the
.p8, which is what the submission is about to do anyway).
- At minimum, make the error say what it means: "your Apple ID's role on this team does not allow reading API keys; the key itself was not checked".
Build/Submit details page URL
No response
Summary
eas submit -p ioscannot use a valid Admin-role App Store Connect API key when the Apple ID thateas submitsigns in with is not an Admin on the team. The submit flow forces an Apple ID login to look up the app (there is no way to decline it unlessascAppIdis in the profile), then reuses that session to validate the key againstGET /iris/v1/apiKeys/{id}, an endpoint App Store Connect gates on the signed-in user's team role. The 403 from that call is thrown, so the submission dies before the key is ever used. The same key works fine througheas credentials(where the Apple login can be declined) and throughascApiKeyPath/ascApiKeyId/ascApiKeyIssuerIdineas.json, because neither path takes a session and therefore neither validates.Expected: what the docs say (Apple Developer Program roles): "Once an Admin or Account Holder has created the key and downloaded the .p8 file, any authorized developer on the Expo project can configure it using eas credentials or define it directly via eas.json fields". A key validation the user's role does not permit should degrade to the same "proceed anyway?" prompt that the unauthenticated path already has, not abort the submission.
This is the mechanism behind #2136 (closed). The last comment there (2026-09-09) reached the same conclusion from the outside (the user's own membership role gates it, not the key's role); this report pins it to the code.
Managed or bare?
Managed (continuous native generation;
ios/andandroid/are gitignored prebuild output, which is whyexpo-env-infoprintsExpo Workflow: barebelow).Environment
eas-cli/24.0.0 darwin-arm64 node-v20.19.4(global install via bun;expo-env-infodoes not see the bun global registry, so itsnpmGlobalPackagesline is corrected by hand below).Error output
eas submit --platform ios --profile <profile>, submit profile{}, Apple ID login accepted when prompted (the Apple ID has a non-Admin role on the team), then "Add a new ASC API Key" with the path to an Admin-role.p8and its key ID (with a session present the CLI does not ask for the issuer ID; it goes to fetch it):Note the request: it is the private
iris/v1web API, authenticated with the Apple ID session cookies, not the public ASC API with the.p8. The key was never exercised. The key itself is fine: the same file, key ID and issuer ID, set up througheas credentialswith the Apple login declined, submitted the same build to TestFlight without any change toeas.json.Where it happens (eas-cli
main)packages/eas-cli/src/submit/ios/IosSubmitCommand.ts,resolveAscAppIdentifierAsync(L172-L194): withoutascAppIdin the profile,ensureAppStoreConnectAppExistsAsyncruns, and that requires the Apple ID login. This is the login the user cannot decline ineas submit.packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts,promptForAscApiKeyPathAsync(L37-L47): after the.p8path and key ID prompts it callsgetBestEffortIssuerIdAsync(L184-L192). Withctx.appStore.authCtxset by step 1, that isctx.appStore.getAscApiKeyAsync(keyId), i.e.GET /iris/v1/apiKeys/{id}through the session. This is theFetching App Store Connect API Keyspinner in the output, and it is where the 403 is raised. Without a session the function returnsnulland the CLI asks for the issuer ID instead; that prompt is the fallback the function's name promises, and a 403 never reaches it because the exception propagates./apiKeyson the signed-in user's membership role (Admin / Account Holder); a Developer or App Manager gets 403 regardless of the key's own role.provideOrGenerateAscApiKeyAsync(L88-L97) callsisAscApiKeyValidAndTrackedAsync(packages/eas-cli/src/credentials/ios/validators/validateAscApiKey.tsL10, the sameiriscall) wheneverauthCtxis set, and only thefalsereturn reaches theProceed anyway?confirm at L97; an exception aborts.SetUpAscApiKey.ts,doBestEffortAutoselectAsync(L82-L94) has the same shape for the "choose an existing key" path vialistAscApiKeysAsync(validateAscApiKey.tsL18).So a session taken for one purpose (find the app id) is reused to gate another (validate the key) at a permission level the first purpose never needed.
Reproducible demo or steps to reproduce from a blank project
.p8. Be a member of that team with the Developer or App Manager role (not Admin).npx create-expo-app repro && cd repro && eas init && eas build:configure. Setios.bundleIdentifierto an app that exists on that team. Add"submit": { "production": {} }(noascAppId, noascApiKey*fields).eas build -p ios --profile production(or any store build).eas submit -p ios --profile production --latest. When asked to log in to your Apple account, log in with the non-Admin Apple ID. When asked for the key, choose "Add a new ASC API Key" and give the.p8path and key ID (the issuer ID is not asked for on this path).Fetching App Store Connect API Keyand the 403 above.Either of these makes the same key work, which is what isolates the cause to the validation call:
eas credentials -p ios, decline the Apple login, set the key up for EAS Submit, then run step 4 again (the key is now on the credentials service andSetUpAscApiKeyreturns early).ascApiKeyPath,ascApiKeyId,ascApiKeyIssuerIdandascAppIdin the submit profile and run step 4 again (no login, no validation).Suggested fix
Any of these would restore the documented behaviour:
getBestEffortIssuerIdAsyncactually best-effort: catch the Apple error and returnnull, so the existing issuer-ID prompt takes over, exactly as it does without a session.provideOrGenerateAscApiKeyAsyncanddoBestEffortAutoselectAsyncand fall through to the existingProceed anyway?confirm (or the "you are not authenticated" warning) instead of aborting. A user who is not allowed to read the team's key list still has a perfectly valid key in hand..p8, which is what the submission is about to do anyway).