Code Signing .NET MAUI Apps in CI/CD: iOS Certificates, Android Keystores, and Secrets (2026)
Sign .NET MAUI iOS and Android apps in CI/CD with working YAML for GitHub Actions and Azure DevOps. Covers keychains, keystores, App Store Connect, and Fastlane Match.
Code signing a .NET MAUI app in CI/CD means importing your iOS distribution certificate and provisioning profile into a temporary keychain on the macOS runner, importing your Android keystore on the build runner, and passing both (plus their passwords) to dotnet publish through environment variables that come from your secret store rather than the repo. Most teams skip this part, hard-code passwords in a YAML file "just for now," and then spend a frantic Friday rotating credentials after a leak. So, this guide walks through doing it properly the first time, for both iOS and Android, in GitHub Actions and Azure DevOps, with .NET MAUI 10.
iOS signing in CI needs a .p12 distribution certificate, a .mobileprovision file, and a temporary keychain created per build.
Android signing in CI needs a .keystore (PKCS12 or JKS), the keystore password, the key alias, and the key password, all passed to dotnet publish via MSBuild properties.
Use App Store Connect API keys (.p8) instead of Apple ID + 2FA. API keys work in headless pipelines; Apple IDs don't.
Fastlane Match stores certificates in an encrypted git repo and is the cleanest solution when you have multiple developers and CI runners that all need the same identity.
Never commit .p12, .p8, .keystore, .mobileprovision, or google-services.json with production keys. Treat them like SSH private keys.
Rotate Android upload keys, Apple distribution certificates, and App Store Connect API keys on a schedule, not after a leak.
The signing assets you actually need
Before writing a line of YAML, get an inventory of every file and password your build needs. I've watched teams burn an entire sprint because nobody owned the iOS distribution certificate after a developer left. The build worked on her laptop and nowhere else. Treat this list as the source of truth for your release pipeline.
For iOS distribution (App Store or TestFlight) you need: a distribution certificate exported as .p12 with a password, an App Store provisioning profile (.mobileprovision) that references that certificate and your app's bundle ID, and an App Store Connect API key (.p8 file plus the issuer ID and key ID) for uploading. The provisioning profile must list every entitlement your app uses (push notifications, app groups, associated domains) or the upload will get rejected after the build succeeds.
For Android Play Store distribution you need: an upload keystore (.keystore in PKCS12 format; JKS still works but is deprecated), the keystore password, a key alias, and the key's password (often the same as the keystore password, but not required to be). For Play App Signing, which is now mandatory for new apps, Google holds the actual app-signing key; your upload key is what you sign the AAB with before submission. Store the upload keystore like you'd store a TLS private key, because functionally it is one.
You also need a google-services.json if you use Firebase, a GoogleService-Info.plist for iOS Firebase, and a Google Play service account JSON if you plan to automate Play Store uploads. None of these belong in source control. All of them belong in your secret store.
Where to store signing secrets
The short answer: in your CI provider's encrypted secret manager, or in a real secret manager (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault) that your CI provider pulls from at build time. The longer answer is more interesting because the choice affects how you rotate secrets and who has access.
For GitHub Actions, repository secrets are encrypted at rest with libsodium sealed boxes and only decrypted on the runner during the job. They're scoped per repository (or per environment, which is what you want for production releases, since environments support required reviewers, so a release build can't ship without a human approval). Organization-level secrets work well for shared signing identities across multiple apps.
For Azure DevOps, use a variable group backed by Azure Key Vault. The variable group makes secrets available to the pipeline as variables, but the actual secret values stay in Key Vault, which has audit logs, access policies, and rotation policies that the variable group doesn't. If anyone ever asks "who accessed this certificate," Key Vault answers; raw pipeline variables don't.
Whichever store you pick, encode binary files (.p12, .keystore, .mobileprovision, .p8) as base64 before storing them as a secret string. Decode them back to a file on the runner just before the build. For more on protecting credentials inside the app itself, see securing .NET MAUI apps with token management and biometrics. Same principles, different threat model.
How do I sign a .NET MAUI iOS app in CI?
Signing a .NET MAUI iOS app on a macOS runner involves five repeatable steps: create a temporary keychain, import the .p12 into it, drop the .mobileprovision into ~/Library/MobileDevice/Provisioning Profiles/, run dotnet publish with the codesign properties pointing at the imported identity, and (when you're done) delete the keychain. The reason for the temporary keychain is isolation. The default login keychain may have other certificates that confuse codesign, and you don't want to leave your distribution cert sitting on a shared runner after the job ends.
Here's a GitHub Actions job that does all of that. It assumes you've stored IOS_P12_BASE64, IOS_P12_PASSWORD, IOS_PROVISION_BASE64, and KEYCHAIN_PASSWORD as repository secrets.
name: ios-release
on:
workflow_dispatch:
jobs:
build-ios:
runs-on: macos-15
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Install MAUI workload
run: dotnet workload install maui
- name: Import signing certificate
env:
P12_BASE64: ${{ secrets.IOS_P12_BASE64 }}
P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Decode the .p12 into a file
echo "$P12_BASE64" | base64 --decode > $RUNNER_TEMP/cert.p12
# Create a temporary keychain, isolated from the runner's login keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -lut 21600 build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security list-keychains -d user -s build.keychain login.keychain
# Import the certificate and allow codesign to use it without prompting
security import $RUNNER_TEMP/cert.p12 \
-k build.keychain \
-P "$P12_PASSWORD" \
-T /usr/bin/codesign
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" build.keychain
# Clean up the decoded file immediately
rm $RUNNER_TEMP/cert.p12
- name: Install provisioning profile
env:
PROVISION_BASE64: ${{ secrets.IOS_PROVISION_BASE64 }}
run: |
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
echo "$PROVISION_BASE64" | base64 --decode \
> ~/Library/MobileDevice/Provisioning\ Profiles/ci.mobileprovision
- name: Build and sign IPA
run: |
dotnet publish src/MyApp/MyApp.csproj \
-f net10.0-ios \
-c Release \
/p:ArchiveOnBuild=true \
/p:CodesignKey="Apple Distribution: My Company (ABCDE12345)" \
/p:CodesignProvision="My App Store Profile"
- name: Upload to TestFlight
env:
API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_KEY_BASE64 }}
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
API_ISSUER: ${{ secrets.APP_STORE_CONNECT_ISSUER }}
run: |
mkdir -p ~/.appstoreconnect/private_keys
echo "$API_KEY_BASE64" | base64 --decode \
> ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8
# Find the .ipa produced by ArchiveOnBuild
IPA=$(find . -name "*.ipa" | head -n 1)
xcrun altool --upload-app -f "$IPA" -t ios \
--apiKey "$API_KEY_ID" --apiIssuer "$API_ISSUER"
- name: Clean up keychain
if: always()
run: security delete-keychain build.keychain || true
The two values that confuse people the most are CodesignKey and CodesignProvision. CodesignKey must be the full Common Name of the certificate as it appears in the keychain, usually something like Apple Distribution: Your Company (TEAMID). CodesignProvision is the human-readable Name of the provisioning profile, not its UUID and not its filename. Get either wrong and you'll see No valid iPhone code signing keys found in keychain, which is misleading because the cert IS in the keychain; codesign just can't match it to the name you provided. I hit this exact bug shipping a client app last year. See the official Apple Distribution publishing docs for .NET MAUI for the canonical property list.
How do I sign a .NET MAUI Android app in GitHub Actions?
Android signing in CI is mechanically simpler than iOS because there's no keychain dance and no provisioning profile to install. You decode the keystore to a file, point dotnet publish at it, and let MSBuild do the rest. The complications are upstream: deciding whether to sign locally with your upload key or let Play App Signing handle production signing, and deciding what to commit (nothing) versus what to store as a secret (everything).
This GitHub Actions job builds a signed AAB ready for Play Console upload. It assumes ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, and ANDROID_KEY_PASSWORD are repository secrets.
Azure DevOps has built-in tasks for both iOS and Android signing, which means you write less shell and trust more YAML magic. InstallAppleCertificate@2 and InstallAppleProvisioningProfile@1 handle the keychain dance for you; DownloadSecureFile@1 handles secure file delivery. The trade-off is that you can no longer just paste your pipeline into a different CI provider, since you're tied to the tasks.
trigger: none
pool:
vmImage: 'macOS-15'
variables:
- group: ios-signing-prod # variable group backed by Key Vault
steps:
- task: UseDotNet@2
inputs:
version: '10.0.x'
- script: dotnet workload install maui
displayName: 'Install MAUI workload'
- task: InstallAppleCertificate@2
inputs:
certSecureFile: 'distribution.p12' # uploaded as a secure file
certPwd: $(IOS_P12_PASSWORD) # from Key Vault variable group
keychain: 'temp' # creates and cleans up a temp keychain
deleteCert: true
- task: InstallAppleProvisioningProfile@1
inputs:
provisioningProfileLocation: 'secureFiles'
provProfileSecureFile: 'AppStore.mobileprovision'
removeProfile: true
- script: |
dotnet publish src/MyApp/MyApp.csproj \
-f net10.0-ios -c Release \
/p:ArchiveOnBuild=true \
/p:CodesignKey="$(APPLE_CODESIGN_KEY)" \
/p:CodesignProvision="$(APPLE_PROVISION_NAME)"
displayName: 'Build signed iOS app'
- task: AppStoreRelease@1
inputs:
serviceEndpoint: 'app-store-connect' # service connection using API key
appType: 'iOS'
ipaPath: '**/*.ipa'
releaseTrack: 'TestFlight'
Both tasks emit warnings to logs but never the secret values themselves. Azure DevOps masks anything stored as a secret variable. If you see *** in a log line, that's the masking working correctly, not a string literal you need to debug.
Should I use Fastlane Match with .NET MAUI?
Yes, if you have more than one developer or more than one machine that needs to sign iOS builds. Fastlane Match stores your distribution certificates and provisioning profiles in an encrypted git repo (or an S3 bucket, or Google Cloud Storage), and every machine that runs fastlane match appstore pulls the same identity. No more "the cert is on Alice's laptop." No more regenerating profiles when someone joins.
With .NET MAUI specifically, Match works exactly as it does for Xcode projects. Apple doesn't care which build tool created the IPA, only that it's signed with a valid identity. The catch is that you need to set CodesignKey and CodesignProvision in your .csproj or your publish command to match whatever Match installed. Match writes installed profile names in a predictable pattern: match AppStore com.mycompany.myapp. Use that exact name as your CodesignProvision value.
Set Match up with a private git repo, generate it once locally, and let CI consume it as a read-only deploy key:
# Local, initial setup, run once
fastlane match init # configure Matchfile with your repo URL
fastlane match appstore # generates cert + profile, encrypts, pushes
# CI, read-only consumer
fastlane match appstore --readonly # pulls and installs into temp keychain
The --readonly flag is the important one. CI must never be allowed to regenerate certificates, only to use existing ones. If CI can regenerate, a compromised pipeline can revoke production identities. See the Fastlane Match documentation for the encryption details (Match uses OpenSSL with a passphrase you store as MATCH_PASSWORD).
Can I build a .NET MAUI iOS app without a Mac?
Technically yes, practically no. The Visual Studio "remote build" model lets a Windows developer trigger an iOS build on a remote Mac, but the actual codesigning, archiving, and IPA packaging still happens on macOS. There's no Apple-supported way to produce a signed IPA on Linux or Windows. For CI, this means your iOS signing job must run on a macOS runner.
The options for cloud macOS in 2026 are: GitHub Actions hosted macOS runners (now on macOS 15 Sequoia by default, Xcode 16+ pre-installed), Azure DevOps macOS hosted agents, MacStadium, AWS EC2 mac instances, and self-hosted Mac minis. For low-volume builds (a few releases a week), hosted runners win on cost. For high-volume or large repos where checkout time dominates, self-hosted Mac minis with persistent caches pay back within a few months.
For the Android half, any Linux runner works. There's no Apple-equivalent platform restriction. That asymmetry is why most teams end up with two CI jobs (one Mac, one Linux) and a release coordinator job that waits for both before promoting to stores.
Secret rotation checklist
Pipelines die a slow death from un-rotated secrets. Apple distribution certificates expire after one year; provisioning profiles expire when their certificate does; App Store Connect API keys don't expire but should be rotated annually; Google Play service accounts should be rotated when staff turns over. Without a calendar, you'll find out about expiry the morning of a release. (Don't ask how I know.)
Build this checklist into your team's release ritual:
Quarterly: audit who has access to the signing secrets in your CI provider and revoke anyone who's left or changed roles.
Every 11 months: generate a new Apple distribution certificate, regenerate provisioning profiles, push to Match, rotate the secret in CI. Don't wait for expiry, since Apple gives you no grace period.
Annually: rotate App Store Connect API keys and Google Play service account JSONs. Both are trivial to regenerate, and the rotation tests your pipeline before a real incident does.
After any incident: revoke the certificate (Apple Developer portal, Certificates, Revoke), generate new ones, rotate everything. The Match repo encryption passphrase too.
Never: commit a .p12, .p8, .keystore, or google-services.json with production keys, even briefly, even to a private repo. Git history is forever and forks are unauditable.
For broader release operations beyond signing (track configuration, staged rollouts, store metadata) pair this with publishing .NET MAUI apps to Google Play and the App Store, which covers the human side of the upload: review timelines, metadata localization, screenshots.
Frequently Asked Questions
What's the difference between development and distribution certificates?
Development certificates sign builds for installation on registered test devices via Xcode or ad-hoc distribution. Distribution certificates sign builds for the App Store, TestFlight, in-house enterprise distribution, or ad-hoc release. CI release pipelines always need a distribution certificate; development certificates are for local debugging only.
Can I sign a .NET MAUI iOS app with a development certificate in CI?
Only if you're distributing internally to a fixed list of registered devices (ad-hoc with a development provisioning profile), and even then it's fragile because every new test device requires regenerating the profile. For TestFlight or App Store, use a distribution certificate.
How do I store an iOS provisioning profile as a GitHub secret?
Base64-encode the .mobileprovision file locally with base64 -i profile.mobileprovision | pbcopy (macOS) and paste the result into a repository secret. On the runner, decode it back to ~/Library/MobileDevice/Provisioning Profiles/ci.mobileprovision before dotnet publish. The file is XML wrapped in a CMS signature, so it survives base64 round-tripping cleanly.
Why does my Android signed AAB get rejected by Play Console?
The most common reason is a mismatch between your upload key fingerprint and what Play Console expects. If you opted into Play App Signing, the first AAB you upload defines the upload key for that app forever. Re-signing with a different keystore later will be rejected. Check the SHA-1/SHA-256 fingerprint with keytool -list -v -keystore upload.keystore and compare against Play Console, Setup, App signing.
Should I check the App Store Connect API key into source control if the repo is private?
No. Private repos still leak, through forks, through compromised contributor accounts, through pushed-by-mistake personal mirrors, through GitHub support staff handling tickets. Treat the .p8 like an SSH private key: secret store only, base64-encoded, decoded just-in-time on the runner, never written to disk longer than needed.
A working guide to shipping .NET MAUI 10 apps with Fastlane: match-based iOS signing, API-key auth for TestFlight, staged Play Store rollouts, and Fastfile lanes that survive real teams.
Bind iOS XCFrameworks and Android AARs in .NET MAUI 10 with Objective Sharpie, Metadata.xml transforms, and a single-NuGet package. Real errors, real fixes.
Stop the soft keyboard from covering inputs in .NET MAUI 10. Practical Android windowSoftInputMode setup, iOS keyboard avoidance, edge-to-edge Android 15 fixes, and a reusable KeyboardAvoidingView pattern that works across both platforms.