Skip to main content
Blog

Mobile

Keys and API endpoints inside APK and IPA: why a "hidden" key is not a secret

Any key or endpoint shipped in a mobile app can be found with jadx or strings. What can stay in the app, what belongs only on the server, and where obfuscation stops helping.

· 10 min read

Photo: Raimond Spekking, CC BY-SA 4.0 · Wikimedia Commons

A common situation in mobile development: the API key for an SMS gateway, a payment provider or a maps service is written into the app code or a .env file, on the assumption that "the user won't see it anyway". Or server addresses, including test and admin ones, sit in the code as plain strings. And this is exactly where the expert starts the analysis: the APK or IPA is unpacked and every string inside it is reviewed.

The review of our own app had a finding on this very topic: API endpoints must not be stored in the code as plain strings. In this article we look at what can be extracted from an app, why "hiding" is not enough, and where proper protection is actually built.

What the problem is

An app installed on a user's device is a file in the user's hands. It can be copied, unpacked, decompiled and observed at runtime. So any value placed in the app will be found by someone willing to spend enough time. This applies to Android and iOS, to native code, and to Flutter and React Native.

What is usually found inside an app:

  • Secret keys for third-party services — SMS sending, payments, email, cloud storage (for example, an S3 access key), AI API keys. These are the most dangerous: with the key, someone can send SMS at your expense, read files or call the payment API.
  • Public identifiers — Firebase configuration, a Google Maps key, an analytics ID. They were not designed as secrets, but without proper restrictions they get abused.
  • Server addresses — the main API, staging, test, admin panel, internal services.
  • A static login/password or "service" token — a value shared by all users and used to access the server API.
  • Encryption keys — an AES key or IV written into the code. Such encryption protects nothing in practice.

Why it matters for the security review

First on the OWASP Mobile Top 10 (2024) list is M1 — Improper Credential Usage, which covers hardcoded keys and credentials. Hardcoded encryption keys fall under M10 (Insufficient Cryptography), and plain strings plus an unprotected binary under M7 (Insufficient Binary Protections). In MASVS these are requirements in the STORAGE, CRYPTO and RESILIENCE groups.

The expert decompiles the APK and searches strings both automatically and manually. A secret key found this way is usually rated high risk, because the impact reaches beyond the app — to the entire service.

What can be seen inside the app: check it yourself

Android: jadx and apktool

# Open Java/Kotlin code in readable form
jadx -d out app-release.apk

# Resources and manifest
apktool d app-release.apk -o res_out

# Typical searches
grep -rniE "api[_-]?key|secret|token|password|bearer" out/ res_out/
grep -rnoE "https?://[a-zA-Z0-9./_-]+" out/ res_out/ | sort -u

Look at res_out/res/values/strings.xml separately — many keys end up exactly there.

Flutter apps

In Flutter, Dart code is compiled into lib/arm64-v8a/libapp.so. jadx will not show it, but the strings are still in the binary:

unzip -o app-release.apk -d apk_out
strings -n 8 apk_out/lib/arm64-v8a/libapp.so | grep -iE "https?://|key|secret"
ls apk_out/assets/flutter_assets/

Two common traps:

  1. A .env file with flutter_dotenv is bundled into the app as an asset and sits as plain text at assets/flutter_assets/.env. No tools are needed to open it.
  2. --dart-define values become constants at compile time and are written into the binary. They are not visible in the source, but strings finds them.

iOS: IPA

unzip -o App.ipa -d ipa_out
plutil -p ipa_out/Payload/App.app/Info.plist
strings ipa_out/Payload/App.app/App | grep -iE "https?://|key|secret"
# In a Flutter iOS app, the Dart code lives here:
strings ipa_out/Payload/App.app/Frameworks/App.framework/App | grep -iE "https?://"

Also review Info.plist and any .plist or .json configuration files inside the app.

The decompiled folder can be checked with secret-scanning tools:

gitleaks detect --no-git --source out/ -v
trufflehog filesystem out/

Run these tools in CI on your source code as well — a key must be kept out of the repository before it can be kept out of the app. We covered this in managing secrets and keys.

Why "hiding" is not a solution

Encoding a key in base64, splitting it into two parts or "encrypting" it with XOR makes static search harder, but it does not protect the key. To use the key, the app has to bring it into memory in plain form, and at that moment it can be seen by hooking the function with Frida or by watching network traffic. The same is true for server addresses: the app sends requests to them, so they are visible in traffic.

You also need an accurate picture of obfuscation:

  • R8 / ProGuard shortens class and method names but does not encrypt string literals.
  • Dart --obfuscate renames symbols, while strings stay as they are.
  • Commercial protection tools can encrypt strings, but those are also decrypted at runtime.

The rule is simple: if disclosing a value would harm the service, it must not be in the app. Only values that can safely be disclosed, and that are restricted on the server side, stay in the app.

Even so, reducing plain strings is useful, and the review expects it: it raises the cost of static analysis, takes the app out of the "easy target" category for automated scanners and prevents accidental exposure of internal addresses. But it is a supporting layer of protection, not the main one.

How to fix it

1. Move secret keys to the server

The app should not call a third-party service directly. It calls your backend with the user's token, and the backend calls the service with the key. A Laravel example:

// routes/api.php
Route::middleware(['auth:sanctum', 'throttle:5,1'])
    ->post('/otp/send', [OtpController::class, 'send']);

// app/Http/Controllers/OtpController.php
public function send(Request $request, SmsGateway $sms)
{
    $user = $request->user();
    $sms->send($user->phone, $this->makeCode($user)); // key in config('services.sms.key')
    return response()->noContent();
}

A Node (Express) example:

app.post('/api/geocode', requireAuth, rateLimit({ windowMs: 60_000, max: 30 }),
  async (req, res) => {
    const r = await fetch(`https://maps.example.com/geocode?q=${encodeURIComponent(req.body.q)}`,
      { headers: { Authorization: `Bearer ${process.env.MAPS_SECRET}` } });
    res.json(await r.json());
  });

This way the key lives only on the server, every request is tied to a user, and the number of requests is limited.

2. Restrict public identifiers

Keys that must stay in the app (for example, for a maps SDK) should be restricted in the provider's console: for Android, the package name and the SHA-1 of the signing certificate; for iOS, the bundle ID; plus a list of allowed APIs and a daily quota. Firebase configuration is not a secret — its security relies on Security Rules and App Check, so the rules must not be left in an "anyone can read" state.

3. Drop the static "service" token

A token shared by every copy of the app is a shared password. Instead, each user gets their own short-lived token at login (Sanctum, OAuth2 and so on). Pre-login requests (registration, OTP) are limited with rate limiting and bot protection.

4. Verify app authenticity on the server

Play Integrity API on Android, App Attest (DeviceCheck) on iOS or Firebase App Check give the server a signal that "this request comes from an unmodified app on a genuine device". This does not replace authentication, but it makes scripted API abuse harder.

5. Only release endpoints in the release build

Staging, test and admin addresses must not end up in the release build at all. In Flutter, use flavors or a separate entry point:

flutter build apk --release --flavor prod -t lib/main_prod.dart \
  --obfuscate --split-debug-info=build/symbols

If the admin panel is not needed in the mobile app, its address should not be in it, and on the server it should be closed with an IP allowlist.

6. Reduce the remaining strings

To avoid keeping values such as the API base URL as plain literals, there are approaches that encrypt them at build time. For example, in Dart the envied package with obfuscate: true does not store values as plain strings:

@Envied(path: '.env.prod', obfuscate: true)
abstract class Env {
  @EnviedField(varName: 'API_BASE')
  static final String apiBase = _Env.apiBase;
}

This is much better than bundling .env as an asset, but the rule above still applies: only values that can be disclosed go here. Binary protection is further rounded out by R8 (minifyEnabled true) on Android, --obfuscate in Dart and RASP — see app integrity and obfuscation.

7. Rotate any exposed key

If a key has been in any released version, removing it is not enough — old APKs stay on the internet. Revoke (rotate) the key in the provider's console and put the new one only on the server.

Short checklist

  • The release APK/IPA has been checked with jadx, apktool and strings; no secret keys found.
  • .env is not bundled as an asset; no secrets passed through --dart-define.
  • Third-party services are called through the backend; keys live only on the server.
  • Keys remaining in the app are restricted in the provider console by package/bundle ID and signature.
  • Firebase Security Rules are not open; App Check or Play Integrity / App Attest has been considered.
  • No shared static token for all users.
  • No encryption key in the code.
  • No staging, test or admin addresses in the release build.
  • API endpoints are not stored as plain literals; R8 and --obfuscate are enabled.
  • Previously exposed keys have been rotated.
  • gitleaks or a similar tool runs in CI.

Conclusion

You cannot keep a secret inside a mobile app — you can only raise the cost of extracting it. So the right approach has two layers: real secrets live on the server, and the app holds only restricted values whose disclosure does no harm; on top of that, obfuscation and fewer plain strings cover the review requirements. This approach helps not only to pass the review but also to limit the damage if a key does leak.

If you want to see what is inside your release app before the expert does, request a security scan. The Claude AI agent quickly goes through the APK/IPA and source code, and our specialist assesses every key and endpoint found and helps you move them to the server.