SwiftSecurityLoggingiOS

iOS security (part 4): logs and sensitive data leaks

We close the fundamentals block with the quietest leak of all. You stored tokens well, encrypted the files, got the secrets out of the binary, and the sensitive data still escapes through a channel almost nobody audits: the logs. A print of the token for debugging, a full response body in the console, a value marked public that stayed there for production. None of this breaks the app, so nobody notices, until someone reads those logs.

This is the security fundamentals series; the previous three parts covered Keychain, Data Protection and API keys.

Why logs are a real problem

On iOS logs don’t stay only in your Xcode. The unified logging system persists them, and they’re readable with the Console app by connecting the device to a Mac, or inside a sysdiagnose the user can generate and share. A crash report can also drag along the last thing that was logged. In other words, what you print can leave the device.

The first step is to stop using print for anything that could touch sensitive data. print writes to standard output with no privacy control, and in a release build it adds nothing but leak surface. The right tool is os.Logger.

os.Logger and privacy levels

Logger is Apple’s unified logging API. You create it once per subsystem and category:

import os

let logger = Logger(subsystem: "com.mydomain.app", category: "auth")

The interesting part is how it treats the values you interpolate. Every value has a privacy level, and the default behavior isn’t uniform:

  • The static text of the message is always visible.
  • Interpolated numbers and scalars are public by default.
  • Interpolated dynamic strings are redacted by default: in a release build they show as <private> when another process reads them.

That default for strings is a good safety net, but relying on it is a mistake. The reason is what happens when you debug:

// During development, to see the token in the console:
logger.debug("token received: \(token, privacy: .public)")

You marked the token public to see it while debugging, and that .public stayed in the commit. In production, that token is now readable from Console. This is the pattern behind most log leaks: not the default, but the .public someone added to debug and forgot to remove.

The rule I recommend is to mark privacy explicitly whenever a value could be sensitive, instead of trusting the default:

logger.info("user login \(userID, privacy: .private)")
logger.error("failed to refresh token: \(error.localizedDescription, privacy: .public)")

For credentials there’s a stricter level, .sensitive, meant for data that shouldn’t appear even in scenarios where .private would relax:

logger.log("auth response \(rawToken, privacy: .sensitive)")

What never to log

Beyond the mechanics, there’s a short list of things that simply shouldn’t go through a log, marked public or not:

  • Tokens, passwords, keys, and any credential.
  • Full request or response bodies. It’s convenient for debugging and it’s exactly where tokens and personal data travel.
  • The user’s personal data: email, phone, location, identifiers.

Extra care with analytics and crash-reporting SDKs: many capture logs and breadcrumbs automatically. If you log a sensitive value, it can end up sent to a third party without you deciding it consciously. It’s worth reviewing what each SDK you integrate captures.

Two neighboring leaks

Logs aren’t the only channel a sensitive value escapes through without you thinking about it. Two more that round out this block.

The clipboard

When you copy something to the general pasteboard, any other app can read it. For a sensitive value like a one-time code or a password, it’s worth marking it so it doesn’t sync to other devices and expires soon:

import UIKit

UIPasteboard.general.setItems(
    [[UIPasteboard.typeAutomatic: code]],
    options: [
        .localOnly: true,
        .expirationDate: Date().addingTimeInterval(60)
    ]
)

.localOnly keeps the value from traveling through the universal clipboard to your Mac or another device, and .expirationDate makes the system discard it after the time you set.

The app switcher snapshot

When the user sends your app to the background, iOS takes a screenshot to show it in the app switcher. If a sensitive value is visible at that moment, a balance, a card number, that snapshot is saved. The fix is to cover the screen before the system takes the photo, when the app goes inactive:

func sceneWillResignActive(_ scene: UIScene) {
    // Show an opaque view covering the sensitive content.
    overlayWindow?.isHidden = false
}

func sceneDidBecomeActive(_ scene: UIScene) {
    overlayWindow?.isHidden = true
}

What we’ve got

  • Logs leave the device via Console, sysdiagnose and crash reports; stop using print for sensitive data.
  • With os.Logger, mark privacy explicitly; the classic mistake is a debug .public left in release.
  • Never log tokens, credentials, full bodies or personal data, and review what your third-party SDKs capture.
  • Close the neighboring leaks: local, expiring clipboard, and cover the screen before the app switcher snapshot.

What’s next

That covers the user’s data on the device: where to store secrets, how to encrypt files, what to do with API keys, and how not to leak through logs. But today there’s one more channel for information to leave your machine, and you open it yourself every time you paste code into an AI assistant.

In part 5, which closes the fundamentals block, we look at how to code with Claude, ChatGPT and the rest without leaking data: what to share and what not, why a secret never goes in the prompt, and how to review the code the AI hands back.

Sources: Logger (Apple) · OSLogPrivacy (Apple) · UIPasteboard (Apple)