The login screen says the account does not exist. That single message, helpful and well intentioned, tells anyone who cares that a given email address is not registered, which means the same screen will confirm when one is. Error messages are an information channel, and mobile apps leak through them constantly because the messages are written for debugging and never rewritten for shipping.
Short answer
CWE-209 describes the weakness directly: generating an error message that contains sensitive information. In mobile apps it appears in three places, and only the first is usually considered. The message shown to the user, the message written to logs and crash reports, and the response the server returns to the client, which an attacker reads with a proxy regardless of what the app displays.
The three surfaces, and why the third matters most
What the user sees. This is the one teams review, and it is the least interesting to an attacker, because it is a summarised version of something more detailed.
What gets logged. A message written to a device log or attached to a crash report reaches whoever can read those, which is a wider set of people than expected and includes any service collecting log context.
What the server returned. This is the important one. An attacker does not read your user interface; they read the response. An app that displays a friendly generic message while the API returned a stack trace, a database error or a precise reason for failure has hidden the information from the user and not from anyone attacking it.
The mental model that helps is to treat every API response as public, because for anyone with a proxy on their own device, it is. Formatting a message nicely in the client does nothing to the data underneath.
Authentication messages, the classic case
Login and account recovery are where this matters most, because the information leaked is which accounts exist.
Distinguishing an unknown account from a wrong password lets someone enumerate your user base by trying addresses. That list has value on its own and is the input to credential stuffing against the accounts that do exist.
The same leak comes through side channels that are easy to miss. A registration form saying an address is already taken. A password reset that says no account was found. A response that takes noticeably longer when the account exists because the password hash was actually computed. Different HTTP status codes for the two cases.
The consistent handling is one message for both cases, one response shape, one status code, and comparable timing. For registration and password reset the standard approach is to send an email in both cases and tell the user to check their inbox, which moves the answer to a channel only the account owner can read.
The counter-argument is usability, and it is genuine: users do mistype addresses. The resolution is that the email they receive can say clearly that no account exists, which helps the legitimate user without answering the question for anyone else.
The other messages that say too much
Validation errors that reveal structure. A message naming a database column, a table, or an internal field name tells an attacker about your schema and about the technology underneath.
Framework error pages returned in production. A stack trace in an API response is a map of your dependencies, versions and file layout, and version information is exactly what someone needs to select a known exploit.
Authorization failures that distinguish outcomes. A response saying a record exists but you may not view it confirms the record exists, which is often the fact worth protecting. Returning the same not-found response for both cases removes the distinction.
Rate limit messages that report state. A message saying how many attempts remain helps an attacker tune their approach and helps a legitimate user very little.
Payment and third-party errors passed through verbatim. Upstream services return detailed messages intended for developers, and forwarding them to the client publishes information about your integration.
Getting the design right
The pattern that works separates what the user needs from what an engineer needs, and connects the two with a reference rather than with content.
The user gets a message that describes what to do next in plain language and nothing about why it failed internally.
The logs get the detail, subject to the discipline that logs should not contain personal data either.
The connection is an identifier. Return a reference in the response and show it to the user, then record the same reference alongside the full detail on the server. Support can then find the exact failure without the client ever receiving the diagnostic content, and a user reporting a problem has something specific to quote.
Do this at the boundary rather than at each call site. One place that converts an internal error into a client response is easier to reason about and much harder to bypass than a convention every developer has to remember.
And decide what the default is when nothing matches. An unhandled error should produce the generic response, not the underlying exception, which means the default path has to be the safe one rather than the fallback.
Testing for it
This is straightforward to check and rarely checked, because it requires reading responses rather than screens.
Put a proxy in front of a release build and drive the failure cases deliberately: wrong password, unknown account, expired session, malformed request, an identifier belonging to another user, a request with a required field removed.
Read the response body each time, not the app's display. You are looking for anything naming an internal component, any stack trace, any difference between the unknown-account and wrong-password cases, and any variation in status code that distinguishes them.
Compare timings on the authentication paths, since a consistent difference is a leak even when the messages match.
Then check the same failures in logs and crash reports, which is where the detail you removed from the response often reappears.
The OWASP MASTG documents this kind of verification, and the MASVS covers the requirement. An automated pass over the artifact such as PTKD.com helps with the adjacent build-level findings, including debuggable builds and verbose logging configuration that make the leakage worse, though the response content itself is a server behaviour you confirm by looking at traffic.
The usability objection, taken seriously
Every discussion of this reaches the same pushback: generic errors make the product worse for real users, who are the overwhelming majority.
That objection is correct in general and wrong in the specific cases that matter. Most errors in an app have nothing to do with security and should be as specific as possible: a field is required, the file is too large, the network is unavailable, the format is wrong. Making those generic helps nobody.
The narrow set that needs care is authentication, account recovery, authorization and anything revealing whether a record exists. That is a small fraction of the error surface, and being deliberately vague there costs very little usability because the user in those flows knows what they were trying to do.
The other resolution is to move specificity to a channel only the right person can read. Telling someone at the login screen that no account exists answers the question for everyone; sending an email saying so answers it only for the person who controls the address.
Applied that way, the product is precise almost everywhere and vague in the few places where precision is a disclosure, which is a much easier position to defend internally than a blanket policy.
Errors during development that ship
A recurring source of this problem is that the detailed behaviour is correct for development and nobody separates the two builds.
Framework debug modes returning full diagnostics are appropriate locally and are shipped when the configuration is environment-driven and an environment variable is wrong. The failure is silent, because the app works.
Verbose client error rendering added to speed up debugging tends to stay, particularly when it lives behind a flag that defaults to on.
Test endpoints that return internal state are the extreme case, and they persist because nothing in normal use touches them.
The control that helps is to make the safe behaviour the default and the verbose behaviour opt-in, rather than the reverse. A misconfiguration then produces a less helpful development experience instead of a production disclosure, which is the correct direction for a mistake to fail.
A ten-minute check
Drive six failures through a proxy against a release build and read the responses: wrong password, unknown account, expired token, malformed body, another user's identifier, and a request missing a required field.
Note whether any response names an internal component, whether the two account cases differ in any observable way, and whether the timings differ consistently.
That is ten minutes, it needs no tooling beyond a proxy, and it is the check that most reliably finds this class of problem before someone else does.
Where this overlaps with logging
The same information you removed from the response often survives in the log, and the two problems are usually fixed at different times by different people.
An error handler that returns a generic response and logs the full exception is doing the right thing, provided the log itself is handled correctly. The failure is when that log is attached to a crash report, uploaded to a third party, or written somewhere readable on the device.
So the sequence that works is to fix the response first, because that is the surface an attacker reads directly, then apply the same scrutiny to what the handler writes locally.
Treat them as one review rather than two, since the error handler is the single place where both decisions are made and reviewing it once covers both surfaces.
The status code question
Response codes leak as reliably as message bodies, and they are easier to overlook because nobody looks at them in a user interface.
Returning one code when an account exists and a different one when it does not is the same disclosure as two different messages, delivered more discreetly. The same applies to authorization: distinguishing forbidden from not-found tells the caller which records exist.
The consistent approach is to pick one response for the whole class of outcome and use it everywhere the distinction would be a disclosure. For authorization on a record the caller cannot see, that generally means answering as though the record does not exist.
Check this in the same proxy pass as the message bodies, because a correctly generic message paired with a distinguishing status code has fixed nothing.
What to take away
Error messages leak through three surfaces, and the API response matters more than the screen because that is what an attacker reads.
Authentication and recovery flows should not distinguish an unknown account from a wrong password, in message, status code or timing, and the specific answer belongs in an email only the account owner can read.
Do not forward framework errors, upstream service messages or validation errors that name internal structure, and make the safe response the default so a misconfiguration degrades your debugging rather than your users' privacy.
Check status codes alongside message bodies, since a generic message paired with a distinguishing code has fixed nothing.
Give the user an action and a reference, keep the detail on the server, and convert errors at one boundary so the safe response is the default rather than something every developer has to remember at each call site.
And verify by driving failures through a proxy and reading the responses, since the app's display tells you nothing about what was actually sent.
Keep the scope narrow while you are at it. Most errors in an app should be as specific as possible, and only authentication, recovery, authorization and existence checks need deliberate vagueness, which is a small share of the error surface and an easy position to defend internally.




