Build/Submit details page URL
No response
Summary
eas-cli mints its App Store Connect JWT with exp = iat + 1200, exactly Apple's twenty-minute
maximum. Apple requires exp to be strictly less than 1200 seconds ahead of its own clock, so
the token is valid only if the client's clock is behind Apple's. Any machine synchronised exactly,
or running even fractionally fast, gets HTTP 401 NOT_AUTHORIZED — and eas-cli then falls back to
interactive Apple ID login, which hides the cause.
Managed or bare?
Managed (CNG — no ios/ or android/ directories; configured via app.config.ts).
Environment
Environment
The reproduction below is a standalone script that never loads the Expo project.
Equivalent details, read from the installed tree:
- eas-cli 21.8.0 — also reproduced on 24.7.0;
jwtDurationSeconds is identical in both
- @expo/apple-utils 2.1.22
- expo 54.0.33, react-native 0.81.5 (managed / CNG — no
ios/ or android/ directories)
- Node v22.14.0, Yarn 4.5.0, npm 10.9.2
- OS Windows 11 (10.0.26200) x86_64
- Auth App Store Connect API key — Team key, App Manager role, supplied via
EXPO_ASC_API_KEY_PATH / EXPO_ASC_KEY_ID / EXPO_ASC_ISSUER_ID
- Clock within 1 second of Apple's, measured from the
Date response header on
api.appstoreconnect.apple.com
Error output
√ Select your Apple Team Type: » Company/Organization
√ Apple Team ID: ... XXXXXXXXXX
Auth error: 'Apple 401 detected - You are either not logged in, your account doesn't have
access to the requested data, or the page doesn't exist
Authentication credentials are missing or invalid. - Provide a properly configured and signed
bearer token, and make sure that it has not expired.'. Login in again (remaining: 3)
Log in to your Apple Developer account to continue
The fallback to interactive login is what obscures this: the visible failure is an Apple ID prompt,
not a refused token. Where interactive login is unavailable, the command simply dies.
Reproducible demo or steps to reproduce from a blank project
Important: whether this reproduces on your machine depends on your clock. If yours runs behind
Apple's — common, since NTP correction is biased slow by network latency — eas-cli works fine and
you will not see it. The script below therefore simulates the offset in the token itself, so it
reproduces deterministically regardless of your own clock.
The failing call is independent of eas-cli, so this needs only a valid ASC API key. It mints tokens
exactly as eas-cli does, varying clock offset and duration independently.
// repro.js — node >= 18
// usage: node repro.js <offsetSeconds> <durationSeconds> <runs>
// offset > 0 simulates a client clock running FAST by that many seconds
const crypto = require('node:crypto'), fs = require('node:fs')
const { EXPO_ASC_API_KEY_PATH: keyPath, EXPO_ASC_KEY_ID: keyId, EXPO_ASC_ISSUER_ID: issuerId } = process.env
const offset = Number(process.argv[2] ?? 0)
const duration = Number(process.argv[3] ?? 1200)
const runs = Number(process.argv[4] ?? 3)
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url')
const pem = fs.readFileSync(keyPath, 'utf8')
function mint() {
const iat = Math.floor(Date.now() / 1000) + offset
const h = b64({ alg: 'ES256', kid: keyId, typ: 'JWT' })
const p = b64({ iss: issuerId, iat, exp: iat + duration, aud: 'appstoreconnect-v1' })
const s = crypto.createSign('SHA256'); s.update(`${h}.${p}`)
return `${h}.${p}.${s.sign({ key: pem, dsaEncoding: 'ieee-p1363' }).toString('base64url')}`
}
;(async () => {
const out = []
for (let i = 0; i < runs; i++) {
const r = await fetch('https://api.appstoreconnect.apple.com/v1/bundleIds?limit=1', {
headers: { Authorization: `Bearer ${mint()}` },
})
out.push(r.status)
await new Promise((res) => setTimeout(res, 400))
}
console.log(`offset=+${offset}s duration=${duration}s -> ${out.join(' ')}`)
})()
Results. offset simulates clock lead; duration is what eas-cli controls:
node repro.js 0 1200 3 -> 401 401 401 # what eas-cli sends, clock ~0s from Apple
node repro.js 0 1200 3 -> 401 200 401 # same params, rerun — flaps on the boundary
node repro.js 0 1199 3 -> 200 200 200 # one second under the ceiling
node repro.js 0 1140 3 -> 200 200 200
node repro.js 5 1200 3 -> 401 401 401 # clock 5s fast, current behaviour
node repro.js 5 1140 3 -> 200 200 200 # clock 5s fast, with margin
node repro.js 60 1140 3 -> 401 401 401 # 60 + 1140 = 1200 exactly — rejected
node repro.js 60 1200 3 -> 401 401 401
Every row fits one rule: Apple accepts the token when duration + clock_offset < 1200, and rejects
it at or above. The offset=60 / duration=1140 row is the useful one — landing on exactly 1200 is
refused, which is why duration=1200 with a perfectly synchronised clock cannot work.
Source: src/credentials/ios/appstore/authenticate.ts, in authenticateWithApiKeyAsync:
const jwtDurationSeconds = 1200; // 20 minutes
passed as duration to new Token({ key, issuerId, keyId, duration }).
Choosing the exact maximum leaves zero tolerance for clock skew. The client must be at or
behind Apple's clock for authentication to work at all, which is why this presents as intermittent
and machine-specific rather than as a deterministic bug. In our case, correcting a one-second clock
lead with w32tm /resync made failures more consistent, not fewer — the opposite of what anyone
debugging this would expect.
Suggested fix: subtract a skew allowance rather than minting at the ceiling. Sixty seconds of
tolerance (duration = 1140) covers ordinary NTP drift; more would cover machines that are not
synchronised at all. We're running a local yarn patch at 1140 and the failures stopped completely.
Nineteen minutes appears to be established practice in other App Store Connect clients for exactly
this reason — see isaced/appstore-connect-sdk#25 and the fastlane discussions #18614 and #21275.
Possibly the same root cause: #2913, #2764, #2458, #2272, #1459 — all "Apple authentication
failed / invalid session" reports with no root cause established, which is how this presents once
the interactive-login fallback takes over.
Build/Submit details page URL
No response
Summary
eas-cli mints its App Store Connect JWT with
exp = iat + 1200, exactly Apple's twenty-minutemaximum. Apple requires
expto be strictly less than 1200 seconds ahead of its own clock, sothe token is valid only if the client's clock is behind Apple's. Any machine synchronised exactly,
or running even fractionally fast, gets HTTP 401
NOT_AUTHORIZED— and eas-cli then falls back tointeractive Apple ID login, which hides the cause.
Managed or bare?
Managed (CNG — no
ios/orandroid/directories; configured viaapp.config.ts).Environment
Environment
The reproduction below is a standalone script that never loads the Expo project.
Equivalent details, read from the installed tree:
jwtDurationSecondsis identical in bothios/orandroid/directories)EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_IDDateresponse header onapi.appstoreconnect.apple.comError output
The fallback to interactive login is what obscures this: the visible failure is an Apple ID prompt,
not a refused token. Where interactive login is unavailable, the command simply dies.
Reproducible demo or steps to reproduce from a blank project
Important: whether this reproduces on your machine depends on your clock. If yours runs behind
Apple's — common, since NTP correction is biased slow by network latency — eas-cli works fine and
you will not see it. The script below therefore simulates the offset in the token itself, so it
reproduces deterministically regardless of your own clock.
The failing call is independent of eas-cli, so this needs only a valid ASC API key. It mints tokens
exactly as eas-cli does, varying clock offset and duration independently.
Results.
offsetsimulates clock lead;durationis what eas-cli controls:Every row fits one rule: Apple accepts the token when
duration + clock_offset < 1200, and rejectsit at or above. The
offset=60 / duration=1140row is the useful one — landing on exactly 1200 isrefused, which is why
duration=1200with a perfectly synchronised clock cannot work.Source:
src/credentials/ios/appstore/authenticate.ts, inauthenticateWithApiKeyAsync:passed as
durationtonew Token({ key, issuerId, keyId, duration }).Choosing the exact maximum leaves zero tolerance for clock skew. The client must be at or
behind Apple's clock for authentication to work at all, which is why this presents as intermittent
and machine-specific rather than as a deterministic bug. In our case, correcting a one-second clock
lead with
w32tm /resyncmade failures more consistent, not fewer — the opposite of what anyonedebugging this would expect.
Suggested fix: subtract a skew allowance rather than minting at the ceiling. Sixty seconds of
tolerance (
duration = 1140) covers ordinary NTP drift; more would cover machines that are notsynchronised at all. We're running a local
yarn patchat 1140 and the failures stopped completely.Nineteen minutes appears to be established practice in other App Store Connect clients for exactly
this reason — see isaced/appstore-connect-sdk#25 and the fastlane discussions #18614 and #21275.
Possibly the same root cause: #2913, #2764, #2458, #2272, #1459 — all "Apple authentication
failed / invalid session" reports with no root cause established, which is how this presents once
the interactive-login fallback takes over.