Also in 中文. Part 6 of Trading app architecture.
Start from an uncomfortable premise: the device is not yours. It belongs to someone who may be curious, compromised, or hostile, and who can inspect memory, intercept traffic, patch the binary, and replay any request your app can make.
That premise is not pessimism. It is the only assumption that produces a design that still holds when it turns out to be true.
The one rule
The client is an input, never an authority.
Every security-relevant decision — can this user do this, what does it cost, what are the limits, is this allowed right now — is made on the server, from server-held state. The client may also check, for a fast and pleasant experience, but its check is a convenience, and the server must behave identically whether or not the client bothered.
The test is direct: if someone crafted your app’s requests by hand, ignoring every check in your UI, would anything bad happen? If yes, that check was load-bearing, and load-bearing checks cannot live on a device you don’t control.
This is the same principle as orders being confirmed only by the server, applied to authorization instead of state. Client-side validation is user experience. Server-side validation is security. They look similar in code and are not the same thing at all.
Client hardening buys time, not prevention
Obfuscation, root and jailbreak detection, integrity checks, anti-debugging, encrypted local storage — these are worth shipping, and it is worth being precise about what they do.
They raise the cost and the time of an attack. They do not prevent it. Every one of them runs on the attacker’s hardware, and anything that runs there can be found and removed by someone patient enough.
The useful framing: hardening is a rate limiter on attacker effort. That’s valuable — most attacks are opportunistic, and cost is a real deterrent. It becomes dangerous only when someone starts treating it as a guarantee, and designs a server that trusts a client which “proved” it wasn’t tampered with. Then a single bypass unlocks everything, because the real control was never there.
So: layer the defenses, assume each layer fails, and make sure the thing behind them is a server that would have said no anyway.
There are no secrets in a binary
Anything shipped inside the app is readable. Strings, keys, endpoints, certificates, the clever thing you spread across three files and XOR’d — all recoverable by someone with a disassembler and an afternoon.
The consequences are concrete:
- No API secrets in the app. A shared secret embedded in a client is a public secret with extra steps. If a credential grants privilege, it cannot ship in the binary.
- Authenticate to get short-lived tokens. The app proves identity with something the user supplies, then holds a token that is narrow in scope and short in life. What leaks then is a token that expires, not a key that doesn’t.
- Sign on the server. If a request must be signed with a key, the key belongs where the server is.
- Use the platform keystore for tokens at rest, and still assume a rooted device can reach them. Keystore raises cost; it doesn’t make the device trustworthy.
Encrypting a secret in the binary just means the decryption key is also in the binary. That is not a criticism of the technique — it’s the definition of the situation.
The effective policy is the lowest layer
A rule worth generalizing beyond security: a system does what its lowest, most specific configuration says, not what its top-level declaration claims.
Platforms have layered policy — a manifest declaration, a configuration file, a per-domain override, a build-type variant. When they disagree, the more specific layer wins, and it is entirely possible for a reassuring top-level flag to be silently negated by something underneath it. A team can read the top line, conclude the system is safe, and be wrong for years, because nothing errors — the override is a legitimate feature.
The practice that follows: audit effective behavior, not declared intent. Read the final merged configuration for the build you actually ship. Verify with observation — inspect real traffic, confirm the runtime state — rather than reading the declaration and trusting it. “What did we declare” and “what does it do” are different questions, and only the second one has consequences.
Every entry point is an undocumented API
The attack surface isn’t only the network. Anything another app or a web page can invoke is an interface you are exposing, whether or not you documented it:
- Exported components and deep links. If another app can launch your screen with parameters, those parameters are attacker-controlled input. Validate the scheme, host, and path strictly against an allowlist; never let a link alone authorize a sensitive action; and require the session to be re-checked at the destination rather than assuming the caller was legitimate.
- Web-to-native bridges. A bridge exposes a set of callable functions to whatever page is loaded. That set is a public API with the privileges of your app. Restrict which origins can use it, keep the surface minimal, and validate every argument as hostile input. A bridge that can reach account or payment functionality should assume the page calling it is malicious.
- Custom URL schemes and inter-app messaging. Same reasoning: you cannot verify who is calling, so the call must not be the authorization.
Each of these is a place where a feature (“let partners deep-link into the app”) quietly becomes an interface, and interfaces need threat models.
Transport, and the pinning trade
Encrypt everything, and be sure of it — the failure mode here is not “we forgot TLS,” it’s a narrow exception for one legacy endpoint that quietly becomes a global allowance.
Certificate pinning is a genuine trade rather than a best practice. It defeats interception with an installed root, which is the realistic attack on a user’s device. It also means a certificate rotation you didn’t plan for can brick every installed copy of your app. If you pin: pin to an intermediate or include backup pins, ship a remote kill switch, and rehearse rotation before you need it. If you can’t operate that reliably, standard TLS validation done properly is better than pinning done fragilely.
Scaling this down
Most of the above is proportional to what an attacker gains. If your app holds public data and reversible actions, exotic hardening is the wrong place to spend.
What is not optional at any scale, because it’s where real incidents come from: authorization enforced on the server for every request, no secrets in the binary, TLS with no exceptions, short-lived scoped tokens, and treating every deep link and bridge call as hostile input.
The unglamorous list is the one that matters. Breaches are rarely elegant — they are a forgotten endpoint that trusts a client-supplied user id, a debug flag that shipped, an internal address left reachable. Get those right first, and add hardening because you have something worth the attacker’s time, not because it feels rigorous.
References
- J. H. Saltzer, M. D. Schroeder, “The Protection of Information in Computer Systems”, Proc. IEEE 63(9), 1975 — complete mediation (check every access at the authority) and open design (no security in a secret mechanism).
- OWASP, Mobile Application Security Verification Standard (MASVS) — the control groups this article compresses: storage, crypto, auth, network, platform interaction, resilience.
- OWASP, Pinning Cheat Sheet — when pinning helps, when it bricks you, and how to rotate.
- Android developers, Network security configuration — a concrete example of layered policy where the per-domain entry overrides the base config.
Previous: Modularity and migration · Back to the overview.