NOTE · 2026-04-25 · security · 8 min
Over-The-Air (OTA) updates for React Native and other hybrid apps are usually framed as a release-velocity feature: ship a JS bug fix without waiting on the Play Store. That framing hides the actual security claim. The OTA channel hands a remote party — your CDN, your release tooling, anyone with write access to either — the ability to evaluate arbitrary code inside your app’s process. Treat it as anything less than a hard cryptographic boundary and you have shipped a remote code execution feature on purpose.
The shape that makes OTA safe is asymmetric: the build pipeline signs, the device verifies. In the superapp-demo architecture, every JS bundle is RSA-signed at build time using a private key held inside the CI environment — it never lands on a developer machine, never lands on the CDN, never moves. The build emits two artifacts: bundle.js and bundle.sig. Both are pushed to the OTA distribution endpoint.
The corresponding public key is hard-embedded in the native Android binary as a raw resource (res/raw/ota_signing_key.pub). It is part of the APK Google Play signs and ships. There is no config endpoint that returns the public key, no remote refresh, no environment variable. Rotating it requires shipping a new APK — which is exactly the point. The trust anchor moves at the speed of Play Store releases; the bundles it signs move at the speed of CI.
The verification has to happen in native code, before the JS runtime ever evaluates the staged bundle. This is the part teams sometimes get backwards: a JavaScript-side check is verifying the same artifact it lives inside. If the bundle is hostile, the check is hostile. The cryptographic decision belongs in code that the attacker cannot replace through the OTA channel.
In practice that means a small native gate in front of the React runtime:
object SignatureVerifier {
private val publicKey: PublicKey by lazy {
// Loaded from APK assets at first use; cached for the process lifetime.
KeyFactory.getInstance("RSA").generatePublic(
X509EncodedKeySpec(loadEmbeddedKeyBytes())
)
}
fun verify(bundle: ByteArray, signature: ByteArray): Boolean = try {
Signature.getInstance("SHA256withRSA").run {
initVerify(publicKey)
update(bundle)
verify(signature)
}
} catch (_: GeneralSecurityException) {
false
}
}
class BundleLoader(private val context: Context) {
fun resolve(): String {
val staged = stagedBundleOrNull() ?: return APK_BUNDLE_PATH
val bytes = staged.path.readBytes() // read once
val sig = staged.signaturePath.readBytes()
return if (SignatureVerifier.verify(bytes, sig)) {
staged.path
} else {
Log.w("OTA", "signature drift — rejecting ${staged.id}")
APK_BUNDLE_PATH // fail closed
}
}
}
The shape that matters is not the algorithm but the failure mode. Any verification miss — bad signature, missing signature file, key mismatch, parsing error — falls through to the APK-shipped bundle. The known-safe path is the default, not the exception. An attacker who corrupts the OTA channel degrades you to the last shipped APK; they do not get code execution.
Three places where the boundary leaks if you’re not paying attention.
Verifying in JavaScript. A check that runs after the bundle loads is verifying the bundle against itself. Even a check that runs before — but lives in the same JS context that will eventually run the bundle — can be neutered by a hostile bundle that replaces the verifier function before it runs. The verifier has to live in code that the OTA channel cannot reach: native code, signed and shipped through the APK.
Fetching the public key over the network. If the trust anchor is fetched, the network owns the trust anchor. The first time someone proposes “let’s host the public key on a config endpoint so we can rotate it without an APK release,” the trust boundary moves from the device to the config endpoint — and the threat surface now includes whoever can write to that endpoint. Embed the key in the APK and rotate it through the store.
TOCTOU on the staged bundle file. Verifying the file at path X then loading the file at path X is two separate reads. On a device with shared filesystem access (or a rooted device, or any process running with the same UID), an attacker can swap the bytes between verify() and eval(). Read the bundle bytes into memory once, verify those exact bytes, then evaluate those exact bytes. Never let the disk be authoritative twice.
OTA is for JS-layer fixes between APK releases. It is not a way around Play Store review for native code, native dependencies, permission changes, or anything that touches the manifest. If a change requires a new permission, a compileSdk bump, or a privileged manifest entry, OTA cannot deliver it — only a signed APK can. The trust boundary OTA establishes is the same boundary as the install: OTA inherits APK trust, it does not extend it. Shipping a feature through OTA that ought to ship through the store is how teams end up with code on devices that the Play Store never reviewed.