devsnack

Play App Signing: The Key You Generate Is Not the Key That Ships

Google re-signs your bundle with a key you have never held. Which loss is recoverable, which is terminal, and why Google Sign-In works in debug and fails on launch day.

DevSnack28 Aug 2026 · 12 min

You generate a keystore, point Gradle at it, build an app bundle, upload it. Play accepts the file, the listing goes live, the app installs on a phone. Nothing in that sequence hints that more than one key is involved, so most people come out of it believing the .jks on their laptop is what their users are running.

The key you generated signs the upload. A key you have never held signs what installs. Play App Signing verifies your bundle against a certificate it holds for you, then re-signs the APKs it generates with a different key that Google keeps. Almost everything confusing about Android signing follows from there being two keys, and only one of them being yours.

Which key you are holding decides what happens when you lose it, which fingerprint Firebase wants, and why Google Sign-In works for the whole of development and then fails on the day you go live.

Two keys, two jobs

The template you bought already has the Gradle wiring. What it cannot tell you is which half of this you are responsible for.

                 you hold             Google holds
                 ────────             ────────────
name             upload key           app signing key
lives in         ~/keys/*.jks         Google's servers
algorithm        RSA 2048 minimum     RSA 4096
signs            the .aab you send    the APKs users install
if you lose it   request a reset      nothing to request

New apps are enrolled automatically, with a Google-generated app signing key — currently a quantum-ready hybrid scheme, which is a decision you never make and never see. The upload key is the only one you generate, and its entire job is proving that the bundle came from you.

This is also why the APK on a user's device does not have the fingerprint you find on your own machine. It was never signed with your key.

Only one of the two losses is survivable

A lost upload key is an inconvenience. Generate a new one, export its certificate, and submit a reset request against the app; Google swaps which certificate it verifies against and your next upload goes through. The app signing key never changes, so every existing install still updates.

A lost app signing key on an app that opted out of Play App Signing and manages its own is the other thing entirely. Google's wording is flat: it cannot be reset if you manage it yourself and lose it. No update reaches those installs again, ever. The only route forward is a new listing, a new package id and a user base you ask to reinstall.

The reset request wants a PEM certificate, not the keystore. Export it the day you generate the key, rather than discovering the flag while already locked out.

keytool -export -rfc \
  -keystore ~/keys/upload-keystore.jks \
  -alias upload \
  -file upload_certificate.pem   # the file the reset form asks for

The fingerprint that breaks Google Sign-In

Google Sign-In, Firebase Auth, Android App Links and Play Games all identify your app by a certificate fingerprint rather than by its package id alone. And you do not have two of those. You have three, because a debug build is not signed with the upload key either — it is signed with the debug keystore Android Studio generated for you, silently, the first time you ever built anything.

build            signed with            fingerprint lives
─────            ───────────            ─────────────────
debug            ~/.android/           your machine
                 debug.keystore
uploaded .aab    upload key            your machine
installed APK    app signing key       Play Console only

Two of the three are readable locally. The debug keystore has fixed credentials — alias androiddebugkey, password android — and is at ~/.android/debug.keystore on macOS and Linux.

keytool -list -v -alias androiddebugkey \
  -keystore ~/.android/debug.keystore -storepass android

keytool -list -v -alias upload -keystore ~/keys/upload-keystore.jks

Certificate fingerprints:
     SHA1:   A1:B2:C3:…
     SHA256: 3F:E4:9A:…   # neither of these is what users run

The third exists only in the Play Console, on the Play App Signing page, and only once your first release has been processed — which is to say it does not exist at the point most people set Firebase up. All three belong in the Firebase project and in the OAuth client, and the reason the bug hides so well is that the first one you register is the debug key. Sign-in then works on your desk, works in the emulator, works for every reviewer running a debug build, and returns ApiException: 10 the first time somebody installs from the store. A developer error with no message attached, which is Google's way of saying it does not recognise the certificate that signed the app asking.

The same asymmetry explains App Links that verify on your device and refuse to verify in production: assetlinks.json has to list the app signing key's SHA-256, because that is the certificate on the installed APK. The upload key's never appears on a device.

There is a mechanical version of this rule that saves rediscovering it. Anything that verifies your app against a certificate — Firebase Auth, Google Sign-In, Maps, App Links, Play Games — needs the fingerprint of the key that signed the build the user is running, and there is no build anyone runs that is signed with your upload key. Its only job is the upload itself.

"Signed with the wrong key"

The upload rejection reads roughly: your Android App Bundle is signed with the wrong key, and it is expected to be signed with the certificate with a fingerprint it then prints. It is not a Gradle problem, and the fix is never to regenerate the key. Three things cause it.

A second machine. Someone else on the project ran keytool rather than copying the keystore, and now there are two upload keys for one app. Only the first one Play saw is valid.

CI that generates a key per run. A build script that creates a keystore when it does not find one will succeed locally, pass in CI, and produce a differently-signed bundle every single time.

A silent fallback to the debug key. If buildTypes.release never sets signingConfig, Gradle signs with the debug key instead of failing. The build is green and the upload is rejected, which is a long way to travel to find a missing line.

Signing on CI without putting the key in the repo

The keystore has to reach the build machine without ever entering git. Base64 is the whole trick: one secret holds the file, others hold the passwords, and the job writes it to a temporary path that disappears with the runner.

.github/workflows/release.yml
- name: Restore the keystore
  run: echo "$KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/upload.jks"
  env:
    KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}

- name: Build the bundle
  run: flutter build appbundle --release
  env:
    # Gradle reads these instead of key.properties, which is
    # not in the repository and never will be.
    KEYSTORE_PATH: ${{ runner.temp }}/upload.jks
    KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
    KEY_ALIAS: upload

On the Gradle side, environment variables take priority and the local key.properties file stays as the fallback, so the same file builds on a laptop and on a runner without a branch in the workflow.

android/app/build.gradle.kts
val envStore = System.getenv("KEYSTORE_PATH")

signingConfigs {
    create("release") {
        if (envStore != null) {
            storeFile = file(envStore)
            storePassword = System.getenv("KEYSTORE_PASSWORD")
            keyAlias = System.getenv("KEY_ALIAS")
            keyPassword = System.getenv("KEYSTORE_PASSWORD")
        } else if (keystorePropertiesFile.exists()) {
            // the local path, unchanged
            storeFile = file(keystoreProperties["storeFile"] as String)
            storePassword = keystoreProperties["storePassword"] as String
            keyAlias = keystoreProperties["keyAlias"] as String
            keyPassword = keystoreProperties["keyPassword"] as String
        }
    }
}

Base64 the file with base64 -i upload-keystore.jks | pbcopy on macOS or base64 -w 0 on Linux — the -w 0 matters, because a wrapped secret decodes to a corrupt keystore and the error you get back talks about an invalid keystore format rather than about line breaks.

What key rotation does not fix

Rotation exists, and it is narrower than it sounds. A signing key can be changed freely before an app has been released to Open testing or Production, which is to say before it matters. After that, the annual key upgrade Play offers only takes effect for installs running Android 17 (API level 37) and above; everything older keeps verifying against the original key, which therefore has to stay valid.

So rotation is not a recovery plan and not a substitute for a backup. It is a hygiene feature for keys that are still fine. Treat the app signing key as permanent, because for most of your install base it is.

This post is also not the rebrand checklist. Changing the package id an app is published under is a separate job with its own failure modes, and the point at which a key stops matching is usually the point at which someone changed the identity underneath it.

Back it up the day you make it, not at the first update

Three artefacts, one place, before the first release: the keystore file, the store and key passwords, and the exported upload_certificate.pem. A password manager entry with the file attached is enough. What is not enough is the laptop it was generated on, which is where every one of these stories starts.

Then write the alias down next to it. A keystore whose password you have and whose alias you have forgotten is recoverable — keytool -list will read it back — but you will look for that command at the worst possible moment, roughly eleven months later, on the evening you are trying to ship a fix.

Play Store Signing Key Generator

The keytool command, the key.properties file and the Gradle signing block for your project, generated from the details you fill in. No account, nothing uploaded.

Open the tool