When building Android applications, most developers live entirely in the world of Java or Kotlin. It’s safe, portable, and the tooling is excellent. But sometimes, you need to step outside of that comfort zone, whether it’s to access hardware acceleration, tap into platform-specific APIs, or reuse existing native libraries. That’s where the Java Native Interface (JNI) comes in.


JNI is powerful, but it comes at a cost. It bypasses many of the safety guarantees of the JVM, and if misused, it can expose your application to serious security risks. Worse, many developers assume that “moving code to native” will protect it from reverse engineering, only to discover that native libraries are just as vulnerable, sometimes even more so. JNI is often used in an attempt to ‘hide’ sensitive logic or keys, but this is a misconception; native binaries can be reverse-engineered, too.


In this article, I’ll walk you through:



What is JNI?


Java Native Interface (JNI) is a programming framework that allows Java code running in the JVM to call—and be called by—native applications and libraries written in C, C++, or other languages.


Throughout this article, I’ll refer to C/C++ code simply as native code.

First and foremost, you shouldn’t use JNI unless you know exactly what you’re doing. It offers little protection against reverse engineering, and improper use can introduce serious security vulnerabilities. There are quite a few articles on this topic and even papers, e.g. this and this.


Why Obfuscate Code?

Obfuscation isn’t a substitute for secure design. It only raises the cost of reverse engineering; flawed logic or hardcoded secrets remain just as vulnerable.



So obfuscation is not only about hiding intent; it also produces cleaner, faster, smaller applications.


A Simple JNI Example


Here’s a minimal setup using JNI:

MainActivity.kt

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
       …
        Log.d("MainActivity", "native call result: ${NativeLib().superSecretMethod()}")
    }
}


NativeLib.kt

class NativeLib {
    
    external fun superSecretMethod(): String

    companion object {
        init {
            System.loadLibrary("nativelib")
        }
    }
}


nativelib.cpp

extern "C" JNIEXPORT jstring JNICALL
Java_me_sergeich_nativelib_NativeLib_superSecretMethod(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}


This example simply loads a native library and calls superSecretMethod() implemented in C++.


Reverse Engineering with Static Analysis


Let’s assume you’ve built the APK and applied ProGuard (or R8). It can be decompiled using tools such as JADX. What do we see?


Most Java classes are renamed to something meaningless. However, we still see MainActivity and the NativeLib class with the method superSecretMethod() intact. Why?



Static analysis of the .so library is also straightforward. Running nm on the shared object or loading it into a disassembler (e.g., Hopper) immediately reveals function names and string literals such as "Hello from C++". Obfuscation helps, but only to a point.


Even worse, dynamic analysis tools like Frida or Xposed can intercept and hook JNI calls at runtime, bypassing static defenses entirely.


Mitigation Strategies

So, how do we make reverse engineering harder?


Solution 1: Register Natives

There are two ways that the runtime can find your native methods. You can either explicitly register them with RegisterNatives, or you can let the runtime look them up dynamically with dlsym. The advantages of RegisterNatives are that you get up-front checking that the symbols exist, plus you can have smaller and faster shared libraries by not exporting anything but JNI_OnLoad. The advantage of letting the runtime discover your functions is that it's slightly less code to write.

Source: https://developer.android.com/training/articles/perf-jni#native-libraries


Instead of relying on long, descriptive JNI method names as Java_me_sergeich_nativelib_NativeLib_superSecretMethod

you can use RegisterNatives() to explicitly map Java methods to C/C++ implementations via function pointers. This avoids exporting the method names directly, and the C/C++ compiler can omit them.


The code in nativelib.cpp will look like this:

jstring superSecretMethod(JNIEnv* env) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}

static JNINativeMethod methods[] = {
        {"superSecretMethod", "()Ljava/lang/String;", (void *) superSecretMethod},
};

jint JNI_OnLoad(JavaVM *vm, void *reserved) {
    JNIEnv *env;
    if (vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) != JNI_OK) {
        return JNI_ERR;
    }

    jclass c = env->FindClass("me/sergeich/nativelib/NativeLib");
    if (c == nullptr) {
        return JNI_ERR;
    }

    int rc = env->RegisterNatives(c, methods, sizeof(methods) / sizeof(JNINativeMethod));
    if (rc != JNI_OK) return rc;

    return JNI_VERSION_1_6;
}


Upsides


Downsides


Solution 2: Manually Renaming Classes

You could rename JNI methods to meaningless symbols (a, b, z), so they don’t leak intent.


Upsides


Downsides


This is hardly viable outside of pet projects; it’s essentially doing the job of a manual obfuscator here.


Solution 3: Native Obfuscator

There are commercial and open-source C/C++ obfuscators. They complicate the reverse engineering of native binaries. It’s not a silver bullet (especially in the age of powerful decompilers and even LLM-based tools), but it adds another barrier. 


Upsides


Downsides


The review of available obfuscators is out of scope of this article.


Practical Suggestions

ProGuard/R8 does quite a good job obfuscating Java/Kotlin code but it doesn’t help at all with native C/C++ code.



JNI is powerful, but with power comes responsibility. Used carelessly, it can do more harm than good.


Ultimately, JNI should be used for performance or interoperability—not as a security boundary. While obfuscation, RegisterNatives(), and native obfuscators can raise the bar for attackers, they cannot replace sound architecture, secure coding practices, or proper use of platform security features. Treat JNI as a tool for capability, not a shield against reverse engineering.


Further reading