Skip to main content
ANVISoftware Solutions
Lesson 9 of 11Advanced21 min

Mobile Security

By the end of this lesson

Protect data on a device you do not control.

One assumption governs this entire lesson: you do not control the device your app runs on. Everything else follows from it, and a team that internalises that sentence makes better decisions than one that memorises a checklist.

Be specific about what it means. The device may be lost or stolen. It may be handed to a colleague, a family member or a site contact for a perfectly innocent reason. It may be rooted or jailbroken, which is to say the operating system's own protections have been removed by whoever owns it. Its network may not be one you would choose. And your app is a file that anybody can download, so the code and the resources inside it can be read by anybody who wants to read them. None of these is exotic. Over a fleet of a few hundred field devices, all of them happen.

From that follows the one rule to take away if you take nothing else: never embed a secret or an API key in the app. Not in source code, not in a configuration file, not in a resource, not in an environment variable baked in at build time, and not in an encrypted blob the app decrypts at startup — because the key that decrypts it has to ship too. Obfuscation is the same shape of argument: it raises the effort required and changes nothing about the outcome. An app is distributed to everybody, so anything inside it is distributed to everybody. An embedded key is a published key.

The consequence is architectural rather than a coding detail. Anything privileged happens on your server. If the field app needs a third-party mapping service, your server holds that provider's key and the app calls your server. If the app needs to send email, your server sends it. And if a piece of logic must not be altered by whoever holds the device — what a user may see, whether a visit can be closed — that logic lives on the server and the app displays the answer. This also improves things that have nothing to do with security: one copy of a provider key can be rotated without a release, rate-limited per user, and replaced with a different provider without stranding the app versions already in the field.

The lesson is defensive throughout. The goal is that a device leaving its owner's hands is a contained problem: the data on it is limited, the credentials on it can be revoked, and nothing on it grants access to anything beyond that one user's own work. Nothing here describes how to attack a device or get around a protection, and you do not need that to build well.

Terms used precisely below. Two of them are frequently confused with each other:

Untrusted client
Your app, from your server's point of view. Its requests arrive from somewhere you do not control, so every one of them has to be authorised and validated on the server regardless of what the app's own code allows. A check performed only in the app is a user experience feature, not a security control.
Rooted or jailbroken device
A device on which the owner has removed the operating system's restrictions. Platform protections such as app isolation and secure storage guarantees are weaker there. You cannot prevent it and you cannot reliably detect it, so the sensible response is to keep less on the device rather than to rely on detection.
Decompilation
Recovering readable structure from a shipped application package. Anyone can download your app from a store, and the strings, configuration files and resources inside it can be read. Code obfuscation makes that slower; it does not make it impossible, and it does nothing at all for a plainly readable string.
Platform secure storage
Keychain on iOS, Keystore-backed storage on Android — introduced in the authentication lesson, where tokens were put there. It is the right home for small credentials and it is not a general-purpose store: it is small, comparatively slow, and wrong for a cached employee list.
Certificate pinning
Configuring the app to accept only specific certificates or public keys for your API, rather than any certificate the device's trust store vouches for. It narrows what a network-level interception can achieve, and it introduces a failure mode of its own. The trade-off callout below is the real content here.
Client configuration holds nothing confidential; the server holds the key
TypeScript
/** Everything the client is allowed to know. This ships to every user. */
export interface ClientConfig {
  apiBaseUrl: string;
  requestTimeoutMs: number;
  featureFlags: Record<string, boolean>;
}

interface SiteLocation {
  latitude: number;
  longitude: number;
}

/**
 * Looks up a site's coordinates. The mapping provider's key never leaves the
 * server: the app asks the employees API, and the API asks the provider.
 */
export async function siteLocation(
  config: ClientConfig,
  accessToken: string,
  siteCode: string,
): Promise<SiteLocation> {
  const path = "/sites/" + encodeURIComponent(siteCode) + "/location";

  const response = await fetch(config.apiBaseUrl + path, {
    headers: { Authorization: "Bearer " + accessToken },
    signal: AbortSignal.timeout(config.requestTimeoutMs),
  });

  if (!response.ok) throw new Error("Site location unavailable");
  return (await response.json()) as SiteLocation;
}
  • The shape is the transferable part. Your toolkit's HTTP client and configuration mechanism will look different; what carries across is the split between what ships and what stays behind.
  • ClientConfig contains a base URL, a timeout and some flags. None of those is a secret. A base URL is discoverable from any request the app makes, so treating it as confidential achieves nothing while making the config harder to work with.
  • What is absent is the point. There is no mapping provider key here, because the app does not have one. It asks your API for a site's location, and your API — running where you control the environment — calls the provider with the key it holds.
  • The Authorization header carries the user's own token from secure storage, as set up in the authentication lesson. That is a per-user credential the server can revoke, which is a categorically different thing from a shared key baked into every installation.
  • encodeURIComponent is defensive rather than cosmetic. A site code is data, and data placed into a URL without encoding can change the shape of the request. The same reasoning applies to anything from a deep link, a scanned code or a text field.
  • The timeout is here because a request with no deadline is its own availability problem, as the API lesson covered. Security work that leaves the app hanging has not made anybody safer.

Defensive habits for the device itself. None is expensive, and each one closes a way that data leaves a device you do not control:

  • Keep credentials in platform secure storage and everything else out of it. Tokens go to Keychain or Keystore-backed storage as set up in the authentication lesson; a cached employee list belongs in your ordinary local store
  • Cache the minimum the app needs to work offline, and expire it. Data you never stored cannot leave with the device, and this is the single most effective control available for a lost handset
  • Never log sensitive data. Logs persist, can be read off a device, and are collected by crash reporting services, so a token or a date of birth written to a log line has left your control
  • Redact at the point of writing rather than afterwards. Once a value has reached a third-party crash reporter, removing it from your code does not remove it from their storage
  • Mark sensitive screens as excluded from screenshots and from the system's app switcher preview. Both platforms offer a way to do this, and the switcher thumbnail is the one people forget — it is captured automatically every time the user leaves the app
  • Exclude credential and cache stores from device backup, or make sure they are keyed so a backup restored onto a different device cannot use them
  • Require transport encryption for every request, with no development exception left enabled in a release build. A single plain-text endpoint is enough to undo the rest
  • Authorise on the server, every time. Hiding a button is a courtesy to the user; the API deciding what this token may do is the control
  • Ask for the fewest permissions the feature needs, and request each one at the point of use with an honest explanation. A permission you never requested is one you cannot misuse or leak through
  • Keep your dependencies current and know what they send. A third-party analytics or logging library added for one screen can collect far more than that screen, and it does so under your app's name
Redacting before a value can reach a log or a crash report
TypeScript
const SENSITIVE_FIELDS = new Set([
  "authorization",
  "accesstoken",
  "refreshtoken",
  "password",
  "dateofbirth",
  "nationalinsurancenumber",
  "homeaddress",
]);

function normalise(key: string): string {
  return key.toLowerCase().replace(/[-_\s]/g, "");
}

export function redact(value: unknown): unknown {
  if (Array.isArray(value)) return value.map(redact);

  if (value !== null && typeof value === "object") {
    const output: Record<string, unknown> = {};
    for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
      output[key] = SENSITIVE_FIELDS.has(normalise(key)) ? "[redacted]" : redact(inner);
    }
    return output;
  }

  return value;
}

/** The only logging entry point the app uses. */
export function logEvent(name: string, detail: Record<string, unknown>): void {
  console.log(name, redact(detail));
}
  • Treat this as a shape. Whatever logging or crash reporting library you use, the principle is that one function is the entry point and redaction happens inside it, before the value goes anywhere.
  • Redaction has to be at the point of writing. A log line already sent to a crash reporting service is in somebody else's storage, on their retention schedule, and deleting the code that produced it changes nothing about the copy they hold.
  • normalise exists because field names are inconsistent in real payloads. Without it, refresh_token and refreshToken are different strings and one of them slips through — which is the kind of gap that is invisible in review.
  • The honest weakness of this approach is that it is a denylist. It protects the fields somebody remembered, and a new field added next month is logged in full until somebody notices. For anything holding personal data, choosing the specific fields you log is stronger than listing the ones you will hide.
  • Never log a whole request or response object, and be careful with HTTP client logging middleware — several clients capture headers by default, which means the Authorization header, which means the user's token in a log file.
  • One more habit that costs nothing: turn verbose logging off in release builds. Debug logging that is useful on your desk becomes a description of your app's internals sitting on a device you do not control.

Summary

  • Assume you do not control the device: it can be lost, shared, rooted, and your app package can be read
  • Never embed a secret or an API key in the app — an embedded key is a published key, and encrypting it in the bundle does not change that
  • Anything privileged happens on your server, which authorises every request; a check that exists only in the app is a user experience feature
  • Keep credentials in platform secure storage, cache the minimum, redact before writing any log, and protect sensitive screens from screenshots, switcher previews and backups
  • Certificate pinning narrows network interception and can take your app offline when a certificate rotates, so switch it on only with backup pins and an update path in place

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Sort what may ship in the app

The field app build needs each of these: the employees API base URL, a mapping provider key, a crash reporting project identifier, a signing key for release builds, an administrator flag that unlocks a diagnostics screen, and the rule that a visit cannot be closed without notes.

For each one, decide whether it can ship inside the app, must stay on the server, or belongs somewhere else entirely. Say why.

Show solution

The API base URL ships. It is visible in every request the app makes, so treating it as confidential buys nothing. Keep it in configuration so a test build can point elsewhere.

The mapping provider key does not ship. It is a shared credential that your account pays for, and in the package it is available to everybody. Your server holds it and the app asks your API, as in the code above.

The crash reporting project identifier ships, with a caveat. It is designed to be in the client and it is not a secret, but check what the library sends by default, because a crash report can include values you did not intend to share.

The release signing key ships nowhere. It belongs in your build system's protected storage, and losing control of it is one of the few genuinely unrecoverable mobile incidents, because it is what a platform uses to decide that an update is really from you.

The administrator flag is the interesting one. A flag in the app decides what the app draws, and a diagnostics screen that reveals data is only protected if the server refuses the underlying requests. So the flag may ship as a display decision, and the entitlement has to be in the user's token and enforced by the API.

The business rule is the same shape. Check it in the app for a fast, clear message, and enforce it on the server because that is the copy that cannot be altered by whoever holds the device. Two checks is not duplication here; they are answering different questions.

Challenge

Design the photo upload without giving the app a key

Visit photos are to be stored in a cloud object store rather than in your database. The obvious approach is to put the storage credential in the app and upload directly.

Explain why that fails, then design an approach that does not require the app to hold any shared credential. Include what happens when the app is terminated mid-upload, and what stops one engineer reading another engineer's photos.

Show solution

Embedding the storage credential fails for the reason the mistake callout gives: it ships to every user, it can be read out of the package, and it is usually scoped to a whole container rather than to one engineer's photos. A single leaked copy exposes every photo, and rotating it needs a store release.

A workable design: the app asks your API to begin an upload. The API authorises the request against the engineer's token, decides where the photo belongs, and returns a short-lived, single-purpose permission to write to exactly that location. The app uploads with that, then tells your API the upload is done so the record can be attached. The app never holds a credential that outlives the one upload or reaches beyond the one object.

Short-lived and narrowly scoped are both load-bearing. A permission valid for minutes and for one object is worth almost nothing if it escapes, which is the property you want for anything that has to pass through an untrusted client.

If the app is terminated mid-upload, the queue on disk still holds the photo, as the state lesson set out. On restart the app asks for a fresh permission rather than reusing the old one, since the old one has likely expired. The server has to recognise a repeated attempt for the same visit and photo so a retry does not attach two copies.

What stops cross-engineer access is entirely server-side: your API decides the location from the authenticated user, never from a path the app supplies, and the store itself does not accept a request without a permission your API issued. If the app could name the destination, a modified client would name somebody else's.

Worth stating the honest cost: this is more moving parts than a direct upload, and it adds a round trip before each photo. That is the price of not shipping a shared credential, and on this trade-off there is not much of an argument to have.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

Your app needs a third-party mapping key. A colleague suggests encrypting it and decrypting at startup. What is wrong with that?
What is the most important thing to plan before enabling certificate pinning?

Saved in this browser only.