
Hardcoded credentials in a CI script are one commit away from a public git history. Five values show up in almost every iOS pipeline: team ID, certificate password, App Store Connect API key, provisioning profile UUID, and App Store Connect issuer ID. This page moves all five into CI secrets, injects them correctly, and verifies none appear in logs or artefacts.
Audit the current pipeline for these common exposure points before you change anything:
xcodebuild ... DEVELOPMENT_TEAM=XXXXXXXXXX in a shell script committed to the repo.security import cert.p12 -P "hardcoded_password" with the password on the command line.fastlane deliver pointing to an API key file inside the repo.echo "$MATCH_PASSWORD" from a debug session.env or printenv into an artefact.Never dump the whole environment to a file. Transfer the required variable names to the new system separately and populate the values from your secret manager at runtime; a plaintext env snapshot captures every secret the shell can see.
GitHub Actions supports repository, environment, and organisation secrets, and documents a 48 KB maximum per secret value, with a workaround pattern for larger payloads (GitHub Actions: using secrets). Binary signing material such as .p12 certificates usually fits once base64-encoded; if it does not, split or store the decryption passphrase as the secret and keep the encrypted blob in the repo.
Two behaviours matter for pipeline design:
GITHUB_TOKEN are not passed to runners triggered from forked repositories. If you accept external PRs, do not rely on signing succeeding on fork-triggered workflows.::add-mask::VALUE to register derived sensitive strings.| Secret | Storage type | Format | Injection | Log check |
|---|---|---|---|---|
APPLE_TEAM_ID | Env secret | 10-char string | env: on the build step | grep log for the 10-char value |
CERT_P12_PASSWORD | Env secret | String | env: then -P "$CERT_P12_PASSWORD" | grep log for the password |
APP_STORE_CONNECT_API_KEY_BASE64 | Env secret | Base64 of .p8 | Decode to a temp file at runtime | grep log for -----BEGIN |
APP_STORE_CONNECT_ISSUER_ID | Env secret | UUID | env: passed to Fastlane | grep log for the UUID |
PROVISIONING_PROFILE_UUID | Env secret | UUID | env: referenced in Fastfile | grep log for the UUID |
CERT_P12_BASE64 | Env secret | Base64 of .p12 | Decode to temp file; security import | confirm no -----BEGIN CERTIFICATE in log |
Keep the base64 form as the registered secret. GitHub will then redact the base64 string from logs even if a step echoes it by mistake.
Encode once locally; store the output as a secret:
base64 -i AuthKey_XXXXXXXXXX.p8 | tr -d '\n'
base64 -i Certificates.p12 | tr -d '\n'
Decode in the job with a restrictive umask so the files are never world-readable, and clean up on failure:
- name: Decode signing assets
env:
ASC_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}
CERT_P12_BASE64: ${{ secrets.CERT_P12_BASE64 }}
run: |
set -euo pipefail
umask 077
WORKDIR="$(mktemp -d)"
echo "WORKDIR=$WORKDIR" >> "$GITHUB_ENV"
printf '%s' "$ASC_API_KEY_BASE64" | base64 --decode > "$WORKDIR/asc_api_key.p8"
printf '%s' "$CERT_P12_BASE64" | base64 --decode > "$WORKDIR/Certificates.p12"
- name: Import certificate
env:
CERT_P12_PASSWORD: ${{ secrets.CERT_P12_PASSWORD }}
run: |
set -euo pipefail
ORIG_LIST=$(security list-keychains -d user | tr -d '"')
security create-keychain -p "" build.keychain
security unlock-keychain -p "" build.keychain
security set-keychain-settings -lut 3600 build.keychain
security import "$WORKDIR/Certificates.p12" \
-k build.keychain \
-P "$CERT_P12_PASSWORD" \
-T /usr/bin/codesign
security list-keychains -d user -s build.keychain $ORIG_LIST
- name: Clean up
if: always()
run: rm -rf "${WORKDIR:-}"
Two details worth keeping straight. security list-keychains -s replaces the search list; the snippet above preserves the previous entries and appends build.keychain. if: always() runs on normal step failure; it cannot guarantee cleanup after a runner machine is killed, so never store long-lived secrets on the runner disk.
Provisioning profiles are not imported into the Keychain. Write the .mobileprovision file to ~/Library/MobileDevice/Provisioning Profiles/ under its UUID and let xcodebuild or Fastlane match pick it up.
Fastlane reads most inputs from environment variables, but the exact name depends on the action. Run fastlane action <name> to list parameters and their env var equivalents for your installed version (Fastlane key management):
fastlane action app_store_connect_api_key # key id, issuer id, key filepath
fastlane action match # MATCH_PASSWORD, MATCH_GIT_URL, ...
fastlane action pilot # TestFlight upload inputs
Pass secrets to xcodebuild as build settings, never on the shell command line as literals:
- name: Archive
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
run: |
xcodebuild -workspace "$WORKSPACE" -scheme "$SCHEME" \
-configuration Release -destination 'generic/platform=iOS' \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
-archivePath "$WORKDIR/App.xcarchive" archive
Replace $WORKSPACE and $SCHEME with the real values from your project (xcodebuild -list prints them); do not guess a scheme name. Do not commit a .env file with real secrets, even with .gitignore: a force-push or mis-scoped ignore pattern is the usual reason production keys end up on GitHub.
After the first green run, spot-check masking with a disposable step and then delete it:
- name: One-shot masking check (delete after use)
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: echo "team=$APPLE_TEAM_ID" # expect: team=***
Download the raw log and grep for the real values from a trusted machine:
grep -F "$KNOWN_TEAM_ID" full-build.log
grep -E "BEGIN (PRIVATE KEY|CERTIFICATE|EC PRIVATE KEY)" full-build.log
Also scan .xcresult bundles, test reports, and .ipa artefacts. A debugging env/printenv step is the single most common source of accidental secret exposure in CI.
MATCH_PASSWORD_V2).Updating a secret value in place while jobs are running can race with step startup and surface as an empty string in some platforms.
Every step above runs on macOS: security, codesign, xcodebuild, Fastlane's signing actions. The right Figment plan depends on which Xcode you need.
security/codesign toolchain. It is suitable for pipelines pinned to an Intel-compatible Xcode (Xcode 26 or earlier per Apple's porting guidance). Specs: 2 vCPU, 8 GiB RAM, 80 GiB disk; browser-console access; no GPU acceleration or audio.Worked cost for a solo iOS CI on this stack: Intel VM at $29 × 12 = $348/year plus a free GitHub Actions self-hosted runner install. Compare to CloudMac's published $999/year M4/16 GB/256 GB offer (cloudmac.org/pricing) when you need Apple silicon: that is $83.25 effective per month and $141 below 12 × Figment's $95 entry, but on hardware Figment confirms per quote rather than a fixed M4 row. Choose the $29 VM for Intel-Xcode signing work, and Figment M-series when ARM execution or the latest Xcode is a hard requirement. Tax and any fees apply as stated in each provider's actual offer.
Request a configuration at /mac-hosting or start here.
Related guides: