Secure CI/CD — Signing APKs, Protecting Secrets in GitHub Actions
Secure your Android CI/CD pipeline — APK signing in GitHub Actions, keystore management, secret protection, artifact verification, and supply chain security.
Your CI/CD pipeline builds and distributes your app. If it’s compromised, an attacker can inject malicious code into your APK and distribute it to your users. Securing the pipeline is as important as securing the app itself.
This post covers APK signing in CI/CD, secret management for keystores, and hardening your GitHub Actions workflow.
APK signing basics
Every Android APK must be signed before distribution. The signing key proves the APK comes from you. If the key is compromised, someone else can push updates to your app.
Two types of signing
| Type | Purpose | Where |
|---|---|---|
| Debug signing | Local development | Android Studio (auto-generated) |
| Release signing | Production distribution | Your keystore file |
Generate a release keystore
keytool -genkey -v \
-keystore release-keystore.jks \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-alias release-key
This creates release-keystore.jks. Never commit this file to Git. Store it securely — losing this key means you can’t update your app on the Play Store.
Signing in GitHub Actions
Step 1: Encode the keystore as Base64
base64 -i release-keystore.jks | pbcopy # macOS
base64 release-keystore.jks | xclip # Linux
Step 2: Add secrets to GitHub
Go to Repository → Settings → Secrets → Actions:
| Secret name | Value |
|---|---|
KEYSTORE_BASE64 | Base64-encoded keystore file |
KEYSTORE_PASSWORD | Keystore password |
KEY_ALIAS | Key alias |
KEY_PASSWORD | Key password |
Step 3: Workflow
name: Build & Sign Release APK
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Decode keystore
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
run: echo "$KEYSTORE_BASE64" | base64 --decode > app/release-keystore.jks
- name: Build release APK
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: ./gradlew assembleRelease
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: release-apk
path: app/build/outputs/apk/release/*.apk
- name: Clean up keystore
if: always()
run: rm -f app/release-keystore.jks
Step 4: Gradle signing config
// app/build.gradle.kts
android {
signingConfigs {
create("release") {
storeFile = file("release-keystore.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEY_ALIAS")
keyPassword = System.getenv("KEY_PASSWORD")
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
The signing config reads from environment variables — no hardcoded passwords.
Play App Signing
Google Play App Signing adds another layer. You upload your APK signed with your upload key, and Google re-signs it with the app signing key before distributing to users:
You → Upload key (yours) → Play Console → App signing key (Google) → Users
Benefits:
- If your upload key is compromised, Google can rotate it
- Google manages the app signing key securely
- Smaller APKs (Google can optimize signing per device)
Enrolling
- Go to Play Console → Your app → Setup → App signing
- Follow the enrollment wizard
- Upload your key or let Google generate one
After enrollment, your release keystore becomes the “upload key.” Even if it’s compromised, the attacker can’t distribute directly — they’d need Play Console access too.
Hardening GitHub Actions
1. Pin action versions
# Bad — uses a mutable tag
- uses: actions/checkout@v4
# Better — pin to a specific commit SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
An attacker who compromises the v4 tag can inject malicious code. Pinning to a SHA ensures you get exactly the version you tested.
For less critical actions, version tags are acceptable. For actions that access secrets, pin to SHA.
2. Minimize secret exposure
# Bad — secret available to all steps
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
steps:
- name: Build
run: ./gradlew assembleRelease
- name: Other step # also has access to KEYSTORE_PASSWORD
run: some-command
# Better — secret only where needed
steps:
- name: Build
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
run: ./gradlew assembleRelease
- name: Other step # no access to KEYSTORE_PASSWORD
run: some-command
3. Limit workflow permissions
permissions:
contents: read
# Only add what's needed
Default permissions are broad. Restrict to the minimum your workflow needs.
4. Prevent secret leaks in logs
- name: Build
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
run: |
# GitHub automatically masks secrets in logs
# But be careful with commands that might print env vars
./gradlew assembleRelease
# DON'T do: printenv | grep KEY
GitHub masks secrets in logs, but commands like printenv, env, or set -x can expose them.
5. Use environments for deployment
jobs:
deploy:
environment: production # requires approval
runs-on: ubuntu-latest
steps:
- name: Deploy to Play Store
env:
PLAY_STORE_KEY: ${{ secrets.PLAY_STORE_KEY }}
run: ./deploy.sh
Environments can require manual approval, restrict to specific branches, and have their own secrets.
Dependency verification
Gradle dependency verification
./gradlew --write-verification-metadata sha256
This generates gradle/verification-metadata.xml with checksums for all dependencies. Gradle verifies them on every build. If a dependency is tampered with, the build fails.
GitHub Dependabot
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
Dependabot creates PRs for outdated dependencies, including security patches.
Artifact verification
Verify APK signature after build
- name: Verify APK signature
run: |
apksigner verify --verbose app/build/outputs/apk/release/app-release.apk
Checksum artifacts
- name: Generate checksum
run: |
sha256sum app/build/outputs/apk/release/app-release.apk > checksum.txt
- name: Upload checksum
uses: actions/upload-artifact@v4
with:
name: checksum
path: checksum.txt
Publish the checksum alongside the APK so users can verify the download.
Checklist
Keystore security
- Keystore not committed to Git
- Keystore stored as encrypted GitHub secret
- Keystore cleaned up after build (
rm -f) - Play App Signing enrolled
- Backup of keystore stored securely (not just in CI)
Workflow security
- Actions pinned to commit SHAs (critical actions)
- Secrets scoped to specific steps
- Workflow permissions minimized
- Environments with approval for production deploys
- Branch protection on main/release branches
Supply chain
- Gradle dependency verification enabled
- Dependabot configured for security updates
- APK signature verified after build
- Checksums published with artifacts
Summary
Secure CI/CD for Android:
- Sign APKs with secrets from GitHub Actions (never hardcode)
- Enroll in Play App Signing for key rotation capability
- Pin actions to commit SHAs for supply chain security
- Scope secrets to the steps that need them
- Verify dependencies with Gradle verification metadata
- Clean up keystores and sensitive files after build
Your CI/CD pipeline is a trust boundary. Treat it with the same security rigor as your production servers.