If your app has been built on EAS, Expo already holds your Android upload key. To build the same release on your own Windows PC, the documented local recipe is:

  1. Download the keystore with eas credentials -p android.
  2. Put the store file, alias and both passwords into android/gradle.properties (or ~/.gradle/gradle.properties).
  3. Add a signingConfig to android/app/build.gradle and run gradlew app:bundleRelease.

It works. But it leaves your upload key's passwords in a plain-text file that every later build, backup tool and git add . can see. In an Expo CNG project it also edits generated files that prebuild --clean overwrites.

There's a way to sign with the same key without writing the passwords into any project or Gradle file. Here it is by hand, then what WinBuild adds.

The two pieces Gradle gives you

1. Android Studio's injected signing properties. When you use Generate Signed Bundle in Android Studio, the IDE doesn't edit your build.gradle. It passes the signing data to the Android Gradle Plugin as project properties:

android.injected.signing.store.file
android.injected.signing.store.password
android.injected.signing.key.alias
android.injected.signing.key.password

AGP uses them to sign the release, whatever signingConfig the project declares. That means no change to the Expo-generated build.gradle. We confirmed it on a real build: the injected properties signed bundleRelease without the project file being touched.

2. Project properties from the environment. Gradle turns any environment variable called ORG_GRADLE_PROJECT_<name> into the project property <name>. Gradle itself recommends this for secrets in unattended builds. Environment variables live in the memory of a process and its children, not in a file.

Put together: set four environment variables in one PowerShell session, run Gradle from it, and close the session.

The manual recipe

1. Keep the keystore outside the project

If you use EAS, download the credentials with eas credentials -p android. Expo's local format is a credentials.json with keystore.keystorePath, keystorePassword, keyAlias and keyPassword. Move the keystore to a folder outside the project, for example D:\keys\my-app\upload.jks (if you keep using credentials.json, set keystorePath to that absolute path), and remember that credentials.json itself holds the passwords in plain text. Expo tells you to keep it out of Git. If you'd rather not keep that file at all, write the two passwords into your password manager and delete it.

2. Check the keystore before a 30-minute build

keytool -list -v -keystore D:\keys\my-app\upload.jks -alias upload -storepass:env KS_PASS -keypass:env KEY_PASS

-storepass:env and -keypass:env make keytool read the passwords from environment variables, so they don't appear on the command line, as Oracle's documentation recommends. Set those two variables the same way as in step 3. Check that the certificate isn't CN=Android Debug and that its SHA-256 matches the upload certificate in Play Console.

3. Pass the signing data to this build only

$store = Read-Host "Keystore password" -AsSecureString
$key   = Read-Host "Key password" -AsSecureString
function Plain($s) { [Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s)) }

$p = "ORG_GRADLE_PROJECT_android.injected.signing"
[Environment]::SetEnvironmentVariable("$p.store.file", "D:\keys\my-app\upload.jks", "Process")
[Environment]::SetEnvironmentVariable("$p.store.password", (Plain $store), "Process")
[Environment]::SetEnvironmentVariable("$p.key.alias", "upload", "Process")
[Environment]::SetEnvironmentVariable("$p.key.password", (Plain $key), "Process")

cd android
.\gradlew.bat app:bundleRelease --no-daemon
exit
  • Read-Host -AsSecureString doesn't echo the passwords or store them in your PowerShell history.
  • The "Process" scope keeps the variables in this window only. Nothing goes into the registry or any file.
  • --no-daemon makes the Gradle process end with the build, so the values don't stay in a background daemon.
  • exit closes the session and its variables with it.

What we tested. We checked the PowerShell part: variables with dots in their names reach child processes, and nothing is saved to the user environment. WinBuild signs real releases with the same four properties, passed the same way. We haven't run this exact script end to end through Gradle, so check the result with step 4.

4. Check the result

keytool -printcert -jarfile app\build\outputs\bundle\release\app-release.aab

The SHA-256 fingerprint must match the one from step 2.

What this doesn't cover

Environment variables are readable by other processes running as the same user, and by the Gradle plugins of the project you're building. That's a trust boundary you accept anyway when you run a build. The recipe also doesn't tell you whether some tool copied a password into a file during the build. That's the gap WinBuild closes.

How WinBuild handles it

WinBuild uses the same two mechanisms, and wraps them in checks:

  • Two ways to give it credentials. Pass --credentials with your EAS credentials.json, or pass --keystore and --key-alias with the passwords in WINBUILD_KEYSTORE_PASSWORD / WINBUILD_KEY_PASSWORD (or a hidden prompt). The keystore is never copied, and it must be outside the build workspace.
  • Preflight in seconds. Before Gradle starts, keytool opens the keystore using :env passwords. A wrong alias or password, or a debug keystore (KEYSTORE_IS_DEBUG), stops the build right away.
  • Passwords only in Gradle's environment. The four injected properties reach Gradle as ORG_GRADLE_PROJECT_* variables of that one process. They never appear in arguments, gradle.properties, metadata or reports.
  • A sweep for leftovers. When the build finishes, even after an error or Ctrl+C, WinBuild scans the android folder and the Gradle home of that attempt. It looks for both passwords in UTF-8 and UTF-16LE and overwrites any copy it finds. If a file can't be read, the build reports SECRET_SWEEP_INCOMPLETE. If a password ends up inside the AAB, the build fails with SECRET_IN_ARTIFACT.
  • Certificate check. verify reports playUploadable: true only if the AAB's certificate isn't a debug one and matches the keystore checked before the build.

The sweep's cost. Since 20 October 2026, the sweep lists the files first and reads them with 4 parallel workers. Each file is opened read-only, and only a file that contains a password is reopened for writing. On the same 39,615 files, that took the scan from 61.4 s to 21.3 s with a warm disk cache. Right after a build is slower: Windows Defender scans freshly written files on first read. On FoosArena the sweep took 282 s for 39,920 files.

What we found. On our test app with a test keystore, an independent scan of 31,394 files found no copy of the password. On FoosArena, signed with its real EAS upload key, an independent scan of 60,990 files (13.6 GB) in the build folders and the user Temp folder found none either. That AAB passed verify with the same certificate as build 17, which Google Play had already accepted.

One limit, stated plainly: the sweep finds passwords in their literal form. An encoded copy (base64, a hash, a properties-file escape) wouldn't be recognised.

Sources

External sources consulted on 20 October 2026. Figures about WinBuild come from our own build records.

Private alpha

Building Expo Android releases on Windows?

WinBuild is in private alpha. Testers get the package directly and help validate different machines and Expo projects.

Request alpha access →No checkout yet · direct founder feedback

← All articles