DEV Community

Cover image for React Native: Prevent Screenshots on Android & Blur on iOS
PEAKIQ
PEAKIQ

Posted on Originally published at peakiq.in

React Native: Prevent Screenshots on Android & Blur on iOS

Originally published on PEAKIQ

Source: https://www.peakiq.in/blog/prevent-screenshots-on-android-blur-on-ios



Sensitive screens leak in ways that have nothing to do with your backend. A user screenshots their balance to send to a friend, forgets about it, and it sits in their camera roll indefinitely. The phone gets lost or stolen. The screenshot ends up in a cloud backup nobody audited. None of that is a server breach, but it's still a data leak — and for regulated apps, still your liability.

The two platforms aren't symmetric here. Android can block a screenshot at the OS level, full stop — the capture fails, no image is produced. iOS gives apps no such API; the system reads the screen buffer directly and nothing running in the app can intercept that. The best available approach there is detection plus proactively blurring content the instant the app leaves the foreground, since that's the same moment both a screenshot and the App Switcher preview get taken.

We recently added this to a React Native app for a client in banking, where the account and payment screens needed to be genuinely screenshot-proof on Android and, at minimum, blurred out of the App Switcher and camera roll on iOS. Below is how the whole thing fits together — the Android native module, the iOS native module, the JS layer that ties them into one hook, and the navigator-level fix for the gap that isn't obvious until you test it on a real stack.

Step 1: Add the Android Native Module

This lives under android/app/src/main/java/.../screenshotprotection in the RN app's native Android project — nothing to add from the JS side yet.

  1. Create ScreenshotProtectionModule.kt. This is the module that toggles FLAG_SECURE on the current Activity's window.
  2. Create ScreenshotProtectionPackage.kt to register the module with React Native's package list.
  3. Add the package to MainApplication.kt's getPackages() override.
// ScreenshotProtectionModule.kt
package com.yourapp.screenshotprotection

import android.view.WindowManager
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.Promise

class ScreenshotProtectionModule(reactContext: ReactApplicationContext) :
    ReactContextBaseJavaModule(reactContext) {

    override fun getName() = "ScreenshotProtection"

    @ReactMethod
    fun enable(promise: Promise) {
        currentActivity?.runOnUiThread {
            currentActivity?.window?.setFlags(
                WindowManager.LayoutParams.FLAG_SECURE,
                WindowManager.LayoutParams.FLAG_SECURE
            )
            promise.resolve(true)
        } ?: promise.reject("NO_ACTIVITY", "No current activity")
    }

    @ReactMethod
    fun disable(promise: Promise) {
        currentActivity?.runOnUiThread {
            currentActivity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
            promise.resolve(true)
        } ?: promise.reject("NO_ACTIVITY", "No current activity")
    }
}
Enter fullscreen mode Exit fullscreen mode
// ScreenshotProtectionPackage.kt
package com.yourapp.screenshotprotection

import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager

class ScreenshotProtectionPackage : ReactPackage {
    override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
        listOf(ScreenshotProtectionModule(reactContext))

    override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
        emptyList()
}
Enter fullscreen mode Exit fullscreen mode

FLAG_SECURE is set on the whole Activity window, not a single React view — on Android this is really "protect this window for as long as the flag is on," which is why the JS layer in Step 4 has to be careful about exactly when it toggles.

Step 2: Add the iOS Native Module

iOS can't block the capture, but it can react to it. This module does two things: hides content the instant the app resigns active state (covers both an actual screenshot and the App Switcher preview), and emits an event to JS when a screenshot notification actually fires, so the app can log it or warn the user.

  1. In Xcode, add ScreenshotProtection.swift and ScreenshotProtection.m to the main app target — this is a small native module, not a widget extension, so no new target is needed.
  2. Bridge it to React Native with the RCT_EXTERN_MODULE macro.
// ScreenshotProtection.swift
import UIKit

@objc(ScreenshotProtection)
class ScreenshotProtection: RCTEventEmitter {

  private var overlay: UIView?

  override func supportedEvents() -> [String]! {
    return ["onScreenshotTaken"]
  }

  override init() {
    super.init()
    NotificationCenter.default.addObserver(
      self, selector: #selector(screenshotTaken),
      name: UIApplication.userDidTakeScreenshotNotification, object: nil
    )
    NotificationCenter.default.addObserver(
      self, selector: #selector(addBlur),
      name: UIApplication.willResignActiveNotification, object: nil
    )
    NotificationCenter.default.addObserver(
      self, selector: #selector(removeBlur),
      name: UIApplication.didBecomeActiveNotification, object: nil
    )
  }

  @objc func screenshotTaken() {
    sendEvent(withName: "onScreenshotTaken", body: nil)
  }

  @objc func addBlur() {
    guard let window = UIApplication.shared.windows.first, overlay == nil else { return }
    let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialDark))
    blur.frame = window.bounds
    blur.tag = 9999
    window.addSubview(blur)
    overlay = blur
  }

  @objc func removeBlur() {
    overlay?.removeFromSuperview()
    overlay = nil
  }

  @objc override static func requiresMainQueueSetup() -> Bool { true }
}
Enter fullscreen mode Exit fullscreen mode
// ScreenshotProtection.m
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>

@interface RCT_EXTERN_MODULE(ScreenshotProtection, RCTEventEmitter)
RCT_EXTERN_METHOD(supportedEvents)
@end
Enter fullscreen mode Exit fullscreen mode

The willResignActiveNotification / didBecomeActiveNotification pair fires for the App Switcher, incoming calls, Control Center — anything that suspends the app, not just screenshots. That's deliberate: it's the same moment iOS renders the App Switcher preview, so one hook covers both leaks.

Step 3: A Ref-Counted Manager, Not a Bare Toggle

A naive "enable on mount, disable on unmount" hook breaks the moment two protected screens overlap — a protected modal opening over a protected screen turns protection off the instant the modal closes, even though the screen underneath still needs it. Tying it to a specific navigation library's focus/blur events also couples this small utility to whatever nav stack happens to be in use today.

The more robust option is a singleton with reference counting, driven only by mount/unmount:

// SecureScreenManager.js
import { NativeModules, Platform } from 'react-native';

const { ScreenshotProtection } = NativeModules;

class SecureScreenManager {
  activeCount = 0;

  acquire() {
    this.activeCount++;
    if (Platform.OS === 'android' && this.activeCount === 1) {
      ScreenshotProtection.enable();
    }
  }

  release() {
    this.activeCount = Math.max(0, this.activeCount - 1);
    if (Platform.OS === 'android' && this.activeCount === 0) {
      ScreenshotProtection.disable();
    }
  }
}

export const secureScreenManager = new SecureScreenManager();
Enter fullscreen mode Exit fullscreen mode
// useScreenshotProtection.js
import { useEffect } from 'react';
import { NativeModules, NativeEventEmitter, Platform } from 'react-native';
import { secureScreenManager } from './SecureScreenManager';

const { ScreenshotProtection } = NativeModules;
const emitter =
  Platform.OS === 'ios' ? new NativeEventEmitter(ScreenshotProtection) : null;

export function useScreenshotProtection(onScreenshot) {
  useEffect(() => {
    secureScreenManager.acquire();

    // iOS: blur is handled natively; we just wire up the callback
    const sub = emitter?.addListener('onScreenshotTaken', () => {
      onScreenshot?.();
    });

    return () => {
      secureScreenManager.release();
      sub?.remove();
    };
  }, [onScreenshot]);
}
Enter fullscreen mode Exit fullscreen mode

The count only reaches zero once every protected component currently mounted has released, so nested or overlapping protected screens no longer disable each other prematurely.

Step 4: Close the Stack Navigator Gap

Reference counting fixes overlap, but it still assumes mount tracks visibility — which isn't true by default in a stack navigator. A screen pushed underneath another one usually stays mounted, so pushing an unprotected screen on top of a protected one doesn't release the count, and FLAG_SECURE stays on for the whole stack rather than the one screen that needed it.

Fix this at the navigator config, not in the hook, so it holds regardless of which navigation library the app uses:

  • React Navigation: set unmountOnBlur: true in the sensitive screen's options.
  • Expo Router: present the sensitive route as "modal" — modals unmount on dismiss.

Either way, "mounted" and "visible" stay the same thing for protected screens, which is what the manager in Step 3 actually needs to be correct.

Usage on a screen, once everything is wired up:

function AccountDetailsScreen() {
  useScreenshotProtection(() => {
    console.warn('Screenshot detected on AccountDetailsScreen');
  });

  return (
    <View>
      <Text>Account: •••• 4821</Text>
      <Text>Balance: $12,480.00</Text>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Real Lesson Here

None of the individual pieces here are complicated — a native flag, a notification observer, a ref count, a navigator option. What makes this easy to get subtly wrong is that "protected" and "visible" look like the same concept until you actually push a second screen on top and check whether the flag turned off. Test each transition independently: opening the screen directly, opening it under a modal, and navigating away from it inside a stack.

If you're adding this to your own app, this is the order that avoids backtracking:

  1. Add the Android native module and register the package.
  2. Add the iOS native module and bridge it with RCT_EXTERN_MODULE.
  3. Build the ref-counted manager instead of a bare mount/unmount toggle.
  4. Set unmountOnBlur (or present as a modal) on every sensitive screen.
  5. Test on physical devices only — the iOS simulator doesn't reproduce screenshot notifications or the App Switcher snapshot, and testing there alone gives false confidence.

What this doesn't cover: a second phone photographing the screen, or screen recording software outside iOS's own capture APIs — no software fix exists for either. Treat this as raising the cost of casual leaks, not as a hard security boundary, and keep the most sensitive fields (full card numbers, SSNs) off screen entirely where the flow allows it.

Top comments (0)