`eas submit` validates a user-provided ASC API key through the Apple ID session and aborts on the 403 a non-Admin Apple ID gets from `iris/v1/apiKeys`
- Dominant language
- TypeScript
- Stars
- 1.4k
- Forks
- 236
- Avg merge
- 3d 1h
- Merged PRs (30d)
- 91
Description
### 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](https://docs.expo.dev/app-signing/apple-developer-program-roles-and-permissions/)): "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 `, 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`)
1. `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`.
2. `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.
3. 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.
4. 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
1. 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).
2. `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).
3. `eas build -p ios --profile production` (or any store build).
4. `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).
5. 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".
Contributor guide
Assessment
This issue has not been assessed yet.