Technical guide

How to handle runtime threat signals

Connect runtime threat signals to safe app behaviour: route them through one policy decision, respect the app lifecycle and enforce on the server.

  • One decision point

    Route every signal through a single policy decision before the interface changes or the server authorises an operation.

  • Respect the lifecycle

    Signals arrive while screens rotate, scenes move to the background and processes die. Hold state outside the screen and update UI on the main thread.

  • Enforce on the server

    A disabled button is a hint. The backend decides whether the sensitive request goes through, and repeats never repeat the action.

Handle a runtime threat signal in three steps. Turn the observation into one policy decision for the current user flow. Apply any visible change on the main thread, in a component that outlives the current screen. Then let your backend decide whether the sensitive request proceeds, so a retry or a modified client cannot repeat or skip the check. Otukan provides runtime threat signals and applies the response policy you set for each protected flow. This guide covers the code around that: lifecycle, threading, idempotency and session binding, scoped separately for Android and iOS.

For the event contract, data handling and analytics side, see runtime threat signals best practices.

Map each signal to one decision

Write an internal mapping from each documented observation to a business question. May this session start a transfer? Change account recovery details? Only view non-sensitive content? Keep the raw observation and the answer apart. The same observation can justify different actions in different flows.

QuestionWhat to settleCheck
MeaningWhich condition the signal representsA reviewer can tell observation from interpretation
ScopePlatform, framework and release that produce itAndroid and iOS have separate entries
RepetitionWhether it can recur, arrive late or out of orderRepeated input never repeats a sensitive action
AvailabilityHow unsupported, unavailable and failed checks lookNone of them becomes a clean result
DataWhich attributes the decision needsLogs exclude credentials and user content

Route every observation to a single decision point, such as one policy object or service in your app. Define how an existing restriction changes when stronger evidence arrives, and what evidence allows recovery. Two screens that each react on their own will disagree sooner or later.

Android: lifecycle-aware handling

Hold state outside the activity

An activity is destroyed and recreated on configuration changes such as rotation, and the system can kill a background process to reclaim memory. A receiver tied to one activity therefore has a shorter life than the decision it triggers.

  • Keep the current decision in a ViewModel exposed as a StateFlow or LiveData. It survives configuration changes.
  • Use SavedStateHandle for the small part of state that must survive process death, such as “this flow is restricted”. Re-evaluate on restore rather than trusting old evidence.
  • For app-wide state, observe ProcessLifecycleOwner to know when the whole app moves to the foreground or background.

Collect on the main thread, only while visible

Views must be touched from the main thread. Collect the decision with lifecycleScope and repeatOnLifecycle(Lifecycle.State.STARTED), or collectAsStateWithLifecycle() in Jetpack Compose. Collection then runs on Dispatchers.Main, stops when the screen is hidden and restarts with the latest state when it returns. A restriction that arrived in the background is applied when the user comes back, not lost.

Never block the main thread while waiting for a remote decision. Do network and disk work on Dispatchers.IO and post only the result back. A blocked main thread produces an “Application Not Responding” dialog, which looks like a crash to the user.

iOS: main actor and scene lifecycle

Update the interface on the main actor

UIKit and SwiftUI views must be updated on the main thread. With Swift concurrency, mark the type that publishes the decision @MainActor, or hop to it with await MainActor.run { … } before changing UI state. In older code, dispatch to DispatchQueue.main. Keep evaluation and network calls off the main actor and publish only the outcome.

An ObservableObject or @Observable model owned above the current view keeps the decision when a view is rebuilt or a sheet is dismissed.

Follow the scene and app lifecycle

Since iOS 13, an app can have several scenes, and each moves through foreground and background on its own. React in sceneWillEnterForeground(_:) and sceneDidEnterBackground(_:) on your UISceneDelegate, or watch the scenePhase environment value in SwiftUI (.active, .inactive, .background).

  • Re-check pending decisions when a scene becomes active. Evidence gathered before suspension may be stale.
  • Hide sensitive content when the scene leaves .active, so the app switcher snapshot does not show it.
  • Assume the system can terminate a suspended app without notice. Persist only what the restored flow needs and re-evaluate on launch.

Bind decisions to the session and make them idempotent

Session binding

Associate each observation with the current app session and the attempted operation. On logout, cancel pending UI work and clear queued decisions so they never cross into the next user’s session. On account switch, start from a fresh state. An old result must not attach itself to whoever signs in next.

Idempotency

Serialise changes to the state that controls a sensitive action. Suppose a user taps twice while a signal arrives: neither the repeated observation nor a network retry should submit the payment again. Send an idempotency key with each sensitive request and let the server return the original outcome for a repeat. On reconnect, discard decisions that no longer apply to the operation. An offline state is a transport condition, not evidence of a hostile device.

Server enforcement

The backend verifies the evidence it requires and authorises the request. A local warning, a disabled button or a client-supplied “passed” flag never replaces that check. Bind platform evidence such as Play Integrity on Android or App Attest on iOS to the request, as described in how to verify mobile app attestation.

Give the user a way forward

Define a recovery route before choosing a restrictive action. Keep support reachable where your risk policy allows. Explain which action the user cannot complete, without exposing detection internals. Do not ask users to turn off security or accessibility tools as a routine workaround.

Test handling and detection separately

Start with a synthetic input delivered to a test seam in a debug build. This isolates your handling from detection. Keep the seam out of release builds. Then run an authorised detector test in a controlled lab; the two answer different questions.

Test caseExpected result to write down first
Authorised positive detector caseDocumented signal and the intended flow restriction
Same journey on a legitimate deviceNormal progress, no restriction
Duplicate or delayed inputNo duplicate action, no decision applied to a new session
Rotation, backgrounding or process deathConsistent state, no update to a dead screen
Lost connection or verifier outageNamed fallback, bounded retry, clear recovery
Malformed or unsupported inputExplicit handling path; absence never means safe

Repeat install, update, sign-in and the sensitive flow on each platform in scope, with release builds. Record device and OS versions, app and protection revisions, input, expected and actual response, and reviewer.

  • Android and iOS observations and expected outcomes are recorded separately.
  • UI changes happen on the main thread and survive rotation, backgrounding and restore.
  • Logout, retry and recovery cannot repeat a transaction.
  • The server enforces every sensitive operation on its own evidence.

For product scope, see runtime self-protection, runtime threat signals and threat monitoring.

Further reading

Common questions

  • Where should an Android app hold the current threat decision?

    Outside the activity, which is destroyed and recreated on configuration changes such as rotation. Keep the decision in a ViewModel exposed as a StateFlow or LiveData, use SavedStateHandle for the small part that must survive process death, and observe ProcessLifecycleOwner for app-wide foreground and background state.

  • How does a restriction reach the screen on Android without being lost?

    Collect the decision with lifecycleScope and repeatOnLifecycle(Lifecycle.State.STARTED), or collectAsStateWithLifecycle() in Jetpack Compose. Collection then runs on Dispatchers.Main, stops when the screen is hidden and restarts with the latest state, so a restriction that arrived in the background is applied when the user returns.

  • How should an iOS app apply a threat decision to the interface?

    On the main actor. Mark the type that publishes the decision @MainActor, or hop to it with await MainActor.run { … } before changing UI state; older code dispatches to DispatchQueue.main. Keep evaluation and network calls off the main actor, and own the model above the current view so rebuilds keep the decision.

  • Can a double tap or retry submit a payment twice after a signal?

    It should not. Serialise changes to the state that controls the sensitive action, send an idempotency key with each sensitive request and let the server return the original outcome for a repeat. On logout, clear queued decisions so they never cross into the next user's session.

  • Is a client-side check enough to block a sensitive request?

    No. The backend verifies the evidence it requires and authorises the request; a local warning, a disabled button or a client-supplied passed flag never replaces that check. Bind platform evidence to the request, such as Play Integrity on Android or App Attest on iOS.

Connect signals to safe decisions

Tell us your platform, framework and the user flow that needs a runtime response. We never request binaries, signing keys, credentials or production data.

Or request a technical demo