Store credentials
Shipping to both stores needs three credentials, and the two Apple ones are not interchangeable.
Prerequisites
- An active Apple Developer Program membership, for anything iOS
- A Google Play Developer account, for anything Android
- The EAS CLI , signed in with
eas login
What each credential does
| Credential | What it is | What it covers |
|---|---|---|
| App Store Connect API key | a .p8 private key, a Key ID and an Issuer ID | Builds, submits, version records, listing text, screenshots, age rating, review submission |
| Apple ID web session | your own Apple Account, with two-factor | Creating the app record, and App Privacy. Neither is in Apple’s public API |
| Google Play service account | a JSON key from Google Cloud | The whole Play side: uploads, listing, Data safety, track promotion, review replies |
Most of the iOS pipeline runs on the key. Only two steps need the web session, and both are one-offs. On the Android side there is one credential and no web session at all.
Create the App Store Connect key
Pick the access level
Two levels are in play, and they differ:
| The key is used for | Minimum role |
|---|---|
| Listing, screenshots, versions, submitting for review | App Manager |
| EAS build credentials | Admin |
eas build registers the bundle identifier and creates distribution certificates and
provisioning profiles on the Developer portal, which Apple gates behind Admin. Expo’s
Creating an App Store Connect API key covers
where the setting is.
One Admin key for both is simplest, and the rest of these pages assume it. If you would
rather not give a submission key Developer-portal rights, generate two: an Admin key for
eas.json, an App Manager key for credentials/asc.env. Give them separate filenames.
Nothing in this pipeline needs Account Holder.
Generate the key and note three values
Generate a team key in App Store Connect, under Users and Access, in the App Store Connect API integrations.
Download the .p8 immediately. Apple offers that download once, there is no recovery,
and a key nobody holds should be revoked rather than left active.
Note the Key ID from the key’s row and the Issuer ID shown above the table. The Issuer ID belongs to the team rather than the key, so regenerating a key later changes only the Key ID and the file.
From your Apple Developer account , under Membership details, note the ten-character Team ID and the entity type, Individual or Organization.
Move the key out of your Downloads folder
Run this in your own terminal:
mkdir -p ~/.appstoreconnect && chmod 700 ~/.appstoreconnect
mv ~/Downloads/AuthKey_<KEY_ID>.p8 ~/.appstoreconnect/
chmod 600 ~/.appstoreconnect/AuthKey_<KEY_ID>.p8The key stays outside the repo. A project directory gets zipped, shared and read by agents, and a private key that was never in it cannot be committed by accident.
macOS denies command-line tools access to ~/Downloads, ~/Desktop and ~/Documents.
A terminal listing one of those folders gets Operation not permitted, which looks like
an empty folder and is not. Move the file yourself rather than asking an agent to go
looking for it, and never revoke a key because a terminal could not find it.
Write credentials/asc.env
export EXPO_ASC_API_KEY_PATH="$HOME/.appstoreconnect/AuthKey_<KEY_ID>.p8"
export EXPO_ASC_KEY_ID=<KEY_ID>
export EXPO_ASC_ISSUER_ID=<ISSUER_ID>
export EXPO_APPLE_TEAM_ID=<TEAM_ID>
export EXPO_APPLE_TEAM_TYPE=INDIVIDUAL # or COMPANY_OR_ORGANIZATION
# The same key under the names asc reads.
export ASC_PRIVATE_KEY_PATH="$EXPO_ASC_API_KEY_PATH"
export ASC_KEY_ID="$EXPO_ASC_KEY_ID"
export ASC_ISSUER_ID="$EXPO_ASC_ISSUER_ID"
# Saves passing --app to every asc command.
export ASC_APP_ID=<numeric Apple ID>The file holds the path, never the key itself. credentials/ is in .gitignore and
.easignore, so nothing under it is committed or uploaded with a build.
Two name families appear because eas-cli and asc were written independently. One file
sets both, so source credentials/asc.env is all either needs. The submit scripts read
the file themselves, so they work in a shell where you forgot to source it.
ASC_APP_ID is the app record’s numeric Apple ID, which does not exist yet on a first
release. Add it once the record is created.
Point eas.json at the same key
Under submit.production.ios:
| Field | Value |
|---|---|
ascApiKeyPath | the same absolute path as EXPO_ASC_API_KEY_PATH |
ascApiKeyId | the Key ID |
ascApiKeyIssuerId | the Issuer ID |
appleTeamId | the Team ID |
ascAppId | the app record’s numeric Apple ID |
eas-cli reads eas.json rather than the environment when submitting, which is why the
same values appear twice. It insists on all three key fields together; two of three is an
error. None of these five values is a secret. The .p8 is the secret, and it is not among
them.
The shipped eas.json points ascApiKeyPath at ./credentials/AuthKey_REPLACE_ME.p8.
Replace it with the absolute path under ~/.appstoreconnect/.
Verify
node .claude/skills/submit/scripts/preflight.mjs
asc auth doctorPreflight reports a key blocked by macOS as unreadable, never as missing, and tells
you to move it. asc auth doctor checks the keychain, the config files and the key files
without touching the network.
Register the key in the keychain, or don’t
asc reads ASC_KEY_ID, ASC_ISSUER_ID and ASC_PRIVATE_KEY_PATH from the environment,
so credentials/asc.env is enough on its own. It can also hold the key in the macOS
keychain under a named profile:
asc auth login --name "<app>" --key-id "<KEY_ID>" --issuer-id "<ISSUER_ID>" \
--private-key ~/.appstoreconnect/AuthKey_<KEY_ID>.p8
asc auth status
asc auth switch --name "<app>"The two mechanisms coexist: asc prefers a stored profile and fills missing fields from
the environment. Once stored, commands keep working even if the .p8 is deleted. Keep the
file anyway, because it is the only copy Apple will ever give you.
CI has no keychain. Set ASC_BYPASS_KEYCHAIN=1 there, along with ASC_KEY_ID,
ASC_ISSUER_ID and the key itself: ASC_PRIVATE_KEY takes the PEM contents and
ASC_PRIVATE_KEY_B64 takes it base64-encoded, so the key can be a CI secret rather than a
file.
Log in the Apple ID web session
Creating the app record and publishing App Privacy are not in Apple’s public API, so both go through an Apple ID session instead of the key. An agent cannot type a two-factor code, so log the session in yourself once and it is reused until Apple expires it:
asc web auth login --apple-id you@example.com
asc web auth statusCreate the Google Play service account
One credential covers the whole Play side. eas submit uploads the bundle with it, and
every play-*.mjs script authenticates with it.
Create the account and enable the API
In the Google Cloud project linked to your Play Console, create a service account, then enable the Google Play Android Developer API on that project. Without the API enabled the token exchange succeeds and every call afterwards fails.
Link the project and grant access
In Play Console, link the Cloud project under Setup, then invite the service-account email under Users and permissions and grant it Release to testing tracks, Release to production and Manage store presence for this app.
A missing grant surfaces as a 403 on the first push, not as an error at setup time.
Expo’s Creating a Google Service Account
has the full walkthrough.
Save the JSON key
Download a JSON key for the service account and save it to
credentials/google-play-service-account.json. That path is already wired into
eas.json’s submit.production.android.serviceAccountKeyPath and already gitignored.
Google Cloud offers an OAuth client secret from the same Credentials page, and it
looks like the right file. A service-account key has "type": "service_account" and a
client_email. Preflight names this one specifically, because the wrong file fails much
later with an error that says nothing about being the wrong kind of file.
Unlike Apple’s .p8, this key can be replaced: Cloud Console will mint another for the
same service account. Delete the old one when you do.
Verify
node .claude/skills/submit/scripts/preflight.mjsPreflight prints the client_email it found. Nothing local can prove that address has
Play Console access, so it names the identity to look for rather than guessing.
The Android upload keystore needs nothing from you. eas build --non-interactive
generates it on EAS the first time and reuses it after that.
What never gets committed
Everything under credentials/ and .asc/. Both are in .gitignore and .easignore, so
neither lands in a commit nor travels with a build. The .p8 belongs in
~/.appstoreconnect/, outside the repo entirely.
Next
- Submit to the stores uses all three credentials
- Production builds covers what eas-cli needs from them