Mobile App Security: Protecting Secrets, Tokens & Data in React Native
Security18 min read

Mobile App Security: Protecting Secrets, Tokens & Data in React Native

A senior-level guide to React Native and Expo security: why anything shipped in your bundle is not secret, how to proxy third-party APIs, store tokens in Keychain/Keystore, pin certificates, harden data at rest, and defend against deep-link hijacking, tampering, and supply-chain attacks.

Taha Kocal

Taha Kocal

Full-Stack & Mobile Developer

May 18, 2026
#React Native#Expo#Mobile Security#Secure Storage#OWASP MASVS#Certificate Pinning#Authentication

Mobile app security is the practice of protecting the secrets, tokens, and user data inside applications that run on devices you do not control - and it differs from web security in one uncomfortable way: the attacker owns the runtime. Every string, environment variable, and endpoint baked into a React Native or Expo bundle can be extracted by decompiling the app, so API keys must be proxied through a backend rather than shipped to the client. Authentication tokens belong in hardware-backed storage - the iOS Keychain or Android Keystore via expo-secure-store - never in AsyncStorage, which is plain unencrypted disk. The industry baseline is the OWASP Mobile Application Security Verification Standard (MASVS), which defines control groups covering storage, cryptography, authentication, network communication, platform interaction, and resilience. This guide works through each layer for React Native and Expo: secret handling, token storage and rotation, certificate pinning, data at rest, deep links, tampering, and supply-chain risk.

This guide is written for React Native and Expo apps, but the principles are platform-agnostic. We will start with the single most misunderstood truth in mobile development, then work through token storage, transport security, data at rest, deep links, tampering, and the operational side of secrets in EAS and your dependency tree. Throughout, the emphasis is on what actually moves the needle versus what merely looks reassuring.

Why Is Nothing in the Bundle Secret?

If a value is shipped inside your app binary, assume it is public. This includes API keys, secret tokens, private endpoints, feature-flag payloads, and anything you injected through a build-time environment variable. It does not matter whether you used process.env, a .env file, react-native-config, or Expo's EXPO_PUBLIC_ prefix: at build time these values are inlined into your JavaScript bundle as plain string literals. Anyone can extract them.

Extraction is trivial. An IPA or APK is just a zip archive. On Android an attacker unzips the APK, runs it through apktool or jadx, and greps the decompiled output. On iOS they pull the decrypted binary off a jailbroken device and run strings against it. The Hermes bytecode that Expo ships can be disassembled with hbctool. There is no build flag, minifier, or bundler that turns a shipped string into a genuine secret. Minification renames variables; it does not encrypt the string values your code depends on at runtime.

If your app can read a secret at runtime, so can an attacker with a copy of your app. Obfuscation raises the cost of extraction by minutes, not years. Architecture is the only real fix.

The EXPO_PUBLIC_ trap

Expo makes this explicit with a naming convention that many developers misread. Any variable prefixed with EXPO_PUBLIC_ is inlined into the client bundle. The word "public" is the warning label. Putting a third-party secret behind that prefix does not protect it; it publishes it.

tsx
// DON'T: this key is now a plain string inside your shipped bundle.
// Anyone can unzip the APK/IPA and read it in seconds.
const stripeSecret = process.env.EXPO_PUBLIC_STRIPE_SECRET_KEY;
const openAiKey = process.env.EXPO_PUBLIC_OPENAI_API_KEY;

await fetch('https://api.openai.com/v1/chat/completions', {
  method: 'POST',
  headers: { Authorization: `Bearer ${openAiKey}` },
  body: JSON.stringify(payload),
});

// EXPO_PUBLIC_ is only acceptable for values that are genuinely public:
// a publishable Stripe key (pk_...), a public Sentry DSN, an analytics
// write-only key, or your own backend's base URL.
const apiBaseUrl = process.env.EXPO_PUBLIC_API_URL;

How Do You Keep API Keys Off the Client?

The architectural answer is that secret keys never leave your server. Instead of calling OpenAI, Stripe, a mapping provider, or any keyed third party directly from the device, the app calls your own backend, and your backend holds the secret and forwards the request. This is sometimes called the Backend-for-Frontend (BFF) pattern, and it is the single most important design decision for mobile security.

This proxy does more than hide a key. It becomes the place where you enforce per-user authentication, rate limiting, spend caps, input validation, and abuse detection. A leaked OpenAI key on a client can drain thousands of dollars before you notice. The same key held server-side, guarded by your own auth and quota logic, is safe even if your app is fully reverse-engineered.

typescript
// Supabase Edge Function (Deno) acting as a secure proxy.
// The OpenAI key is set via `supabase secrets set` and never ships to the client.
import { createClient } from 'jsr:@supabase/supabase-js@2';

Deno.serve(async (req) => {
  // 1. Authenticate the caller using the user's own session, not the API key.
  const authHeader = req.headers.get('Authorization');
  if (!authHeader) return new Response('Unauthorized', { status: 401 });

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_ANON_KEY')!,
    { global: { headers: { Authorization: authHeader } } },
  );

  const { data: { user }, error } = await supabase.auth.getUser();
  if (error || !user) return new Response('Unauthorized', { status: 401 });

  // 2. Enforce a per-user quota / rate limit here (omitted for brevity).

  // 3. Forward the request using the secret that only the server knows.
  const { prompt } = await req.json();
  const openaiRes = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${Deno.env.get('OPENAI_API_KEY')}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'gpt-4o-mini',
      messages: [{ role: 'user', content: prompt }],
    }),
  });

  return new Response(await openaiRes.text(), {
    headers: { 'Content-Type': 'application/json' },
  });
});
tsx
// The client only ever talks to your backend. No third-party secret in sight.
async function askAssistant(prompt: string, accessToken: string) {
  const res = await fetch(`${process.env.EXPO_PUBLIC_API_URL}/assistant`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${accessToken}`, // the USER's token, short-lived
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ prompt }),
  });
  if (!res.ok) throw new Error('Assistant request failed');
  return res.json();
}

Where Should You Store Auth Tokens?

Your app still needs to hold something sensitive on the device: the user's session tokens. Where you put them matters enormously. AsyncStorage is the wrong place. It is an unencrypted key-value store backed by a plain SQLite database (Android) or a plist-style file (iOS). On a rooted or jailbroken device, and in unencrypted device backups, its contents are readable as plaintext. Never store access tokens, refresh tokens, passwords, or PII in AsyncStorage.

The correct home for secrets is the operating system's hardware-backed secure enclave: the iOS Keychain and the Android Keystore. In Expo, expo-secure-store wraps both behind one API. On iOS it uses the Keychain Services; on Android it encrypts values with a key held in the Keystore (backed by the Trusted Execution Environment or a dedicated secure element where available). Values are encrypted at rest and scoped to your app.

tsx
import * as SecureStore from 'expo-secure-store';

const ACCESS_KEY = 'auth.accessToken';
const REFRESH_KEY = 'auth.refreshToken';

export async function saveTokens(access: string, refresh: string) {
  await SecureStore.setItemAsync(ACCESS_KEY, access, {
    keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
  });
  await SecureStore.setItemAsync(REFRESH_KEY, refresh, {
    keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
  });
}

export async function getAccessToken() {
  return SecureStore.getItemAsync(ACCESS_KEY);
}

export async function clearTokens() {
  await SecureStore.deleteItemAsync(ACCESS_KEY);
  await SecureStore.deleteItemAsync(REFRESH_KEY);
}

A few important nuances. The keychainAccessible option controls when the value is readable. WHEN_UNLOCKED_THIS_DEVICE_ONLY is a strong default: the token is only decryptable while the device is unlocked, and the THIS_DEVICE_ONLY suffix prevents it from being restored onto a different device through an iCloud or encrypted backup. For truly sensitive apps you can also require biometric authentication before a value can be read by passing requireAuthentication, which gates access behind Face ID or fingerprint. Note that SecureStore has a practical size limit of around 2 KB per value on some platforms, so it is for secrets, not for caching large blobs.

If you are not on Expo, react-native-keychain gives you the same Keychain and Keystore access with fine-grained control over accessibility and biometric prompts. The bare MMKV library is fast but only encrypted if you explicitly configure an encryption key, and that key then has its own storage problem, so for tokens prefer the OS secure stores.

How Should You Handle Access and Refresh Tokens?

A robust mobile auth model uses two tokens. The access token is a short-lived JWT (commonly 5 to 15 minutes) sent with every API call. The refresh token is a long-lived, opaque credential used only to obtain new access tokens. This split limits the blast radius of a leaked access token to minutes, while keeping the user logged in for weeks without re-entering credentials.

Refresh tokens must rotate. Every time a refresh token is used, the server issues a brand-new refresh token and invalidates the old one. This enables reuse detection: if an old, already-consumed refresh token is presented again, it means either your app hit a race condition or an attacker has stolen a token. The safe response is to revoke the entire token family and force a fresh login. This turns a stolen refresh token from a permanent backdoor into a self-destructing one.

tsx
import { getAccessToken, saveTokens, clearTokens } from './tokenStore';
import * as SecureStore from 'expo-secure-store';

let refreshPromise: Promise<string> | null = null;

// Single-flight refresh: many parallel 401s share ONE refresh call,
// which avoids racing rotations that would trip reuse detection.
async function refreshAccessToken(): Promise<string> {
  if (refreshPromise) return refreshPromise;

  refreshPromise = (async () => {
    const refresh = await SecureStore.getItemAsync('auth.refreshToken');
    if (!refresh) throw new Error('No refresh token');

    const res = await fetch(`${process.env.EXPO_PUBLIC_API_URL}/auth/refresh`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken: refresh }),
    });

    if (!res.ok) {
      await clearTokens();           // reuse detected or expired -> log out
      throw new Error('Session expired');
    }

    const { accessToken, refreshToken } = await res.json();
    await saveTokens(accessToken, refreshToken); // store the ROTATED pair
    return accessToken;
  })();

  try {
    return await refreshPromise;
  } finally {
    refreshPromise = null;
  }
}

export async function authedFetch(input: string, init: RequestInit = {}) {
  let token = await getAccessToken();
  const withAuth = (t: string | null): RequestInit => ({
    ...init,
    headers: { ...init.headers, Authorization: `Bearer ${t}` },
  });

  let res = await fetch(input, withAuth(token));
  if (res.status === 401) {
    token = await refreshAccessToken();
    res = await fetch(input, withAuth(token)); // retry once with fresh token
  }
  return res;
}

Two practical details save real production pain. First, use single-flight refresh: when several requests fail with 401 at once, they must share one refresh call rather than each triggering its own rotation, which would otherwise cause them to invalidate each other. Second, on logout you must call a server-side revoke endpoint, not just clear local storage. Deleting the token from the device does not stop a copy that was already exfiltrated; only server-side revocation does.

When Do You Need Certificate Pinning?

All traffic must be HTTPS, always. Both platforms enforce this by default: iOS App Transport Security blocks cleartext HTTP, and Android disables cleartext traffic for apps targeting recent SDK levels. Do not add exceptions to ship faster. If you need a local HTTP endpoint during development, scope the exception to that domain and never to the whole app, and never ship it.

TLS protects against passive eavesdropping, but it trusts the device's certificate store. An attacker who controls the device or the network can install a rogue CA and perform a man-in-the-middle attack, decrypting your "encrypted" traffic to read tokens and payloads. Certificate pinning defends against this by making the app trust only a specific certificate or public key, ignoring the system trust store. If the presented certificate does not match your pin, the connection is refused.

Pin the public key (SPKI hash), not the full certificate, so that routine certificate renewals do not break your app as long as you keep the same key pair. Always ship at least one backup pin so you can rotate keys without bricking installed apps. In React Native, react-native-ssl-pinning or the newer react-native-cert-pinner handle this; on newer Android, network_security_config.xml supports pinning declaratively.

tsx
import { fetch as pinnedFetch } from 'react-native-ssl-pinning';

// Pin the server's SPKI public-key hashes. Include a BACKUP pin so you can
// rotate the leaf certificate without forcing every user to update the app.
async function secureRequest(path: string, token: string) {
  return pinnedFetch(`https://api.tahakocal.dev${path}`, {
    method: 'GET',
    sslPinning: {
      certs: ['primaryPublicKeyHash', 'backupPublicKeyHash'],
    },
    headers: {
      Accept: 'application/json',
      Authorization: `Bearer ${token}`,
    },
  });
}

Certificate pinning is powerful and dangerous in equal measure. A pin you cannot rotate is a time bomb: the day that certificate expires, every installed copy of your app stops working. Always ship backup pins and have a rollout plan before you pin anything.

How Do You Protect Data at Rest?

Beyond tokens, think hard about every byte your app persists. The guiding principle is data minimization: the most secure data is the data you never store. If you do not need a value after the session, keep it in memory only. If you must persist it, classify it and store it accordingly.

A practical tiering of on-device storage:

  • Secrets (tokens, keys, credentials): expo-secure-store / Keychain / Keystore, never AsyncStorage.
  • Sensitive user data (PII, health, financial records): encrypted database such as SQLCipher or op-sqlite with encryption, with the key held in SecureStore.
  • Non-sensitive cache (UI preferences, non-personal lookup data): AsyncStorage or MMKV is fine.
  • Ephemeral secrets (a one-time code, a decrypted payload): keep in component state or memory and let it be garbage collected; never write it to disk.

Do not leak secrets into logs and crash reports

A surprisingly common leak vector is observability tooling. A console.log of a request object, a Sentry breadcrumb capturing headers, or an analytics event with the full response body can quietly ship tokens and PII to a third-party dashboard. Scrub sensitive fields before they ever reach a logger, and strip verbose logging from production builds entirely with babel-plugin-transform-remove-console.

tsx
// DON'T: this ships the Authorization header and body to Sentry/console.
console.log('API request', { url, headers, body });

// DO: log only what is safe, and redact known-sensitive keys.
const REDACT = new Set(['authorization', 'password', 'refreshtoken', 'token']);

function safeMeta(obj: Record<string, unknown>) {
  return Object.fromEntries(
    Object.entries(obj).map(([k, v]) =>
      REDACT.has(k.toLowerCase()) ? [k, '[redacted]'] : [k, v],
    ),
  );
}

if (__DEV__) console.log('API request', safeMeta({ url, status }));

// Also configure Sentry to strip auth headers before sending:
// Sentry.init({ beforeSend(event) { /* delete event.request?.headers?.Authorization */ return event; } });

Two more platform hardening steps worth knowing. iOS and Android capture a screenshot of your app when it is backgrounded for the app switcher; if a sensitive screen is visible, that thumbnail is written to disk. You can blur or hide the screen on backgrounding. And fields containing secrets should disable keyboard autofill, spellcheck, and clipboard suggestions to avoid third-party keyboards caching the value.

How Do You Defend Against Deep-Link Hijacking?

Deep links are a security surface, not just a navigation convenience. Custom URL schemes such as myapp:// are not exclusive: any other app on the device can register the same scheme. If your OAuth flow or a password reset returns a token via a custom scheme, a malicious app that also claims that scheme can intercept the callback and steal the token. This is URL-scheme hijacking, and it is a real, exploited class of attack.

The defense is to use verified deep links instead of custom schemes for anything sensitive: iOS Universal Links and Android App Links. These are tied to an HTTPS domain you prove you own via an apple-app-site-association file and an assetlinks.json file. Because ownership is verified against your domain, no other app can claim your links. For OAuth specifically, always use PKCE (Proof Key for Code Exchange), which binds the authorization code to a secret generated on the device so an intercepted code is useless without it. Expo AuthSession handles PKCE for you.

tsx
// Treat every incoming deep link as untrusted input. Validate the host and
// path, and never blindly execute an action or trust an embedded token.
import * as Linking from 'expo-linking';

function handleDeepLink(url: string) {
  const { hostname, path, queryParams } = Linking.parse(url);

  // Only accept links from your verified domain/host.
  const ALLOWED_HOSTS = new Set(['tahakocal.dev', 'app.tahakocal.dev']);
  if (hostname && !ALLOWED_HOSTS.has(hostname)) return;

  // Whitelist the routes a deep link is allowed to reach.
  if (path === 'reset-password') {
    // Do NOT trust a token passed in the URL as proof of anything.
    // Send it to the backend for verification before acting on it.
    verifyResetToken(String(queryParams?.token ?? ''));
  }
}

Linking.addEventListener('url', ({ url }) => handleDeepLink(url));

Should You Detect Jailbreak, Root and Tampering?

A jailbroken or rooted device breaks the OS security guarantees your app relies on: the sandbox, secure storage isolation, and code integrity. On such a device an attacker can attach Frida or a debugger, hook your functions, dump memory, and bypass client-side checks. You cannot fully prevent this, but for higher-risk apps (finance, health) you can detect it and degrade gracefully, for example by refusing to run, disabling certain features, or increasing server-side scrutiny of that session.

Be realistic about what detection buys you. Any client-side check can be patched out by an attacker who controls the device, so root and jailbreak detection is a speed bump, not a wall. The durable defense is attestation performed and verified on your server: Apple App Attest and Android Play Integrity let your backend cryptographically verify that a request came from a genuine, unmodified build of your app on a genuine device, before it will honor sensitive operations. Because the verdict is checked server-side, it cannot simply be hooked away on the client.

A layered anti-tampering posture:

  • Client-side: jail-monkey or a root/jailbreak check to detect compromised devices and adjust behavior.
  • Integrity: Play Integrity API (Android) and App Attest / DeviceCheck (iOS), verified on your server for sensitive endpoints.
  • Server-side: treat every request as potentially forged; enforce authorization, rate limits, and anomaly detection regardless of client claims.
  • Never rely on a client-side boolean (isPremium, isAdmin) to gate value; the server must be the source of truth.

OWASP MASVS and the Mobile Top 10

You do not have to invent a threat model from scratch. OWASP maintains two mobile references worth internalizing. The Mobile Application Security Verification Standard (MASVS) is a structured baseline of security requirements, and the Mobile Application Security Testing Guide (MASTG) is the companion manual for verifying them. The MASVS groups controls into categories such as Storage, Crypto, Authentication, Network, Platform interaction, Code quality, and Resilience against reverse engineering.

The OWASP Mobile Top 10 is the more approachable entry point, cataloguing the most common and impactful risks. The current list emphasizes issues that map directly to everything above: improper credential usage, inadequate supply-chain security, insecure authentication and authorization, insufficient input and output validation, insecure communication, inadequate privacy controls, insufficient binary protections, security misconfiguration, insecure data storage, and insufficient cryptography. If you can honestly say your app handles each of these, you are ahead of most shipping mobile software.

Secrets and Configuration in EAS

Build-time secrets are a real category, distinct from runtime secrets. Your app never needs your Google service-account JSON or your Apple API key, but your build pipeline does. These must live in EAS as encrypted secrets, never committed to the repository. The classic mistake is checking a google-services.json with a private key or an App Store Connect key into git, where it lives forever in the history even after you delete it.

bash
# Store build-time secrets in EAS, encrypted, out of your repo and bundle.
eas secret:create --scope project --name GOOGLE_SERVICE_ACCOUNT_KEY \
  --type file --value ./service-account.json

eas secret:create --scope project --name SENTRY_AUTH_TOKEN \
  --type string --value "sntrys_..."

eas secret:list   # audit what exists; values are never printed back

# Reference file-type secrets from eas.json / app config, e.g. the
# Android service account path used for store submission:
#   "serviceAccountKeyPath": "$GOOGLE_SERVICE_ACCOUNT_KEY"

# And keep the real files out of version control:
echo "service-account.json"  >> .gitignore
echo "google-services.json"  >> .gitignore
echo ".env*"                 >> .gitignore

Keep the distinction crisp. EXPO_PUBLIC_ variables are for genuinely public runtime config that you accept will be in the bundle. EAS secrets are for build-time and submission credentials that must never reach the device. A third-party API secret belongs in neither: it belongs behind your backend proxy. If you have already committed a secret, rotating it is mandatory; deleting the file does not undo the exposure because it remains in git history and in any clone or fork.

Is Code Obfuscation Worth It?

Obfuscation deserves an honest word because it is often oversold. Tools like Hermes bytecode compilation, ProGuard/R8 on Android, and JavaScript obfuscators do raise the effort required to reverse-engineer your app, and they are worth enabling for release builds. But obfuscation transforms the form of your code, not its secrecy. A determined attacker with a debugger observes the actual runtime values regardless of how the source was scrambled. Treat obfuscation as friction that filters out casual attackers, never as a substitute for keeping secrets off the device in the first place.

Permissions, Privacy and App Tracking Transparency

Security and privacy overlap: data you never request cannot leak. Request the minimum permissions your feature actually needs, request them just in time with clear context rather than all at once on launch, and prefer coarse over precise where it suffices (approximate location instead of exact, add-only photo access instead of the full library). Over-permissioned apps are both a privacy liability and a common App Store review rejection.

On iOS, App Tracking Transparency (ATT) legally requires an explicit permission prompt before you access the IDFA or track the user across apps and websites owned by other companies. You must declare a usage description string, and any tracking SDK must wait for authorization. Both stores also require an accurate privacy manifest and data-safety declaration describing what you collect and why; these must match your actual behavior, because mismatches are audited and rejected.

tsx
import {
  requestTrackingPermissionsAsync,
  getTrackingPermissionsAsync,
} from 'expo-tracking-transparency';

// Ask only when you actually need tracking, and only initialize the
// tracking SDK AFTER the user has granted permission.
async function maybeEnableTracking() {
  const { status: existing } = await getTrackingPermissionsAsync();
  const status =
    existing === 'undetermined'
      ? (await requestTrackingPermissionsAsync()).status
      : existing;

  if (status === 'granted') {
    // Safe to initialize IDFA-based attribution / ad SDKs now.
    return true;
  }
  // Respect the decision: run in non-tracking mode, do not nag.
  return false;
}

Dependency and Supply-Chain Risk

A typical React Native app pulls in hundreds of transitive npm packages, and every one of them runs with your app's privileges. Supply-chain attacks, where a popular package is compromised or a malicious clone is typo-squatted, have repeatedly hit the npm ecosystem. Your dependency tree is part of your attack surface, and it is explicitly called out in the OWASP Mobile Top 10.

Practical supply-chain hygiene:

  • Commit and honor a lockfile so builds are reproducible and unexpected version drift is visible.
  • Run npm audit / a scanner such as Snyk or Socket in CI, and treat high-severity findings as build failures.
  • Vet new dependencies before adding them: maintenance activity, download counts, open issues, and whether you truly need them versus a few lines of your own code.
  • Pin or carefully review major upgrades; a compromised patch release is a known attack vector, so avoid blind auto-updates in CI.
  • Watch native modules especially closely: they run outside the JS sandbox with full device access, so a malicious one is far more dangerous than a pure-JS package.
  • Enable Dependabot or Renovate for timely security patches, but require human review before merging.

The Mobile Security Checklist

Use this as a pre-release gate. If you cannot check a box, you have a known risk to accept or fix consciously rather than by accident.

Secrets and API keys

  • No third-party secret keys shipped in the bundle; all keyed APIs are proxied through your backend or an edge function.
  • EXPO_PUBLIC_ is used only for genuinely public values (your API base URL, publishable keys, public DSNs).
  • Build-time credentials (service-account JSON, store keys) live in EAS secrets, not in git; .env and credential files are gitignored.
  • Any secret ever committed to git history has been rotated.

Tokens and authentication

  • Tokens stored in expo-secure-store / Keychain / Keystore, never AsyncStorage.
  • Short-lived access tokens paired with rotating refresh tokens and server-side reuse detection.
  • Single-flight refresh implemented; logout calls a server-side revoke endpoint.
  • Authorization is enforced server-side; no client boolean gates real value.

Transport and data

  • HTTPS enforced everywhere; no cleartext exceptions in release builds.
  • Certificate/public-key pinning in place for sensitive apps, with backup pins and a rotation plan.
  • Sensitive data at rest is encrypted (SQLCipher/encrypted DB) with keys in secure storage; data collection is minimized.
  • Secrets and PII are scrubbed from logs, breadcrumbs, and crash reports; console logging stripped from production.

Platform, integrity and privacy

  • Verified deep links (Universal Links / App Links) for sensitive flows; OAuth uses PKCE; incoming links are validated.
  • Root/jailbreak detection plus server-verified App Attest / Play Integrity for high-risk operations.
  • Release builds use Hermes/R8 obfuscation as friction, not as a secret store.
  • Least-privilege permissions requested just in time; ATT prompt before any cross-app tracking; privacy manifests accurate.
  • Dependencies scanned in CI, lockfile committed, native modules vetted, upgrades human-reviewed.

The throughline of every item above is the same principle we started with: the device is hostile territory, so genuine secrets and genuine authority must live on the server, while the client is hardened to protect the user and to raise the cost of attack. Get the architecture right first (proxy your keys, store tokens correctly, enforce authorization server-side) and the remaining measures become defense in depth rather than desperate patches. Security is not a feature you add at the end; it is a set of decisions you make from the first commit.

Share this article

Related Articles