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

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:
- A
.envfile withflutter_dotenvis bundled into the app as an asset and sits as plain text atassets/flutter_assets/.env. No tools are needed to open it. --dart-definevalues become constants at compile time and are written into the binary. They are not visible in the source, butstringsfinds 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.
Automated search
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
--obfuscaterenames 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. -
.envis 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
--obfuscateare 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.


