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
| Approach | What you store | Pros | Cons |
|---|---|---|---|
Option A — fastlane match | Encrypted certs in private Git repo | Team-wide, zero-touch onboarding | Need passphrase + Git token as secrets |
| Option B — Xcode Cloud managed signing | Nothing — Apple manages | Zero secret management | Locked to Xcode Cloud |
| Option C — base64-encoded cert as secret | .p12 and .mobileprovision base64 → GitHub Secret | No external repo | Manual rotation, harder to share across projects |
| App Store Connect API key | .p8 private key | Replaces Apple ID/password for uploads | Must be carefully scoped |
Concept → Why → How → Code
Concept. Three secrets matter for an iOS release pipeline:
- Code signing identity (private key + certificate, usually a
.p12). - Provisioning profile (
.mobileprovision). - 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
- App Store Connect → Users and Access → Keys → “+ Generate API Key”.
- Name: “CI Upload”. Access: App Manager (not Admin).
- Download the
.p8immediately — it can only be downloaded once. - Record the Key ID (shown in the table) and Issuer ID (above the table).
- Base64 the
.p8and add to secrets:ASC_KEY_BASE64=base64 -i AuthKey_AAAA1111BB.p8ASC_KEY_ID=AAAA1111BBASC_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 isfastlane match developmentand 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
.p12private key never leaves a YubiKey or AWS CloudHSM — signing happens via a remote signing service.
Common misconceptions
- “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. - “
fastlane matchstores plaintext certs.” It encrypts everything withMATCH_PASSWORDusing AES-256 before committing. Without the password, the repo is opaque. - “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.
- “Forks of my repo can read my secrets.” Default: no. Secrets are not exposed to workflows triggered by
pull_requestfrom forks. But they are exposed onpull_request_target— which is exactly why that trigger is dangerous. - “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_PASSWORD — fastlane 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.