Skip to content

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 #4396

Description

@kbrandwijk

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)

  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".

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions