adjoe Engineers’ Blog
 /  Android  /  How Native Code Protects Sensitive Logic in Android
Mobile App Security: How Native Code Protects Sensitive Logic in Android
Android

Mobile App Security: How Native Code Protects Sensitive Logic in Android

A cutting-edge mobile application may be leading in innovation, but it is still vulnerable to potential threats and exploits in mobile app security. Your competitive advantage is sitting at the heart of your application code.

Whether it’s new algorithms, advanced encryption routines, or innovative approaches for data collection, it is a prime target for unauthorized access or reverse engineering. 

What’s the most effective way to secure sensitive logic in Android apps?

At adjoe Programmatic, we have integrated native code into our SDK to secure sensitive application logic while maintaining performance and usability. 

In this article, we explore the challenges of securing Android code, making a case for why native code protects sensitive logic and is the preferred method for mobile app security. 

Android’s Reverse Engineering Risk: Why Obfuscation Isn’t Enough

Android’s open architecture allows developers to create diverse applications, but exposes them to security threats, including the risk of reverse engineering. 

With tools like JADX and ApkTool, attackers can easily decompile APK files, turning obfuscated Java or Kotlin code back into semi-readable logic. 

While obfuscation tools like ProGuard rename classes, methods, and variables, they do not fundamentally hide the underlying algorithms or workflows.

For example, let’s imagine an app implementing a proprietary algorithm for generating secure session tokens based on user data and timestamps:

Original Kotlin Code (Before Obfuscation):

Although variable names and method identifiers are obfuscated, the token-generation logic is still clearly visible. An attacker could reverse-engineer this process to predict or forge session tokens, compromising application security.

fun generateSessionToken(userId: String, timestamp: Long): String {  

    val salt = "someSecureSalt"  

    val data = "$userId|$timestamp|$salt"  

    return data.hashCode().toString()  

}

Decompiled Code (After Obfuscation)

public String a(String a, long b) {  

    String c = "someSecureSalt";  

    String d = a + "|" + b + "|" + c;  

    return Integer.toString(d.hashCode());  

}

This example illustrates the limitations of obfuscation in protecting sensitive code. To protect sensitive data or critical logic, stronger measures are required. 

To learn more about obfuscation, check out the article about improving code obfuscation in Android apps.

Native Code: A Reliable Defense Mechanism

Native code, typically written in C or C++, is a game-changer for securing sensitive logic. Unlike Java/Kotlin, native code compiles directly into machine instructions, making it far less vulnerable to reverse engineering. 

Native binaries are difficult to analyze due to their lower-level nature. Even if attackers access the compiled library, interpreting its purpose requires advanced knowledge and effort, significantly raising the bar for potential breaches and reducing the risk of exposure.

Other than increased complexity for reverse-engineering, here are some advantages of using native code in Android:

  • Improved Performance: Native code is optimized for execution at the hardware level, often yielding better performance for intensive computations, which makes it a valuable choice for efficiency.
  • Enhanced Flexibility: Native code can run across platforms like Android and iOS, making it an ideal choice for SDKs that target a wide range of devices.

Decompiled Native Code: Significantly Harder to Analyze

To further illustrate the difference in reverse engineering complexity, let’s examine how the native version of the generateSessionToken function appears when reverse-engineered using tools like IDA Pro or Ghidra.

Even though the logic in C++ is straightforward, its compiled output in native assembly is significantly more complex and obfuscated by nature. Here’s a snippet of what disassembled native code might look like:

.text:0001234C                 LDR             R1, [SP,#0x14+var_4]
.text:00012350                 ADD             R1, R1, #0x1F
.text:00012354                 MOV             R0, R4
.text:00012358                 BL              _Znwj
.text:0001235C                 MOV             R2, R0
.text:00012360                 MOV             R0, R4
.text:00012364                 MOV             R1, R5
.text:00012368                 BL              _ZNSsC1ERKSs
.text:0001236C                 ADD             SP, SP, #0x10
.text:00012370                 POP             {R4-R7,PC}

You can see this is far from the readable Kotlin or Java decompiled output. To understand what’s happening here, an attacker would need expertise in ARM assembly and knowledge of how the compiler lays out C++ constructs — a much higher bar than skimming through Java source code.

This difference is why native code can act as a “black box”, hiding your algorithmic logic behind machine-level instructions that are hard to interpret and even harder to replicate.

How to Protect Android Logic with Native Code?

Now let’s walk through how to leverage native code for protecting logic in practice. Here’s a breakdown of the approach we took in implementing adjoe Programmatic SDK:

1. Identifying Sensitive Application Components

The first step is identifying which parts of your application or SDK require protection. While native code provides advantages in security and performance, it comes with trade-offs in development complexity and debugging. 

That’s why it’s important to isolate only the truly sensitive logic; for example:

  • Custom data encryption or obfuscation routines.
  • Logic containing sensitive information about users or revealing users’ identities.
  • Fraud detection logic, such as anti-tampering mechanisms.

By limiting the scope of native code to the core sensitive components, you reduce maintenance overhead while significantly increasing protection.

We will use the session token generation logic for this guide.

2. Developing the Native Layer in C/C++

The next step is to write your sensitive logic purely in C++, without worrying yet about how it connects to your Kotlin/Java layer. 

This step focuses solely on implementing the secure functionality, in this case, the session token generation.

Here’s a native implementation that mimics the original logic from Kotlin:

File: token-generator.cpp

#include <string>

#include <sstream>

std::string generateSessionToken(const std::string& userId, long timestamp) {

    const std::string salt = "someSecureSalt";

    std::stringstream ss;

    ss << userId << "|" << timestamp << "|" << salt;

    std::string data = ss.str();

    std::hash<std::string> hasher;

    size_t hashed = hasher(data);

    return std::to_string(hashed);

}

Remember that as a bonus, since your logic is now written in standard C++, it remains portable and reusable across different platforms-even outside Android if needed.

At this point, the function is secure and self-contained. 

The next step is to integrate it into your Android project using Android NDK (Native Development Kit) and expose it to your Android module using JNI (Java Native Interface).

3. Integrating Native Code into Android Module

To connect your native code with your Android project, you’ll need to configure the Android NDK and use JNI to bridge between Java/Kotlin and C++.

In this example, we will be simply using CMake alongside Gradle to build the native library. You can review the Android Documentation for more advanced settings and different build options based on your needs.

3.1. Add NDK and CMake Configuration

a. Create the Native Directory Structure

<module-name>/

└── src/

    └── main/

        └── cpp/

            ├── adjoe-sample.cpp

            ├── token-generator.cpp

            └── CMakeLists.txt

The adjoe-sample.cpp file is the entry point of your native library and will contain the JNI bridge wrapping your token generation logic.

The CMakeLists.txt file is a script used by CMake. This file tells CMake how to build your project—what source files to compile, what libraries to link, what include directories to use, etc.

Here’s a simple CMakeLists.txt to build your native library:

cmake_minimum_required(VERSION 3.22.1)

project("AdjoeSample")

add_library(
    ${CMAKE_PROJECT_NAME} 
    SHARED
    adjoe-sample.cpp
)

target_link_libraries(
    ${CMAKE_PROJECT_NAME}
    android
)

b. Configure Gradle for Native Build

Android Gradle Plugin comes with support for building native libraries with CMake. You can use externalNativeBuild, the NDK workflow that’s a part of the Android Gradle Plugin, to configure CMake.

File: <module-name>/build.gradle.kts

android {

    ...

    defaultConfig {

        ...

        externalNativeBuild {

            cmake {

                cppFlags += "-std=c++11"

            }

        }

    }

    externalNativeBuild {

        cmake {

            // The path to the `CMakeLists.txt` created in previous steps

            path = file("src/main/cpp/CMakeLists.txt")

        }

    }

}

c. Load the Native Library

The last step of integration is to load the compiled native library in your application runtime:

package io.adjoe.programmatic.sample

object TokenGenerator {

    init {
        // The library name must match the CMake project name defined in the `CMakeLists.txt` file
        System.loadLibrary("AdjoeSample")
    }
}  

3.2. Create the JNI Bridge

Now that the native code is set up, you’ll need to expose the generateSessionToken function to your Java/Kotlin code via the Java Native Interface (JNI).  JNI allows your Java/Kotlin layer to call C++ functions directly. 

For this purpose, you need to implement a bridge function using JNI. The function must follow a specific naming convention and use JNI types to be callable from your Android code.

a. Define the Bridge Function in Kotlin

package io.adjoe.programmatic.sample

object TokenGenerator {

    init {

        System.loadLibrary("AdjoeSample")

    }

    external fun nativeGenerateSessionToken(userId: String, timestamp: Long): String

}

b. Implement the JNI Binding in C++

File: adjoe-sample.cpp

#include <jni.h>

#include <string>

#include "token-generator.cpp"

extern "C"

JNIEXPORT jstring JNICALL

Java_io_adjoe_programmatic_TokenGenerator_nativeGenerateSessionToken(

        JNIEnv *env,

        jobject /* this */,

        jstring userId,

        jlong timestamp

) {

    const char *nativeUserId = env->GetStringUTFChars(userId, nullptr);

    std::string token = generateSessionToken(nativeUserId, timestamp);

    env->ReleaseStringUTFChars(userId, nativeUserId);

    return env->NewStringUTF(token.c_str());

}

Note:

The Java_io_adjoe_programmatic_TokenGenerator_nativeGenerateSessionToken function name is derived from the corresponding Kotlin package/class/method name. Make sure it matches exactly with your Kotlin implementation.

Once this is complete, your Kotlin code can securely generate session tokens using the native implementation, which will be compiled into a shared library (.so file) and included in your module output. 

This native library acts as a black box, performing the required operations without exposing the underlying code, and the nativeGenerateSessionToken function will be used as a normal Kotlin function.

val token = TokenGenerator.nativeGenerateSessionToken("user123", System.currentTimeMillis())

4. Obfuscating the Native Library

While native code offers a strong layer of protection, it’s not entirely immune to reverse engineering, especially when determined attackers use tools like IDA Pro or Ghidra to analyze the compiled .so binaries. 

One additional step to further harden your native code against tampering and analysis is to apply Symbol Obfuscation on your native code. 

This involves stripping or renaming the function and variable names that are embedded in the compiled binary, which are otherwise accessible to reverse engineers using disassemblers or decompilers.

Key Considerations and Challenges

While native code enhances security, it comes with challenges that developers should address.

  1. Debugging Complexity: Native code can be harder to debug compared to Java/Kotlin. Using tools like Android Studio’s native debugger helps streamline the process.
  2. Cross-Platform Variability: Variations in processor architecture (ARM, x86) require building native libraries for each target platform.
  3. Performance Overhead: Although native code is efficient, improper use (e.g., frequent JNI calls) can introduce performance bottlenecks.

With these trade-offs in mind, it’s recommended to always evaluate whether the added security justifies the extra maintenance effort.

Best Practices for Using Native Code

Implementing native code involves more details than what can be covered in this article. If you’re considering implementing native code in your Android projects, review Android guides and follow these best practices to enhance security and performance.

  • Minimize JNI Calls: Reduce the number of calls between Java and native code to avoid unnecessary overhead.
  • Use Secure Communication: Encrypt data exchanged between the app and native layer to prevent interception.
  • Stay Updated: Use the latest NDK version to leverage improved tools and security features.
  • Combine with Other Protections: Pair native code with additional security measures like obfuscation, code signing, secure API, and other mobile app security best practices.

Moreover, note that Android 15 introduced support for 16 KB memory page sizes. You can review the Android documentation for more details and guidance on adding the support in your native build.

adjoe Insights: Native Code on Android for Mobile Application Security 

Protecting sensitive application logic on Android is a critical aspect of mobile app security. Modern reverse engineering tools can easily bypass basic obfuscation techniques, making them inefficient on their own.

To truly build secure mobile applications and strengthen your overall security posture, it’s important to combine native code with well-considered design, advanced obfuscation strategies, and robust runtime integrity checks.

At adjoe, we’ve successfully implemented these security strategies into our SDK. The results? Strong protection for key components of our logic without sacrificing performance. 

If you’re developing an Android SDK or app with sensitive components, we highly recommend integrating native code for security-critical logic. It significantly increases the difficulty for attackers and enables cross-platform reuse, giving your app a real edge. 

Need expert insights on building great products and mobile app security? The adjoe engineer’s blog has got you covered.