react native
ci-cd
mobile-security
eas-update
devops
codepush
ota-updates
hardening
Imagine you just launched your mobile app and discover a critical bug. In the traditional model, youโd have to fix the code, recompile the entire app, upload it to the app stores, wait for review (which can take days on iOS), and then wait for your users to manually update. This process can take days or even weeks, during which your users are experiencing the issue.
This is where OTA (Over-The-Air) updates and app hardening come in. These techniques let you solve problems quickly while keeping your app secure.
Over-The-Air (OTA) updates are a mechanism that lets you update your React Native appโs JavaScript code without going through the app stores. Think of it as updating your appโs โbrainโ while keeping the โbodyโ (native code) intact.
When working with React Native, itโs important to understand your app is made of two parts: native code (Java/Kotlin for Android, Objective-C/Swift for iOS) and JavaScript code (your business logic, React components, etc.). OTA updates can only modify the JavaScript part, which represents most of your app.
OTA updates let you fix bugs in minutes instead of days. Your users get the update automatically without doing anything, meaning you can iterate quickly, test new features, get instant feedback, and even roll back if something goes wrong. This agility is essential in modern mobile development.
Before implementing OTA updates, you need to understand their restrictions. You canโt update native code via OTA, so if you change dependencies that modify iOS or Android code, youโll need a traditional update through the stores. You also canโt change native permissions (camera, location, etc.) or modify native project configuration.
This restriction exists for technical and security reasons. Native code is compiled and signed during the store publishing process, and Apple and Google policies donโt allow this code to change without their review. However, most day-to-day changes are in JavaScript, so OTA covers 80-90% of your update needs.
There are mainly two solutions for OTA updates in React Native: Microsoftโs CodePush and Expoโs EAS Update. While CodePush was traditionally the default for CLI projects, EAS Update now also supports bare React Native (CLI) projects, offering a modern and well-maintained alternative.
CodePush was developed by Microsoft and has been the most popular OTA solution for React Native CLI for years. It lets you publish updates directly to specific user groups, do staged rollouts, and instantly revert problematic updates.
The main advantage of CodePush is its maturity and extensive documentation. Many teams have used it successfully in production for years. However, Microsoft has reduced active development on CodePush, so new features are less frequent.
EAS Update, developed by Expo, is the most modern and actively maintained solution. While Expo was traditionally associated with the managed workflow, EAS Update now works perfectly with React Native CLI (bare workflow) projects. It offers native integration with the Expo ecosystem, better debugging, and faster updates thanks to its optimized infrastructure.
If youโre starting a new project, EAS Update is generally the best choice. If you already have CodePush implemented and it works well, migration isnโt urgent, but consider EAS Update for future projects.
Letโs implement OTA updates using CodePush so you understand how this technology works from the ground up. Once you grasp the concepts here, migrating to EAS Update will be easy if you wish.
First, install the App Center CLI, which manages CodePush. App Center is Microsoftโs platform that hosts and distributes your updates. Install it globally using npm:
1npm install -g appcenter-cli
Once installed, authenticate with your Microsoft account. Run the login command and follow the process in your browser:
1appcenter login
This command will open your browser where you can sign in with your Microsoft account (or create one if you donโt have it). Once authenticated, youโll get a token saved locally on your machine.
Before you can send updates, you need to register your app in App Center. This is done separately for iOS and Android since theyโre technically two different apps. Create the apps using these commands:
1appcenter apps create -d MyApp-iOS -o iOS -p React-Native 2appcenter apps create -d MyApp-Android -o Android -p React-Native
Here, -d specifies the display name, -o is the OS, and -p indicates itโs a React Native project. You can change โMyAppโ to your actual app name.
Now create the deployment keys, which your app uses to identify itself with CodePush. Typically, you work with two environments: Staging (for testing) and Production (for end users):
1appcenter codepush deployment add -a <user>/MyApp-iOS Staging 2appcenter codepush deployment add -a <user>/MyApp-iOS Production 3appcenter codepush deployment add -a <user>/MyApp-Android Staging 4appcenter codepush deployment add -a <user>/MyApp-Android Production
Replace <user> with your App Center username. These commands will generate deployment keys youโll need in the next step.
Now install the CodePush package in your React Native project:
1npm install --save react-native-code-push
For React Native 0.60 and above, linking is automatic thanks to autolinking, but you still need additional native configuration.
On iOS, open your ios/MyApp/AppDelegate.mm file and modify it to import CodePush and configure the bundle URL. Find the line where sourceURLForBridge is defined and replace it with:
1#import <CodePush/CodePush.h> 2 3- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge 4{ 5 #if DEBUG 6 return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"]; 7 #else 8 return [CodePush bundleURL]; 9 #endif 10}
This tells your app to use the metro bundler in debug mode (for hot reloading) and CodePush in release mode to get the bundle.
On Android, open android/app/src/main/java/com/<yourapp>/MainApplication.java and add CodePush as a React Package. Modify the getJSBundleFile() method:
1import com.microsoft.codepush.react.CodePush; 2 3@Override 4protected String getJSBundleFile() { 5 return CodePush.getJSBundleFile(); 6}
Now add your deployment keys to the configuration. On iOS, open ios/MyApp/Info.plist and add:
1<key>CodePushDeploymentKey</key> 2<string>YOUR_IOS_DEPLOYMENT_KEY</string>
On Android, open android/app/src/main/res/values/strings.xml and add:
1<string name="CodePushDeploymentKey">YOUR_ANDROID_DEPLOYMENT_KEY</string>
You can get your deployment keys by running:
1appcenter codepush deployment list -a <user>/MyApp-iOS --displayKeys 2appcenter codepush deployment list -a <user>/MyApp-Android --displayKeys
Now that the native configuration is ready, integrate CodePush in your JavaScript code. The simplest way is to wrap your main component with the CodePush HOC (Higher Order Component).
In your App.js or App.tsx:
1import React from 'react'; 2import { Text, View } from 'react-native'; 3import codePush from 'react-native-code-push'; 4 5function App() { 6 return ( 7 <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> 8 <Text>My App with CodePush</Text> 9 </View> 10 ); 11} 12 13// Basic CodePush configuration 14const codePushOptions = { 15 checkFrequency: codePush.CheckFrequency.ON_APP_RESUME, 16}; 17 18export default codePush(codePushOptions)(App);
This basic configuration checks for updates every time the user resumes the app. There are three main strategies: ON_APP_START (on launch), ON_APP_RESUME (on resume), and MANUAL (when you decide).
Now for the exciting part: publishing your first OTA update. Make a change in your code, for example, modify the text in your App.js. Then run:
1appcenter codepush release-react -a <user>/MyApp-iOS -d Staging 2appcenter codepush release-react -a <user>/MyApp-Android -d Staging
This command does several things automatically: generates the production bundle of your JavaScript code, packages it with assets, uploads it to App Center servers, and marks it as available for the specified deployment (Staging in this case).
The -d flag specifies the deployment target. Always test in Staging first, and when youโre sure, publish to Production:
1appcenter codepush release-react -a <user>/MyApp-iOS -d Production 2appcenter codepush release-react -a <user>/MyApp-Android -d Production
The basic configuration works, but for optimal user experience youโll want more control. CodePush lets you customize how and when updates are downloaded and installed.
You can create a loading screen while the update downloads:
1import React, { useEffect, useState } from 'react'; 2import { View, Text, ActivityIndicator } from 'react-native'; 3import codePush from 'react-native-code-push'; 4 5function App() { 6 const [updateProgress, setUpdateProgress] = useState(null); 7 8 useEffect(() => { 9 codePush.sync( 10 { 11 installMode: codePush.InstallMode.IMMEDIATE, 12 updateDialog: { 13 title: "Update available", 14 optionalUpdateMessage: "A new version is available. Do you want to update?", 15 optionalInstallButtonLabel: "Yes", 16 optionalIgnoreButtonLabel: "Later", 17 }, 18 }, 19 (status) => { 20 switch (status) { 21 case codePush.SyncStatus.DOWNLOADING_PACKAGE: 22 // Update downloading 23 break; 24 case codePush.SyncStatus.INSTALLING_UPDATE: 25 // Update installing 26 break; 27 } 28 }, 29 ({ receivedBytes, totalBytes }) => { 30 setUpdateProgress({ receivedBytes, totalBytes }); 31 } 32 ); 33 }, []); 34 35 if (updateProgress) { 36 const percent = (updateProgress.receivedBytes / updateProgress.totalBytes) * 100; 37 return ( 38 <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> 39 <ActivityIndicator size="large" /> 40 <Text>Downloading update: {percent.toFixed(0)}%</Text> 41 </View> 42 ); 43 } 44 45 return ( 46 <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> 47 <Text>My App</Text> 48 </View> 49 ); 50} 51 52export default App;
This implementation shows visual progress of the update and gives users control over when to install optional updates.
A recommended practice is not to release updates to 100% of your users immediately. CodePush lets you do progressive rollouts. For example, you can release first to 25% of users:
1appcenter codepush release-react -a <user>/MyApp-iOS -d Production --rollout 25
If all goes well after monitoring, you can increase the percentage:
1appcenter codepush patch -a <user>/MyApp-iOS Production --rollout 100
This strategy gives you time to detect issues before they affect all your users.
Now that you understand how OTA updates work, you need to make sure your app is secure. This is where hardening comes in. Hardening is the process of securing your app against threats and vulnerabilities. When you publish a mobile app, youโre distributing your code to millions of devices you donโt control. Malicious users may try to reverse engineer your app, modify its behavior, or extract sensitive information.
Hardening isnโt about making your app โ100% impossible to hackโ (that doesnโt exist), but about significantly increasing the cost and effort required to compromise it. Think of it like your house locks: not impenetrable, but they deter most intruders.
OTA updates introduce an additional attack vector: if someone intercepts or manipulates your updates, they could inject malicious code into your usersโ apps. Thatโs why OTA solutions like CodePush and EAS Update implement cryptographic verification of updates, but your responsibility doesnโt end there.
You need to protect API keys, secrets, and sensitive data your app handles. You need to detect when your app is running in a compromised environment (rooted/jailbroken device). And you need to obfuscate your code to make reverse engineering harder.
The most common mistake in React Native apps is storing API keys directly in JavaScript code. This is problematic because anyone can decompile your JavaScript bundle and read those keys in plain text.
The solution is to never store secrets in JavaScript code. Instead, use environment variables during build and store sensitive values in native code. This is where react-native-config helps.
First, install the library:
1npm install react-native-config
Create a .env file at your project root (and add it to .gitignore):
1API_KEY=your_secret_api_key 2API_ENDPOINT=https://example.com
Now you can access these variables in your JavaScript code safely:
1import Config from 'react-native-config'; 2 3const apiKey = Config.API_KEY; 4const endpoint = Config.API_ENDPOINT;
The key point is these variables are injected at build time, not runtime. This means theyโre not in the JavaScript bundle distributed with your app, but in compiled native code, which is much harder to extract.
Rooted (Android) or jailbroken (iOS) devices are risky because they allow privileged access to the OS. In these environments, an attacker can more easily intercept network traffic, modify your appโs behavior, or extract stored data.
Use jail-monkey to detect these environments:
1npm install jail-monkey
Implement checks at app startup:
1import JailMonkey from 'jail-monkey'; 2 3function AppSecurityCheck() { 4 useEffect(() => { 5 if (JailMonkey.isJailBroken()) { 6 Alert.alert( 7 'Unsecure device', 8 'This app cannot run on modified devices for security reasons.', 9 [{ text: 'OK', onPress: () => BackHandler.exitApp() }] 10 ); 11 } 12 }, []); 13 14 // rest of your app 15}
This check isnโt foolproof (it can be bypassed), but adds a layer of protection and deters casual attacks. For apps handling very sensitive data (fintech, health), you could also check for debugging or hooking tools:
1if (JailMonkey.hookDetected()) { 2 // The app is being modified at runtime 3} 4 5if (JailMonkey.canMockLocation()) { 6 // GPS can be faked 7}
Even though your JavaScript bundle is minified in production, itโs still relatively easy to read for a determined person. Obfuscation makes your code much harder to understand without affecting functionality.
Metro, React Nativeโs bundler, doesnโt offer obfuscation by default. You need to use metro-transform-plugins or tools like javascript-obfuscator. Hereโs how to integrate obfuscation into your build process.
Install the obfuscator:
1npm install --save-dev javascript-obfuscator metro-transform-plugins
Create a metro.config.js file if you donโt have one, and configure the transformer:
1const { getDefaultConfig } = require('@react-native/metro-config'); 2const JavaScriptObfuscator = require('javascript-obfuscator'); 3 4module.exports = (async () => { 5 const defaultConfig = await getDefaultConfig(__dirname); 6 7 return { 8 ...defaultConfig, 9 transformer: { 10 ...defaultConfig.transformer, 11 minifierPath: 'metro-minify-terser', 12 minifierConfig: { 13 // Production config 14 compress: { 15 drop_console: true, // Remove console.logs 16 }, 17 }, 18 }, 19 }; 20})();
For more aggressive obfuscation, you can create a custom plugin to process your bundle after compilation:
1// obfuscator-plugin.js 2const JavaScriptObfuscator = require('javascript-obfuscator'); 3const fs = require('fs'); 4 5function obfuscateBundle(bundlePath) { 6 const code = fs.readFileSync(bundlePath, 'utf8'); 7 8 const obfuscated = JavaScriptObfuscator.obfuscate(code, { 9 compact: true, 10 controlFlowFlattening: true, 11 controlFlowFlatteningThreshold: 0.75, 12 deadCodeInjection: true, 13 deadCodeInjectionThreshold: 0.4, 14 debugProtection: false, // true in production may affect performance 15 debugProtectionInterval: 0, 16 disableConsoleOutput: true, 17 identifierNamesGenerator: 'hexadecimal', 18 log: false, 19 renameGlobals: false, 20 rotateStringArray: true, 21 selfDefending: true, 22 stringArray: true, 23 stringArrayEncoding: ['base64'], 24 stringArrayThreshold: 0.75, 25 unicodeEscapeSequence: false, 26 }); 27 28 fs.writeFileSync(bundlePath, obfuscated.getObfuscatedCode()); 29} 30 31module.exports = { obfuscateBundle };
Note that aggressive obfuscation can increase bundle size and slightly affect performance. Find the right balance for your app by testing.
All communication between your app and servers must be encrypted using HTTPS/TLS. But thatโs not enough: you also need to implement SSL Pinning to prevent man-in-the-middle attacks.
SSL Pinning means your app only accepts specific certificates you define, even if the device has fake certificates installed. Use react-native-ssl-pinning to implement it:
1npm install react-native-ssl-pinning
Configure pinning in your code:
1import { fetch } from 'react-native-ssl-pinning'; 2 3const response = await fetch('https://example.com/data', { 4 method: 'GET', 5 timeoutInterval: 10000, 6 pkPinning: true, 7 sslPinning: { 8 certs: ['my-certificate'], // name of the .cer file in your bundle 9 }, 10 headers: { 11 'Accept': 'application/json', 12 'Content-Type': 'application/json', 13 }, 14});
Place your certificate file (.cer) in:
ios/MyApp/my-certificate.cerandroid/app/src/main/assets/my-certificate.cerThis technique prevents someone from intercepting your traffic by installing a fake certificate on the device, which is common in corporate environments or compromised devices.
Never use AsyncStorage for sensitive data. AsyncStorage isnโt encrypted and can be easily read on rooted devices or via backup. For sensitive data, use the native keychain.
Install react-native-keychain:
1npm install react-native-keychain
Store sensitive data securely:
1import * as Keychain from 'react-native-keychain'; 2 3// Save 4await Keychain.setGenericPassword('username', 'password', { 5 service: 'com.myapp', 6 accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_ANY, 7 accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED, 8}); 9 10// Retrieve 11const credentials = await Keychain.getGenericPassword({ 12 service: 'com.myapp', 13}); 14 15if (credentials) { 16 console.log('User:', credentials.username); 17 console.log('Password:', credentials.password); 18}
Data stored with Keychain uses the deviceโs Secure Enclave and is protected with hardware encryption. You can also require biometric authentication to access it.
Now that you understand both OTA and hardening separately, letโs talk about how they work together and special considerations you need.
CodePush and EAS Update implement cryptographic verification of updates by default. Each update is signed with private keys on the servers and your app verifies the signature before installing. This prevents someone from injecting malicious code.
However, make sure your deployment keys are protected. Never commit them to your Git repo and use environment variables to manage them:
1# .env.production 2CODEPUSH_KEY_IOS=your_ios_key 3CODEPUSH_KEY_ANDROID=your_android_key
In your CI/CD pipeline, inject these variables as encrypted secrets. If using GitHub Actions:
1- name: Release CodePush Update 2 env: 3 CODEPUSH_KEY: ${{ secrets.CODEPUSH_KEY_IOS }} 4 run: | 5 appcenter codepush release-react \ 6 -a user/MyApp-iOS \ 7 -d Production \ 8 --deployment-key $CODEPUSH_KEY
A key advantage of OTA is instant rollback if you detect a problem. You can revert to a previous version in seconds:
1appcenter codepush rollback -a <user>/MyApp-iOS Production
Implement active error monitoring (using Sentry or similar) to detect issues quickly after a release:
1import * as Sentry from '@sentry/react-native'; 2import codePush from 'react-native-code-push'; 3 4// Report CodePush version to Sentry 5codePush.getUpdateMetadata().then((metadata) => { 6 if (metadata) { 7 Sentry.setTag('codepush.version', metadata.label); 8 Sentry.setTag('codepush.deployment', metadata.deploymentKey); 9 } 10});
This lets you correlate errors with specific bundle versions and do informed rollbacks.
For critical security bugs, youโll want to force the update. CodePush supports mandatory updates:
1appcenter codepush release-react \ 2 -a <user>/MyApp-iOS \ 3 -d Production \ 4 --mandatory true
In your code, mandatory updates are installed automatically:
1codePush.sync({ 2 installMode: codePush.InstallMode.IMMEDIATE, 3 mandatoryInstallMode: codePush.InstallMode.IMMEDIATE, 4});
Use this option carefully. Mandatory immediate updates restart the app automatically, which can frustrate users in the middle of a task. For most updates, itโs better to use ON_NEXT_RESTART.
Maintain a clear versioning scheme that differentiates between app store versions and OTA versions. A common convention is:
[appstore_version].[codepush_version]
Example: 1.2.0.5
- 1.2.0 is the App Store version
- 5 is the fifth OTA update on that base version
Store this info in your app for debugging:
1import { getVersion, getBuildNumber } from 'react-native-device-info'; 2import codePush from 'react-native-code-push'; 3 4export async function getAppVersion() { 5 const cpMetadata = await codePush.getUpdateMetadata(); 6 const storeVersion = getVersion(); // 1.2.0 7 const buildNumber = getBuildNumber(); // 42 8 const cpLabel = cpMetadata?.label || 'none'; // v5 9 10 return { 11 full: `${storeVersion}.${cpLabel}`, 12 store: storeVersion, 13 build: buildNumber, 14 codePush: cpLabel, 15 }; 16}
Never do manual releases. Automate the entire process to avoid human errors and ensure consistency. An example with GitHub Actions:
1name: Release OTA Update 2 3on: 4 push: 5 branches: 6 - main 7 8jobs: 9 release-codepush: 10 runs-on: ubuntu-latest 11 steps: 12 - uses: actions/checkout@v2 13 14 - name: Setup Node 15 uses: actions/setup-node@v2 16 with: 17 node-version: '18' 18 19 - name: Install dependencies 20 run: npm ci 21 22 - name: Run tests 23 run: npm test 24 25 - name: Install AppCenter CLI 26 run: npm install -g appcenter-cli 27 28 - name: Login to AppCenter 29 run: appcenter login --token ${{ secrets.APPCENTER_TOKEN }} 30 31 - name: Release to CodePush Staging 32 run: | 33 appcenter codepush release-react \ 34 -a ${{ secrets.APPCENTER_APP_IOS }} \ 35 -d Staging \ 36 --description "Auto-release: ${{ github.event.head_commit.message }}"
This workflow runs tests, then automatically publishes to Staging every time you push to main. You can extend it with additional steps to publish to Production after validation.
Feature flags let you enable or disable features remotely without a new release. This is especially powerful combined with OTA. You can deploy new code but keep it disabled, test it internally, and activate it gradually.
Use a library like react-native-config-reader or services like LaunchDarkly:
1import { getFeatureFlag } from './featureFlags'; 2 3function MyFeature() { 4 const isEnabled = getFeatureFlag('new_feature'); 5 6 if (!isEnabled) { 7 return <LegacyFeature />; 8 } 9 10 return <NewFeature />; 11}
This gives you an instant โkill switchโ: if a new feature causes problems, you disable it without rolling back the entire code.
Implement comprehensive telemetry to understand how your updates behave in the field. Besides crash reporting, monitor:
1// Track update adoption 2codePush.getUpdateMetadata().then((metadata) => { 3 if (metadata) { 4 analytics.track('CodePush Update Installed', { 5 label: metadata.label, 6 appVersion: metadata.appVersion, 7 deploymentKey: metadata.deploymentKey, 8 description: metadata.description, 9 isFirstRun: metadata.isFirstRun, 10 }); 11 } 12}); 13 14// Track download time 15const startTime = Date.now(); 16codePush.sync( 17 { ... }, 18 (status) => { 19 if (status === codePush.SyncStatus.UPDATE_INSTALLED) { 20 const downloadTime = Date.now() - startTime; 21 analytics.track('Update Download Time', { 22 milliseconds: downloadTime, 23 }); 24 } 25 } 26);
This info helps you optimize your bundle size and understand adoption patterns.
Before launching your app with OTA enabled, make sure you meet these security requirements:
Secret protection:
Compromise detection:
Code obfuscation and protection:
Secure communication:
Storage:
OTA-specific:
In conclusion, OTA updates give you incredible agility to iterate and fix issues quickly. Hardening ensures this agility doesnโt compromise your usersโ security. These two practices donโt conflictโthey complement each other.
Remember, security is a continuous process, not a final state. Stay updated on new vulnerabilities, update your dependencies regularly, and review your security implementation with every major release.