โ† Back to Lessons

    react native

  • ci-cd

  • mobile-security

  • eas-update

  • devops

  • codepush

  • ota-updates

  • hardening

OTA Updates and Hardening in React Native CLI

Understanding OTA Updates

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.

Understanding OTA Updates

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.

Why are they important?

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.

Limitations you should know

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.

OTA Solutions for React Native CLI

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: The traditional option

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: The modern alternative

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.

Implementing OTA with CodePush

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.

Preparing your environment

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.

Registering your app in App Center

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.

Installing and configuring the SDK

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

Integrating CodePush in your React code

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).

Publishing your first update

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

Advanced update handling

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.

Progressive rollout strategies

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.

Hardening in React Native

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.

Why is it important in the context of OTA?

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.

Fundamental hardening techniques

Protecting secrets and API keys

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.

Detecting compromised environments

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}

Obfuscating your JavaScript code

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.

Securing network communication

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: ios/MyApp/my-certificate.cer
  • Android: android/app/src/main/assets/my-certificate.cer

This technique prevents someone from intercepting your traffic by installing a fake certificate on the device, which is common in corporate environments or compromised devices.

Secure data storage

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.

Integrating hardening with OTA updates

Now that you understand both OTA and hardening separately, letโ€™s talk about how they work together and special considerations you need.

Update integrity verification

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

Safe rollback

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.

Forced vs optional updates

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.

Best practices for a robust OTA+Hardening system

Implement strict semantic versioning

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}

Automate your release pipeline

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.

Implement feature flags

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.

Monitoring and observability

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.

Pre-production security checklist

Before launching your app with OTA enabled, make sure you meet these security requirements:

Secret protection:

  • No API key or secret in JavaScript code
  • Environment variables for sensitive configuration
  • CodePush/EAS Update deployment keys in CI/CD secrets

Compromise detection:

  • Jailbreak/root check at startup
  • Debugging tool detection in production
  • Alerts when the app runs in an unsecure environment

Code obfuscation and protection:

  • JavaScript bundle obfuscated in production
  • Console.logs removed from final bundle
  • Source maps protected (not public)

Secure communication:

  • All traffic over HTTPS/TLS
  • SSL Pinning implemented for critical endpoints
  • Timeout and retry logic for network requests

Storage:

  • Sensitive data in native Keychain
  • AsyncStorage only for non-sensitive data
  • Additional encryption for PII if needed

OTA-specific:

  • Staged rollouts configured (25% โ†’ 50% โ†’ 100%)
  • Active error monitoring post-release
  • Documented and tested rollback plan
  • Mandatory updates only for critical security

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.