Skip to content

Token Mint Clock Skew Issue #4462

Description

@austin-repstracker

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.

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

    needs reviewIssue is ready to be reviewed by a maintainer

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions