Get listed

Mobile app security: no secrets in the APK, authz on the row

A glass phone case with the SIM tray slid out in coral.

A mobile app that talks to your API is a public client. The phone is not a secret store you control.

Certificate pins can break on rotation. A token in app storage can be read on a rooted device or from a backup. The API still has to authorize the object, because the app binary will be inspected.

The usual mistake is treating the APK as a trusted peer and skipping the same IDOR and rate-limit checks you would put on a website.

This page is the cloud-side controls that still hold when the client is a phone, and the Android network settings that are worth shipping.

Android 7.0 shipped Network Security Configuration in 2016. The current Android page still warns that a pin without a backup takes the install offline when you rotate the CA.It never mentioned the APK. Every string you ship in the binary is public the day the build hits the store.

This is not a cloud-provider catalog. Public versus private tenancy does not authorize an invoice. Keep the secure coding checklist next to the API. If the phone still uses a cookie session against a first-party origin, the session overview is the storage model. Four links is the cap. The rest of this page is the binary, the TLS policy, and the object check.

The binary is not a vault

An APK is a zip. An IPA is a zip. Strings, resource XML, and BuildConfig fields are readable without a jailbreak. Treat any value compiled into the client as a public identifier, not a credential. That includes a third-party API secret, an AWS AKIA key, a Stripe restricted key, a Firebase admin JSON, and the HMAC that mints your own JWTs. The phone may call your API. It must not hold the key that signs other people’s tokens.

Client identifiers that are not secrets can stay: a OAuth client id that the authorization server already treats as public, a map style key that is referer-bound and billed, a crash reporter app id. Bind those on the provider console to the package name and the signing cert. Do not pretend the binding is confidentiality.

// BAD: compiled into every APK
// buildConfigField "String", "JWT_SIGNING_SECRET", "\"super-secret\""
// buildConfigField "String", "STRIPE_SECRET_KEY", "\"sk_live_...\""

// FIX: only public, bindable ids. No signing material.
buildConfigField "String", "API_ORIGIN", "\"https://api.example.com\""
buildConfigField "String", "OAUTH_CLIENT_ID", "\"mobile-app\""

On iOS the same rule is Info.plist and any #define in a shipped binary. If a value would let a stranger act as the service, it does not belong in the bundle. Put it on the API host. The Verizon 2026 DBIR, in the third-party cloud section still names missing MFA, sloppy rotation, and missing least privilege on users and service accounts as the boring causes. A key in the APK is a service account you cannot release.

TLS first. Pin only if you can rotate

Cleartext is the first fail. Android’s network config can deny it for the whole app. iOS App Transport Security does the same unless you add an exception you can name. Pinning is a tighter trust list: the chain must contain one of the SPKI hashes you shipped. OWASP MASTG still treats a missing pin as not a finding on MASVS L1, and as required on L2. That is why this page calls the pin optional.

Android’s own docs say include a backup key, and they allow an expiration on the pin-set so an unupdated client stops pinning instead of going dark. Expiration is a safety valve, not a plan. If you cannot ship a store update before the leaf or the intermediate rotates, do not pin.

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
 <base-config cleartextTrafficPermitted="false" />
 <domain-config>
 <domain includeSubdomains="true">api.example.com</domain>
 <!-- Optional. Only if you rotate pins before expiration. -->
 <pin-set expiration="2027-03-01">
 <pin digest="SHA-256">BASE64_SPKI_CURRENT</pin>
 <pin digest="SHA-256">BASE64_SPKI_BACKUP</pin>
 </pin-set>
 </domain-config>
</network-security-config>

Replace the two BASE64_SPKI_* placeholders with hashes you computed from certificates you own. I am not pasting a live pin. NSC applies to HttpsURLConnection and to WebView traffic in the same app. Native stacks and some third-party engines skip it. If you pin in OkHttp, you still need the same backup and expiry story. A pin on a host you do not control, a CDN you might change, or a third-party auth page is how you brick login. Skip those hosts.

Certificate Transparency is a separate Android config. It is not a pin. User-installed CAs should stay off in release builds. Debug overrides belong in a debug-only source set. same Android security-config page for those three knobs. I have not claimed a 2026 platform default that forces pinning on every Play app. There is not one I can cite.

A valid token is not an object grant

The phone will send a Bearer token. The API will parse it. That answers who. It does not answer which invoice. IDOR is the mobile bug that looks like a deep link: /invoices/1842 on a device the user already unlocked. A UUID in the path does not close it. Sjoerd’s line above is the whole control. The query must include the tenant from the verified actor.

Most Express cookie apps should not add a JWT at all. The JWT guide on this site says so. A native client talking to an API you also serve to the web is one of the cases that may warrant a token. Then you still pin alg, aud, iss, and exp on the server. You never verify the token in the app and then trust a body field named orgId.

// FIX: pg 8. Actor comes from the verified access token, not the JSON body.
async function loadOwnedInvoice(pool, actor, invoiceId) {
 const { rows } = await pool.query(
 `SELECT id, memo, amount_cents
 FROM invoices
 WHERE id = $1 AND org_id = $2`,
 [invoiceId, actor.orgId]
 );
 return rows[0] || null;
}

app.get("/invoices/:id", async (req, res) => {
 const row = await loadOwnedInvoice(req.db, req.actor, req.params.id);
 if (!row) {
 res.status(404).json({ error: "not_found" });
 return;
 }
 res.json(row);
});

loadOwnedInvoice is the named gate. A missing row and a foreign row both return 404. A 403 on a known id is an existence oracle. PUT, PATCH, and DELETE use the same predicate. Do not load by id and compare row.org_id in a later line you will skip on the write path. The IDOR page is the longer treatment of that query. This page only insists the mobile client cannot be the enforcement point.

// BAD: trust the phone
// const orgId = req.body.orgId || req.headers["x-org-id"]
// const { rows } = await pool.query("SELECT * FROM invoices WHERE id = $1", [id])

If you mint JWTs, keep org_id as a claim you set, then ignore any org field the client sends. Claims are not a substitute for the WHERE clause. A stolen access token still reads only the rows that token’s actor owns, which is bad enough. A token plus an arbitrary id without loadOwnedInvoice reads the company.

The phone is a client. The object check is on the API. A pin is an extra TLS constraint, not the lock.
device
 Keystore refresh --> short access token
 |
 | TLS 1.2+ (optional pin-set)
 v
 api.example.com
 |
 | loadOwnedInvoice(actor, id)
 v
 invoices WHERE id AND org_id
 |
 miss --> 404

Where the refresh value lives

localStorage advice from the web JWT page applies even harder on a phone: a world-readable file is a portable credential for the whole TTL. Use the platform store. On Android that is the Android Keystore, with a key that requires biometric unlock if the product can stand the prompt. On iOS that is the Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly unless you have a named backup story. I am not pasting a vendor wrapper that was deprecated in Jetpack Security crypto.

// Kotlin sketch. Keystore-backed AES key, then encrypted bytes on disk.
fun storeRefreshToken(context: Context, token: String) {
 val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
 if (!ks.containsAlias("refresh_wrap")) {
 val spec = KeyGenParameterSpec.Builder(
 "refresh_wrap",
 KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
 )
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(false)
.build()
 KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
.apply { init(spec) }
.generateKey()
 }
 // Encrypt token with the keystore key, write ciphertext to app-private filesDir.
}

storeRefreshToken is the named write. The access token stays in memory and dies with the process or a 15-minute exp. The refresh value is the long one. Do not log it. Do not put it in crash breadcrumbs. Do not sync it to a backup you do not control unless the product requires device migrate, and then say so in the Keychain accessibility. Biometric gating is a product call. Turning it on without a fallback lockout path is how you strand a user. Turning it off is still better than a plaintext file in external storage.

ValueWhereNever
Signing secretAPI host onlyAPK, IPA, repo
RefreshKeystore / Keychainlog, backup, extras
Access tokenmemory, short expworld file
Object grantloadOwnedInvoicepath id alone

Prove the API and the package

You are testing your app and your API. You are not unpacking someone else’s store binary to steal a key.

  1. In your own APK or IPA, strings the package or dump BuildConfig. Expect API_ORIGIN and OAUTH_CLIENT_ID. A hit on sk_live_, AKIA, BEGIN PRIVATE, or a JWT secret is a ship stop.
  2. Call GET /invoices/<an-id-you-own> with your own access token. Expect 200. Replay with a second test user’s token against the same id. Expect 404 from loadOwnedInvoice.
  3. Send the same request with a body or header orgId set to another tenant. The row must not change. The query ignores that field.
  4. If you shipped a pin-set, confirm both hashes are yours and the expiration is after the next planned rotate. A pin that expires next week with no store release in flight is a ticket.
  5. Confirm release builds reject user CAs and cleartext. Debug overrides must not be in the release source set.
curl -sS -D - -o /tmp/inv.json \
 -H "Authorization: Bearer $TOKEN_USER_A" \
 "https://api.example.com/invoices/$INVOICE_B"
# Expect: HTTP/2 404

rg -n "sk_live_|AKIA[A-Z0-9]{16}|JWT_SIGNING|BEGIN PRIVATE|buildConfigField.*SECRET" \
 --glob '!app/build' --glob '!.gradle'

The curl uses tokens you already issued in a test tenant. Do not point it at a production customer. The IDOR page has the longer 404-versus-403 note. This page only needs the 404 from loadOwnedInvoice.

Questions we keep getting

Do I have to pin to pass a mobile assessment?

Not for MASVS L1. L2 still asks for it on endpoints you control. Android will not fail your Play listing for a missing pin. A pin you cannot rotate will fail your users. TLS plus a denied cleartext config is the baseline I would ship first.

Can the app verify the JWT and skip the server check?

No. The app is a client the user controls. Verification on the device is a UX hint. loadOwnedInvoice on the API is the control. If you do not need a JWT at all, use a server session and read the JWT page before you add jose.

Is an IP allow list a substitute for object authz?

No. Carrier NAT and home wifi share addresses.That is a management-plane belt. It does not decide invoice 1842.