What sensitive data commonly leaks into logs and crash reports, and how do you prevent it?
The bug that leaks a password isn't usually the login screen — it's the debug log line, the HTTP interceptor, and the crash reporter breadcrumb that nobody remembered to turn off before shipping.
LEARN BY DOINGPut your intuition to work3 steps
What sensitive data commonly leaks into logs and crash reports, and how do you prevent it?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Where secrets accidentally end up
Most data leaks into logs and crash reports come from tools that were never meant to be adversarial — they're debugging aids left switched on. The recurring offenders: a stray Log.d/print with a password or auth token during a login bug hunt that never gets deleted; an HTTP logging interceptor set to log full request/response bodies, which in production quietly writes every Authorization header and every PII field to the device log; analytics events that pass a whole user object instead of an explicit allowlist of fields, so a toString() dump ends up including email or ssn; and crash reporters whose breadcrumbs capture recent network calls or local variables by default, occasionally alongside a "custom key" a developer set to a real token "to help debug the crash."
// ANTI-PATTERNLog.d("LoginActivity", "Logging in with password=$password, token=$authToken")
// ANTI-PATTERNprint("Received access token: \(response.accessToken)")
#Debug logging vs. release logging
The fix isn't "delete every log statement" — it's making sure debug-level and sensitive logs categorically cannot exist in a release build, rather than trusting every future PR to remember to delete one line manually. On Android, ProGuard/R8 can strip logging calls entirely via -assumenosideeffects, and a thin wrapper keeps that intent obvious in code review:
object SafeLog {fun d(tag: String, message: String) {if (BuildConfig.DEBUG) Log.d(tag, message)}}fun redactCard(number: String): String ="**** **** **** ${number.takeLast(4)}"
# proguard-rules.pro-assumenosideeffects class android.util.Log {public static int d(...);public static int v(...);}
The same discipline applies on iOS: gate anything beyond a coarse-grained, non-sensitive log behind #if DEBUG, and remember that NSLog and even print output has historically been readable from outside the app (the system log, Console.app, sysdiagnose bundles attached to support tickets) — treat "goes to the console" as "goes somewhere you don't control."
#HTTP logging interceptors are a common, avoidable offender
A logging interceptor set to the most verbose level is one of the highest-yield leak sources precisely because it's not obviously "a log statement" — it's infrastructure that developers configure once and forget. At BODY level, OkHttp's HttpLoggingInterceptor writes the full request and response, including the Authorization header and any PII in the JSON payload, straight to logcat.
val logging = HttpLoggingInterceptor().apply {level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODYelse HttpLoggingInterceptor.Level.NONEredactHeader("Authorization")redactHeader("Cookie")}val client = OkHttpClient.Builder().addInterceptor(logging).build()
#Crash reporters have their own blast radius
Crashlytics, Sentry, and similar tools are genuinely useful and genuinely capable of leaking exactly the data you were trying to protect, because their entire job is to capture as much context as possible around a failure — recent log lines, breadcrumbs, sometimes local variables in native crashes. The two mistakes that show up over and over: putting a real secret into a "custom key" meant to help debug a specific crash, and shipping a crash SDK's default breadcrumb/network-capture behavior without reviewing what it actually records.
// ANTI-PATTERN — this ships the token to your crash dashboard, in plaintext, for every crashFirebaseCrashlytics.getInstance().setCustomKey("auth_token", authToken)// Better: identify the situation, not the secretFirebaseCrashlytics.getInstance().setCustomKey("user_tier", user.tier)
// Scrub PII before Sentry ever sends the eventSentryAndroid.init(context) { options ->options.beforeSend = SentryOptions.BeforeSendCallback { event, _ ->event.request?.cookies = nullevent.contexts.remove("user_pii")event}}
#Screens, screenshots, and the app switcher
Two leak paths are easy to forget because they're not logs at all: the OS-level screenshot taken automatically for the app switcher, and screen recording by another app or accessibility service. On Android, FLAG_SECURE on a sensitive screen's window blocks both, and replaces the recents-list thumbnail with a blank one. On iOS there's no direct equivalent flag, so the common pattern is covering the content with a blur or placeholder view the moment the app resigns active/enters background, and removing it on return to foreground.
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
func applicationDidEnterBackground(_ application: UIApplication) {let blur = UIVisualEffectView(effect: UIBlurEffect(style: .dark))blur.frame = window?.bounds ?? .zeroblur.tag = 999window?.addSubview(blur)}func applicationWillEnterForeground(_ application: UIApplication) {window?.viewWithTag(999)?.removeFromSuperview()}