Secure mobile app CI secrets for Android keystores and iOS signing
For developers maintaining mobile CI pipelines, this shows how to store Android keystores and iOS signing assets without leaking them into repos, logs, or build artifacts. You’ll finish with a pipeline that signs releases from protected CI secrets, rotates credentials cleanly, and fails with diagnosable errors when signing material is missing or wrong.
TL;DR — Put Android keystores, iOS certificates, and provisioning profiles into your CI secret store as base64-encoded blobs or encrypted files, write them to disk only during the job with
umask 077, import them into a temporary keychain on macOS runners, and delete them in a cleanup step. The most common fix is to stop committing signing files to the repo and stop echoing passwords in shell traces; use masked environment variables and temporary files instead. Reading time: ~5 min
Goal
When you are done, your CI pipeline can produce signed Android and iOS release builds using protected secrets stored outside the repository, with signing files created only at job runtime, restricted to the build user, removed after the build, and verifiable from logs without exposing secret values.
Prerequisites
- Access to your CI system’s protected secret or variable store and permission to edit pipeline config
- Android signing assets:
release.keystore, alias name, keystore password, key password - iOS signing assets:
.p12signing certificate,.mobileprovisionprovisioning profile, and the.p12password - macOS runner access for iOS builds; Linux or macOS runner for Android builds
bashavailable in CI shellbase64available — check with:
base64 --help >/dev/null 2>&1; echo $?
- For Android verification: JDK with
keytooland Android build tools withapksigner— check with:
keytool -help >/dev/null 2>&1; echo $?
apksigner --version
- For iOS verification on macOS:
securityandcodesign— check with:
security version
codesign --version
- Local shell access to prepare secrets before uploading them to CI
Steps
Step 1: Convert signing files to base64 for CI secret storage
Run these locally, not in CI, and paste the output values into your CI secret store.
base64 < release.keystore | tr -d '\n' > android_keystore.b64
base64 < dist-cert.p12 | tr -d '\n' > ios_cert_p12.b64
base64 < AppStore.mobileprovision | tr -d '\n' > ios_profile_b64
wc -c android_keystore.b64 ios_cert_p12.b64 ios_profile_b64
What you should see when this succeeds: three non-empty .b64 files and wc -c shows byte counts greater than zero.
Step 2: Add secrets to your CI secret store as protected, masked variables
In your CI provider’s dashboard, add these exact variable names in the project or org secret store:
| Variable name | Value |
|---|---|
ANDROID_KEYSTORE_B64 | contents of android_keystore.b64 |
ANDROID_KEYSTORE_PASSWORD | your keystore password |
ANDROID_KEY_ALIAS | your alias, for example release |
ANDROID_KEY_PASSWORD | your key password |
IOS_CERT_P12_B64 | contents of ios_cert_p12.b64 |
IOS_CERT_PASSWORD | .p12 password |
IOS_PROFILE_B64 | contents of ios_profile_b64 |
KEYCHAIN_PASSWORD | random 32+ char password generated for CI |
Use your provider’s secret UI, for example: project settings → CI/CD or secrets → add variable. Mark them protected/restricted to release branches or tags if your provider supports that.
What you should see when this succeeds: the variables are listed as masked/hidden and their values are not readable back in the UI.
Step 3: Write Android signing material to a temporary file during the job
Add this shell to your Android release job before the build command.
set -euo pipefail
set +x
umask 077
mkdir -p "$RUNNER_TEMP/signing"
printf '%s' "$ANDROID_KEYSTORE_B64" | base64 --decode > "$RUNNER_TEMP/signing/release.keystore"
cat > "$RUNNER_TEMP/signing/keystore.properties" <<'EOF'
storeFile=REPLACE_STORE_FILE
storePassword=REPLACE_STORE_PASSWORD
keyAlias=REPLACE_KEY_ALIAS
keyPassword=REPLACE_KEY_PASSWORD
EOF
sed -i.bak "s|REPLACE_STORE_FILE|$RUNNER_TEMP/signing/release.keystore|" "$RUNNER_TEMP/signing/keystore.properties"
sed -i.bak "s|REPLACE_STORE_PASSWORD|$ANDROID_KEYSTORE_PASSWORD|" "$RUNNER_TEMP/signing/keystore.properties"
sed -i.bak "s|REPLACE_KEY_ALIAS|$ANDROID_KEY_ALIAS|" "$RUNNER_TEMP/signing/keystore.properties"
sed -i.bak "s|REPLACE_KEY_PASSWORD|$ANDROID_KEY_PASSWORD|" "$RUNNER_TEMP/signing/keystore.properties"
rm -f "$RUNNER_TEMP/signing/keystore.properties.bak"
ls -l "$RUNNER_TEMP/signing"
What you should see when this succeeds: release.keystore exists with mode -rw------- or equivalent and no secret values are printed in logs.
Step 4: Point your Android build to the temporary keystore file
If your Gradle build reads keystore.properties, load the file from the temp path. In your app module build.gradle or build.gradle.kts, use the exact file path from the environment.
def keystorePropertiesFile = file(System.getenv("RUNNER_TEMP") + "/signing/keystore.properties")
def keystoreProperties = new Properties()
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
android {
signingConfigs {
release {
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
}
}
}
Then build:
./gradlew --no-daemon clean assembleRelease
What you should see when this succeeds: Gradle finishes with BUILD SUCCESSFUL and produces app-release.apk or app-release.aab.
Step 5: Import iOS signing assets into a temporary keychain on the macOS runner
Add this shell to your iOS release job before xcodebuild.
set -euo pipefail
set +x
umask 077
KEYCHAIN_PATH="$RUNNER_TEMP/ci-signing.keychain-db"
PROFILE_DIR="$HOME/Library/MobileDevice/Provisioning Profiles"
mkdir -p "$PROFILE_DIR"
printf '%s' "$IOS_CERT_P12_B64" | base64 --decode > "$RUNNER_TEMP/dist-cert.p12"
printf '%s' "$IOS_PROFILE_B64" | base64 --decode > "$RUNNER_TEMP/profile.mobileprovision"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$RUNNER_TEMP/dist-cert.p12" -k "$KEYCHAIN_PATH" -P "$IOS_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security list-keychains -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
UUID=$(/usr/libexec/PlistBuddy -c 'Print UUID' /dev/stdin <<< "$(security cms -D -i "$RUNNER_TEMP/profile.mobileprovision")")
cp "$RUNNER_TEMP/profile.mobileprovision" "$PROFILE_DIR/$UUID.mobileprovision"
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
What you should see when this succeeds: security find-identity lists at least one valid signing identity, for example 1) ABCDEF... "Apple Distribution: Example, Inc.".
Step 6: Build, then securely remove temporary signing files
Run your platform builds, then delete the temporary files even on failure.
trap 'rm -f "$RUNNER_TEMP/signing/release.keystore" "$RUNNER_TEMP/signing/keystore.properties" "$RUNNER_TEMP/dist-cert.p12" "$RUNNER_TEMP/profile.mobileprovision"; security delete-keychain "$RUNNER_TEMP/ci-signing.keychain-db" >/dev/null 2>&1 || true' EXIT
For iOS build:
xcodebuild -workspace App.xcworkspace -scheme App -configuration Release -archivePath "$RUNNER_TEMP/App.xcarchive" archive
What you should see when this succeeds: the build completes, and the cleanup trap runs without printing secret contents.
Verify it works
For Android, verify the produced artifact is signed:
apksigner verify --verbose app/build/outputs/apk/release/app-release.apk
keytool -list -v -keystore "$RUNNER_TEMP/signing/release.keystore" -alias "$ANDROID_KEY_ALIAS" <<< "$ANDROID_KEYSTORE_PASSWORD" | sed -n '1,12p'
Expected output shape:
Verifies
Verified using v1 scheme (JAR signing): true
Verified using v2 scheme (APK Signature Scheme v2): true
Verified using v3 scheme (APK Signature Scheme v3): true
For iOS, verify the identity and profile are present before archive:
security find-identity -v -p codesigning "$RUNNER_TEMP/ci-signing.keychain-db"
ls -1 "$HOME/Library/MobileDevice/Provisioning Profiles"
Expected output shape:
1) 0123456789ABCDEF0123456789ABCDEF01234567 "Apple Distribution: Example, Inc."
1 valid identities found
A1B2C3D4-E5F6-7890-1234-56789ABCDEF0.mobileprovision
End-to-end success means the CI job produces a signed release artifact and rerunning the job on a clean runner still succeeds without any signing files committed to the repo.
Common pitfalls
Base64 value copied with line breaks or shell prompt text
Mistake: pasting wrapped base64 output or including terminal prompt characters into the CI secret.
Symptom: decode fails with output like:
base64: invalid input
Fix: regenerate with base64 < file | tr -d '\n' and paste only the file contents.
Shell tracing leaks passwords into CI logs
Mistake: running signing steps under set -x or echoing variables for debugging.
Symptom: logs show commands containing storePassword=... or security import ... -P ....
Fix: add set +x before any secret-handling command and use masked CI variables only.
iOS keychain import succeeds but codesign still says no identity found
Mistake: importing the .p12 without setting the key partition list on newer macOS runners.
Symptom:
errSecInternalComponent
No signing certificate "iOS Distribution" found
Fix: run security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" after import.
Provisioning profile installed under the wrong filename
Mistake: copying the profile as profile.mobileprovision into the provisioning directory instead of naming it by UUID.
Symptom: xcodebuild cannot match the profile and archive fails with profile-not-found messages.
Fix: extract the UUID with security cms -D -i and copy it to ~/Library/MobileDevice/Provisioning Profiles/$UUID.mobileprovision.
Keystore path hard-coded to the repository
Mistake: Gradle still points to app/release.keystore committed long ago or missing on CI.
Symptom:
Execution failed for task ':app:packageRelease'.
> Keystore file '/builds/project/app/release.keystore' not found for signing config 'release'.
Fix: load storeFile from $RUNNER_TEMP/signing/keystore.properties exactly as shown above.
Secrets exposed in artifacts or caches
Mistake: caching the temp signing directory or archiving the workspace after the build.
Symptom: later jobs can restore release.keystore, .p12, or .mobileprovision from cache.
Fix: keep signing files under a temp directory excluded from caches and delete them with a trap ... EXIT cleanup step.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI