How does a CI job authenticate to the App Store Connect API: API keys, the JWT claims, the signing algorithm, expiry, roles and rotation?

25.6.4 · L3 · Interview

Predict first

A token is built and verified with CryptoKit. What does it print?

import CryptoKit
import Foundation

func b64url(_ d: Data) -> String {
    d.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
}
func unb64url(_ s: String) -> Data {
    var t = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
    while t.count % 4 != 0 { t += "=" }
    return Data(base64Encoded: t)!
}

let key = P256.Signing.PrivateKey()          // stands in for the .p8 key downloaded from App Store Connect
let issued = 1_800_000_000
let header = #"{"alg":"ES256","kid":"ABC123DEFG","typ":"JWT"}"#
let claims = #"{"iss":"69a6de70-0000-0000-0000-000000000000","iat":\#(issued),"exp":\#(issued + 1200),"aud":"appstoreconnect-v1"}"#
let signingInput = b64url(Data(header.utf8)) + "." + b64url(Data(claims.utf8))
let sig = try key.signature(for: Data(signingInput.utf8))
let token = signingInput + "." + b64url(sig.rawRepresentation)

let parts = token.split(separator: ".").map(String.init)
print("parts:", parts.count, "| signature bytes:", unb64url(parts[2]).count)
let verifies = try key.publicKey.isValidSignature(P256.Signing.ECDSASignature(rawRepresentation: unb64url(parts[2])), for: Data((parts[0] + "." + parts[1]).utf8))
print("signature verifies:", verifies)
let tampered = parts[0] + "." + b64url(Data(claims.replacingOccurrences(of: "1800000000", with: "1900000000").utf8))
print("tampered claims verify:", key.publicKey.isValidSignature(sig, for: Data(tampered.utf8)))
let body = try JSONSerialization.jsonObject(with: unb64url(parts[1])) as! [String: Any]
print("lifetime seconds:", (body["exp"] as! Int) - (body["iat"] as! Int), "| aud:", body["aud"]!)
print("DER form differs from the 64-byte form a JWT carries:", sig.derRepresentation.count != 64)
let exp = body["exp"] as! Int
func state(now: Int) -> String { now >= exp ? "expired" : "valid" }
print(state(now: issued + 1199), state(now: issued + 1200))
print("token has padding or +/ characters:", token.contains("=") || token.contains("+") || token.contains("/"))
  • 3 parts, 64-byte signature, verifies, tampered claims fail, 1200 s lifetime, valid then expired, no padding
  • A DER signature of 64 bytes
  • The tampered token still verifies

Mental model. The .p8 key is the master key kept in the safe; each JWT is a visitor badge printed for one job that stops working after twenty minutes.

You create an API key in App Store Connect (an issuer ID, a key ID and a one-time-download .p8 private key, with a role such as App Manager or Developer that limits what it can do). A client proves it holds the key by sending a short-lived JWT as the bearer token: a header with alg: ES256 and your key ID in kid, claims iss (the issuer ID), iat, exp and aud: appstoreconnect-v1, signed with ECDSA P-256 and SHA-256. Apple documents a maximum lifetime of 20 minutes, so a pipeline mints a fresh token per job rather than storing one. The Swift check builds exactly that token with CryptoKit and a throw-away key: three base64url parts, a 64-byte raw signature (JWT carries the fixed-length r‖s form, not the variable-length DER form many libraries return by default, which is a classic cause of 401 errors), a verified signature, a failed verification when the claims are altered, and a 1200-second lifetime.

Operationally: the same key drives xcodebuild -allowProvisioningUpdates (-authenticationKeyPath, -authenticationKeyID, -authenticationKeyIssuerID, Q480), fastlane, Transporter and your own scripts, so treat it like a signing key: store the .p8 in the secret store, give each pipeline its own key with the least role that works, never print it, rotate by creating a new key and revoking the old, and note that revocation breaks every job using it. Use iat from a trustworthy clock; a runner whose clock is minutes off gets rejected. The check proves the token format and signature logic locally; no request was sent to Apple and the 20-minute limit is taken from Apple’s documentation, not tested.

ES256 in a JWT is the raw 64-byte signature over header.claims, so changing any claim invalidates it.

Common traps

  • Sending a DER-encoded signature in the JWT. ES256 needs the 64-byte r‖s form; the DER form fails verification.
  • Minting one token and caching it for hours. It expires; generate it per job and keep the key, not the token, in storage.

They might follow up with

  • Individual or team key? Team keys serve automation; individual keys are tied to a person and a narrower set of APIs.
  • What if the .p8 leaks? Revoke the key in App Store Connect immediately and issue a new one; tokens signed with it stop working.

Related questions

  • 25.2.1 What are the main ways to run iOS CI/CD (Xcode Cloud, GitHub Actions, Fastlane, others), and what do they all…
  • 25.2.3 How do you design a release pipeline: hermetic builds, secrets, caching, artefact promotion and the order of…
  • 14.1.1 What is the difference between hashing, a MAC, encryption and a digital signature, and when do you use…
  • 12.9.4 How does HMAC request signing work, what must a canonical string include, and how do you verify a signature…

← Why must nested code be signed before the code that contains it, what…