← All posts
Deep dive #ios#swiftui#uikit#privacy#security

iOS has no FLAG_SECURE — host the app inside a secure text field's layer

Android blocks screenshots with one window flag; iOS has no equivalent. What it does have is a secure text field whose characters never appear in screenshots, recordings or mirroring. Lift that field's internal canvas out, host your whole UI inside it, and toggle isSecureTextEntry per screen.

On Android, keeping a screen out of screenshots is one line: set FLAG_SECURE on the window. iOS has no public equivalent. You can detect a screenshot after the fact (userDidTakeScreenshotNotification) or notice a recording (UIScreen.isCaptured), but neither stops the pixels from being captured.

There is one surface iOS does blank in every capture: the characters of a secure text field. Screenshots, screen recordings and AirPlay mirroring all show it empty. The trick is to put your whole app inside that surface.

The mechanism

A UITextField with isSecureTextEntry = true draws its text into an internal canvas subview, and that canvas’s layer carries the “do not capture” flag. So:

  1. Make a UITextField that never becomes first responder (otherwise a keyboard rises over your app).
  2. Set isSecureTextEntry = true, call layoutIfNeeded(), and find the canvas subview.
  3. Remove the canvas from the field, add it to your root view controller’s view, pin it edge to edge, and set isUserInteractionEnabled = true.
  4. Add your UIHostingController’s view inside the canvas.

The field keeps its reference to the canvas, so toggling field.isSecureTextEntry later switches the shielding on and off. You never re-create the hosted tree, so a playing video or a scroll position survives moving between shielded and unshielded screens.

override func loadView() {
    view = UIView()
    addChild(host)
    let target: UIView
    if let canvas = Self.secureCanvas(of: field) {
        canvas.removeFromSuperview()
        canvas.isUserInteractionEnabled = true
        view.addSubview(canvas); pin(canvas, to: view)
        target = canvas
    } else {
        logger.fault("secure canvas not found: screens are NOT shielded")
        target = view
    }
    target.addSubview(host.view); pin(host.view, to: target)
    host.didMove(toParent: self)
}

func setShielded(_ on: Bool) { field.isSecureTextEntry = on }

The parts that bite

It is not a public API, so make the failure loud. The behaviour (“a secure field’s text is never captured”) is documented. The view you reach it through is not. Find the canvas by its type name (it was _UITextLayoutCanvasView when I measured it). Do not fall back to “the first subview”: if a future iOS renames the canvas, that fallback hosts your app in an ordinary view, completely unshielded. Your tests would still pass and nothing would log anything. Look it up by name, log a fault when it’s missing, and have a unit test assert the canvas is found on the OS under test. Then an iOS change turns a test red instead of quietly shipping capturable screens.

Don’t crash when it’s missing. Crashing every user at launch because a privacy layer couldn’t be found is the worse failure. Run unshielded, log at fault level, and let the test catch it.

The app-switcher snapshot is a separate problem. The secure layer covers screenshots and recordings. To make sure the multitasking thumbnail holds nothing sensitive, overlay an opaque cover whenever scenePhase != .active on a shielded screen.

Tie it to the build configuration, not to a flag someone has to remember. We wanted screenshots during development and the shield in release, so the shield decision sits under #if DEBUG. A release archive can’t ship without it. A UI test proves the mechanism in a debug build through a debug-only launch argument that forces it on.