10.8 — CI Secrets, Certs & Code Signing

Opening scenario

Your CI works on Monday. On Tuesday a contractor opens a PR from a fork. GitHub Actions runs your workflow — including the step that imports the .p12 into the temp keychain — and the log helpfully prints “imported 1 identity.” For 12 minutes, that contractor’s PR has effectively shipped malware-signing capability under your team’s name. Nobody catches it because secrets aren’t exposed to fork PRs by default — but your match Git token was exposed because you set it via env: at the workflow level instead of the job level.

Secret hygiene on CI is one of those topics where one mistake compromises your entire app’s signing trust. This chapter is the playbook.

Context taxonomy

ApproachWhat you storeProsCons
Option A — fastlane matchEncrypted certs in private Git repoTeam-wide, zero-touch onboardingNeed passphrase + Git token as secrets
Option B — Xcode Cloud managed signingNothing — Apple managesZero secret managementLocked to Xcode Cloud
Option C — base64-encoded cert as secret.p12 and .mobileprovision base64 → GitHub SecretNo external repoManual rotation, harder to share across projects
App Store Connect API key.p8 private keyReplaces Apple ID/password for uploadsMust be carefully scoped

Concept → Why → How → Code

Concept. Three secrets matter for an iOS release pipeline:

  1. Code signing identity (private key + certificate, usually a .p12).
  2. Provisioning profile (.mobileprovision).
  3. App Store Connect API key (.p8 + Key ID + Issuer ID) — used to upload, manage metadata, fetch TestFlight info.

Why. Apple removed username/password support for altool and xcrun notarytool in 2024. The App Store Connect API key is now the only sustainable upload credential.

Option A — fastlane match

Setup the certs repo (one-time, on a trusted dev machine).

# 1. Create a private GitHub repo: github.com/acme/ios-certs (empty, no README)
# 2. From your iOS project:
cd ios
fastlane match init
#   storage_mode: git
#   git_url: git@github.com:acme/ios-certs.git
#   type:    development  (we'll generate appstore separately)

# 3. Generate certs (writes encrypted artifacts to the repo)
fastlane match development --app_identifier com.acme.notes
fastlane match appstore    --app_identifier com.acme.notes

# 4. Set a strong passphrase when prompted; record it in 1Password as MATCH_PASSWORD

Use in CI (GitHub Actions).

- name: Generate temp Git token for match
  id: match-token
  uses: actions/github-script@v7
  with:
    script: |
      const token = await core.getIDToken();
      // ...or use a fine-grained PAT scoped to read:repo on certs repo only

- name: Sync signing
  env:
    MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
    MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN_B64 }}
  run: fastlane match appstore --readonly

MATCH_GIT_TOKEN_B64 is echo -n "x-access-token:$PAT" | base64 where $PAT is a fine-grained PAT scoped to Contents:Read on just the certs repo. Never use a classic token with org-wide scope.

Option B — Xcode Cloud managed signing

Nothing to do. App Store Connect provisions an Xcode Cloud signing identity scoped to the workflow. You only manage workflow-level secrets (Slack webhooks, env vars).

Option C — base64-encoded certs as GitHub secrets

# Convert .p12 to base64 on your machine
base64 -i Certificates.p12 -o Certificates.p12.b64
# Copy contents into GitHub repo → Settings → Secrets → CERT_P12_BASE64

# Provisioning profile
base64 -i AppStore.mobileprovision -o AppStore.mobileprovision.b64
# → MOBILE_PROVISION_BASE64

Workflow step that imports them safely.

- name: Import signing identity
  env:
    CERT_P12_BASE64:    ${{ secrets.CERT_P12_BASE64 }}
    CERT_P12_PASSWORD:  ${{ secrets.CERT_P12_PASSWORD }}
    MOBILE_PROVISION_BASE64: ${{ secrets.MOBILE_PROVISION_BASE64 }}
  run: |
    set -euo pipefail
    KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain"
    KEYCHAIN_PASSWORD=$(openssl rand -base64 24)

    security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
    security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
    security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"

    CERT_FILE="$RUNNER_TEMP/cert.p12"
    echo "$CERT_P12_BASE64" | base64 --decode > "$CERT_FILE"
    security import "$CERT_FILE" -k "$KEYCHAIN_PATH" -P "$CERT_P12_PASSWORD" \
      -T /usr/bin/codesign -T /usr/bin/security
    security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"

    security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"')

    PROFILE_DIR="$HOME/Library/MobileDevice/Provisioning Profiles"
    mkdir -p "$PROFILE_DIR"
    echo "$MOBILE_PROVISION_BASE64" | base64 --decode > "$PROFILE_DIR/AppStore.mobileprovision"

    # Cleanup file copy of cert
    rm "$CERT_FILE"

Cleanup step (always run).

- name: Cleanup keychain
  if: always()
  run: security delete-keychain "$RUNNER_TEMP/build.keychain" || true

App Store Connect API key setup

  1. App Store Connect → Users and Access → Keys → “+ Generate API Key”.
  2. Name: “CI Upload”. Access: App Manager (not Admin).
  3. Download the .p8 immediately — it can only be downloaded once.
  4. Record the Key ID (shown in the table) and Issuer ID (above the table).
  5. Base64 the .p8 and add to secrets:
    • ASC_KEY_BASE64 = base64 -i AuthKey_AAAA1111BB.p8
    • ASC_KEY_ID = AAAA1111BB
    • ASC_ISSUER_ID = 69a6de70-...

Use in Fastlane.

# Fastfile
def asc_api_key
  app_store_connect_api_key(
    key_id:       ENV["ASC_KEY_ID"],
    issuer_id:    ENV["ASC_ISSUER_ID"],
    key_content:  ENV["ASC_KEY_BASE64"],   # base64-encoded .p8
    is_key_content_base64: true,
    in_house: false
  )
end

lane :beta do
  build_app(scheme: "Acme", export_method: "app-store")
  upload_to_testflight(api_key: asc_api_key)
end

In the wild

  • Most teams use Option A (match) because onboarding a new engineer is fastlane match development and done.
  • Single-app teams sometimes prefer Option C — fewer moving parts, no certs repo to maintain.
  • Apple’s own developer relations push Option B (Xcode Cloud) for the simplicity.
  • Security-paranoid teams (banking, healthcare) often add a hardware security module (HSM) step where the actual .p12 private key never leaves a YubiKey or AWS CloudHSM — signing happens via a remote signing service.

Common misconceptions

  1. “GitHub Secrets are encrypted, so it’s fine.” They are — at rest. Once decrypted into a step’s env, they can leak via echo, log forwarding, third-party actions, or any tool that prints env vars.
  2. fastlane match stores plaintext certs.” It encrypts everything with MATCH_PASSWORD using AES-256 before committing. Without the password, the repo is opaque.
  3. “App Store Connect API keys can do anything.” They’re scoped — App Manager keys can’t manage Users & Access; Developer-scoped keys can’t upload. Pick the minimum role.
  4. “Forks of my repo can read my secrets.” Default: no. Secrets are not exposed to workflows triggered by pull_request from forks. But they are exposed on pull_request_target — which is exactly why that trigger is dangerous.
  5. “Rotating certs is rare.” They expire annually. Build a calendar reminder + a rotation runbook now.

Seasoned engineer’s take

Three rules that prevent 95% of CI signing disasters:

TIP. Use Option A (match) for any team of 2+ engineers shipping a single iOS app. The cost of setup (1 hour) repays itself the first time a new engineer joins or a cert expires.

WARNING. Never set sensitive env vars at the workflow level when only one job needs them. Job-scoped secrets are visible only to that job; workflow-scoped secrets are visible to every step in every job, including any third-party action.

The rotation runbook is non-negotiable: certs expire annually; the App Store Connect API keys should be rotated every 6–12 months; MATCH_PASSWORD should be rotated when anyone with access leaves the team. Document the rotation procedure in your repo’s RUNBOOK.md and dry-run it once a year.

Interview corner

Junior“Where do code signing certificates live on a GitHub Actions runner?” The runner has no certs by default. You import them at runtime from a GitHub Secret (base64-encoded .p12) into a temporary keychain, then delete the keychain in a cleanup step.

Mid“Walk me through fastlane match.” match keeps your team’s certs and profiles in a private Git repo, encrypted with MATCH_PASSWORD. On CI, you provide MATCH_PASSWORD and a read-only Git token. fastlane match appstore --readonly checks out the repo, decrypts, and installs into a temp keychain. Onboarding is fastlane match development for new devs.

Senior“You discover a former engineer leaked the certs repo URL but not the passphrase. What’s your response?” (1) Rotate MATCH_PASSWORDfastlane match change_password. (2) Re-issue all distribution certs (Apple portal — revokes old ones). (3) Regenerate all provisioning profiles. (4) Force-push the certs repo to invalidate any clones. (5) Rotate the GitHub PAT and the App Store Connect API key as a precaution. (6) Audit recent CI runs for unauthorized triggers.

Red flag“We share the App Store Connect API key via Slack and everyone has it on their laptops.” That’s a credentials sprawl that scales the blast radius linearly with team size. The key should live only in CI secrets and a single 1Password vault.

Lab preview

Lab 10.3 sets up Option A (match) plus an App Store Connect API key, runs through the full encrypt → CI → decrypt → sign → upload cycle.


Next: 10.9 — Cloud Mac Environments & Cost