Skip to main content

React Native

This page provides guidance on integrating the Approov service into a React Native mobile app.

USAGE.md file from the approov-service-react-native repository:

Usage

This document describes the features and functionality of the Approov Service for React Native. It provides details on how to interact with the service layer and customize its behavior to suit your application's needs.

Empty Config Initialization​

You can initialize the ApproovService with an empty configuration string if you want the React Native environment to behave as a standard set of networking APIs (like fetch or XMLHttpRequest) without active Approov protection. This is useful for remote activation of Approov or when you need a bypass state (e.g., for specific deployment environments).

// Initialize with an empty string to operate as a standard network client
ApproovService.initialize("");

When initialized this way, all network requests made through the native React Native networking module will proceed without Approov token injection, message signing, secure string substitution, or dynamic pinning. You can enable full Approov protection later in the application lifecycle by calling ApproovService.initialize(config) with a valid configuration string.

⚠️ Critical: Initialization Timing & Network Requests​

The Approov SDK must fully complete its native initialization sequence and receive your configuration string before it can protect your network traffic.

If your application executes a fetch() or axios request before ApproovService.initialize() has successfully completed, that specific request may proceed without an Approov token and without a reliable pinning guarantee. On iOS, the passive +load probe may still log evidence that startup networking primitives already existed, but the request itself is not recoverable after it has left the device.

You must await useApproov() / approovReady (or await ApproovService.initialize(...)) before making protected fetch() calls. A request that leaves the device before initialization completes may be forwarded without an Approov token. If you intentionally initialize with "", that request path is treated as an explicit no-Approov bootstrap mode: requests will proceed without Approov protection until you later call ApproovService.initialize("<valid-config>"). This empty-first then valid-config-later flow is supported for advanced service-layer integrations, but switching directly between different non-empty config strings is still rejected. The extended session metadata ledger exposed by getSessionDiagnostics() is intended only for development and troubleshooting startup/interception issues. On iOS, turn it off for production with ApproovService.setSessionMetadataCollectionEnabled(false) as part of startup. Android currently does not persist an equivalent session ledger; this toggle is retained there only for API parity. The iOS ledger now has an internal safety cap of about 1 MB, but that cap is only a backstop. It is not a reason to leave session metadata collection enabled in release builds.

To guarantee all requests are protected, you must strictly gate your network activity behind the initialization state. We provide two ways to do this:

If you are using the <ApproovProvider> wrapper at the root of your app, the provider automatically handles initialization. You can use the useApproov() hook in your child components to wait until Approov is fully ready before fetching data or rendering.

import React, { useEffect, useState } from 'react';
import { ActivityIndicator, View } from 'react-native';
import { useApproov } from '@approov/approov-service-react-native';

const MainScreen = () => {
// Extract the async initialization state
const { approovReady, approovError } = useApproov();
const [data, setData] = useState(null);

useEffect(() => {
// Only fetch your critical API data once Approov is definitively initialized
if (approovReady && !approovError) {
fetch("https://api.example.com/data")
.then(res => res.json())
.then(setData);
}
}, [approovReady, approovError]);

if (approovError) return <Text>Approov failed to start</Text>;
if (!approovReady) return <ActivityIndicator size="large" />;

return <View> /* Render your data */ </View>;
}

Option 2: Awaiting the Promise (For Headless/Service logic)​

If you are manually initializing Approov outside of the React component tree (e.g., in a background service or a dedicated API wrapper module), ApproovService.initialize() returns a Promise. You should await it before making any subsequent network calls.

import { Platform } from 'react-native';
import { ApproovService } from '@approov/approov-service-react-native';

async function bootstrapAppAndFetch() {
try {
// 1. Wait for Approov to finish initializing its Native modules
await ApproovService.initialize("<your-config-string>");

// 2. Capture startup diagnostics metadata.
// On Android this is the pre-flight health check. On iOS this is a baseline.
const diagnostics = await ApproovService.getPinningDiagnostics();
console.log("Approov startup diagnostics:", diagnostics);

// 3. CRITICAL ON ANDROID: heal the shared OkHttpClient before the first request.
if (Platform.OS === "android" &&
diagnostics &&
(!diagnostics.isInterceptorPresent || !diagnostics.isPinnerPresent)) {
console.warn("Approov hooks are missing from the active OkHttpClient. Healing...");
await ApproovService.updateClientFactory(true);
}

// 4. Make the first protected request
const response = await fetch("https://api.example.com/secure-data");

// 5. On iOS, inspect session metadata immediately after the first protected request.
const afterFirstRequest = await ApproovService.getPinningDiagnostics();
console.log("Approov post-request diagnostics:", afterFirstRequest);
} catch (error) {
console.error("Failed to initialize Approov", error);
}
}

During development, staging, and the first production rollout of a new app build, you should capture ApproovService.getPinningDiagnostics() metadata twice:

  1. Immediately after initialization and before the first protected request
  2. Immediately after the first protected request

This gives you visibility into two different failure classes:

  1. Android factory override problems before the first request
  2. iOS session registration, delegate, and pinning-verification problems after the first request

If you are also using ApproovService.getSessionDiagnostics(), treat it as a temporary troubleshooting aid. It is most useful while validating a new integration, tracking down third-party NSURLSessionDelegate conflicts, or confirming that a startup request escaped before initialization completed. After that work is done, disable the extended ledger in production:

ApproovService.setSessionMetadataCollectionEnabled(false);

Do not leave getSessionDiagnostics() metadata collection enabled in production on iOS. The ledger is capped internally to about 1 MB to prevent unbounded growth, but it still stores extra diagnostic state that should only exist during development, staging, or short-lived rollout debugging. On Android this toggle is currently a no-op for ledger storage.

import { Platform } from 'react-native';
import { ApproovService } from '@approov/approov-service-react-native';

async function captureApproovDiagnostics(stage) {
const diagnostics = await ApproovService.getPinningDiagnostics();
console.log(`[Approov ${stage}]`, JSON.stringify(diagnostics, null, 2));

if (Platform.OS === 'android' &&
(!diagnostics.isInterceptorPresent || !diagnostics.isPinnerPresent)) {
await ApproovService.updateClientFactory(true);
}

return diagnostics;
}

await ApproovService.initialize("<your-config-string>");
await captureApproovDiagnostics("pre-first-fetch");

await fetch("https://api.example.com/secure-data");

await captureApproovDiagnostics("post-first-fetch");

Interpret the metadata as follows:

  • Android: isInterceptorPresent and isPinnerPresent must both be true before the first protected request. If not, another SDK likely replaced the shared OkHttpClientFactory.
  • iOS: a pre-request baseline with zero sessions is normal. The important signal is the metadata after the first protected request. If sessionsWithoutPinning is greater than 0, then Approov observed requests on intercepted sessions but pinning was not verified for those sessions.
  • iOS limitation: getPinningDiagnostics() only knows about sessions that actually reached the Approov interceptor. A completely bypassed first request may still require inspection of native ApproovService logs for IMP CONFLICT, SKIPPING session creation, or skipping ... unregistered session.

You should send this metadata to your observability platform during early adoption. It is one of the fastest ways to spot request mutation or pinning regressions introduced by new monitoring SDKs, networking wrappers, or startup ordering changes.

Pre-Flight Native Network Health Check (Android)​

React Native heavily optimizes Android networking by using a single, globally shared OkHttpClient. If another observability or analytics SDK (e.g., Datadog, New Relic) initializes after Approov does, that SDK might programmatically overwrite the networking factory, completely stripping away the Approov protection without throwing an error.

For robust Android deployments, it is highly recommended to perform the ApproovService.getPinningDiagnostics() health-check exactly once, just before you execute the very first API request of the application session, as demonstrated in Option 2 above. If the interceptor is missing, calling ApproovService.updateClientFactory(true) will instantly heal the active client, preserving the foreign SDK's hooks while adding the Approov protection back on top.

Message Signing​

It is possible to sign HTTP requests using Approov to ensure message integrity and authenticity. There are two types of message signing available:

  1. Installation Message Signing: Uses an installation-specific key (held in the device's Secure Enclave/TEE) to sign requests. This provides strong non-repudiation as the signing key never leaves the device and is unique to that specific installation.
  2. Account Message Signing: Uses a shared account-specific secret key (HMAC-SHA256) to sign requests. This key is delivered to the SDK only upon successful attestation.

Advantages of Message Signing:

  • Integrity: Ensures that the request parameters (headers, body, URL) have not been tampered with during transit.
  • Authenticity: Proves that the request originated from a genuine, attested application instance.

Message signing is opt-in. The default ApproovService configuration forwards requests unsigned. To enable it, install a policy mutator with setServiceMutatorType(mask, { sign: true }), or install ApproovDefaultMessageSigning natively. Once enabled, and provided your Approov account has message signing configured, the message signature headers are added to your outbound requests automatically.

Enabling message signing from JavaScript (no native code)​

You do not need any native code to turn message signing on. setServiceMutatorType installs a policy mutator that signs, and options.signature chooses which signature type it produces: 'install' (the default, the per-installation Secure Enclave/TEE key) or 'account' (the shared account HMAC-SHA256 key). Apply it after initialize has completed (see the re-initialization note in the Approov Service Mutator section, the mutator is reset on every successful init).

import { ApproovService } from '@approov/approov-service-react-native';

await ApproovService.initialize('<config>');

// Enable installation message signing (the default signature type).
// Signing is on unless you pass { sign: false }, so a named preset already signs:
await ApproovService.setServiceMutatorType(ApproovService.MutatorPreset.PROCEED_IF_UNAVAILABLE);

// Or be explicit about the mask and the signature type:
const RD = ApproovService.ReturnDecision;
await ApproovService.setServiceMutatorType(RD.NO_APPROOV_SERVICE, { sign: true, signature: 'install' });

// Account message signing instead of the install key:
await ApproovService.setServiceMutatorType(RD.NO_APPROOV_SERVICE, { signature: 'account' });

// Turn signing back off (forward unsigned), or restore the unsigned default mutator:
await ApproovService.setServiceMutatorType(RD.NO_APPROOV_SERVICE, { sign: false });
await ApproovService.setServiceMutatorType(ApproovService.MutatorPreset.DEFAULT);

signature is ignored when sign is false and for MutatorPreset.DEFAULT (which restores the unsigned default mutator). The behaviour is identical on Android and iOS.

For the failure-status mask, the security warning on masking MITM_DETECTED/REJECTED, and how a proceeding request reports its status to the backend, see the Approov Service Mutator section below. Reach for a native ApproovServiceMutator (the platform examples further down) only when you need fully custom per-request logic.

Token Binding​

Token Binding allows you to bind the Approov token to a specific piece of data, such as an OAuth token or a user session identifier. This adds an extra layer of security by ensuring that the Approov token can only be used in conjunction with the bound data. The ApproovService calculates a hash of the binding data locally and includes this hash in the Approov token claims. It is important to note that the actual binding data is never sent to the Approov cloud service; only the hash is transmitted.

To set up token binding, specify a header name. The value of this header in your requests will be used for the binding.

Example: Bind to Authorization Header​

// Bind the Approov token to the Authorization header (e.g., for OAuth)
ApproovService.setBindingHeader("Authorization");

If the value of the binding header changes (e.g., the user logs in and gets a new OAuth token), the SDK automatically invalidates the current Approov token and fetches a new one with the updated binding on the next request.

Use Approov Status as Token​

In some cases, you might want to send the Approov fetch status (e.g., NO_NETWORK, MITM_DETECTED) to your backend when an actual token cannot be obtained. This allows your backend to distinguish between different failure reasons even when the Approov-Token would otherwise be empty or missing.

To enable this feature:

ApproovService.setUseApproovStatusIfNoToken(true);

When enabled, the Approov-Token header is populated with the status string (with the configured prefix) only when the mutator allows the request to proceed without a token (for example, default NO_APPROOV_SERVICE handling, or custom mutator overrides). If the mutator blocks the request, no outbound request is made.

⚠️ The status string differs between platforms. Android emits the SDK enum name, iOS emits the SDK's human-readable description:

ConditionAndroidiOS
MITM detected on the Approov channelMITM_DETECTEDMITM detected

Android uses TokenFetchStatus.toString() (the enum name, matching the okhttp and retrofit layers); iOS uses stringFromApproovTokenFetchStatus, which returns prose. A backend that matches the Android spelling exactly will therefore not match traffic from iOS. Until this is normalised, either match case-insensitively ignoring separators, or map both forms at the backend. Verified on device/simulator: an iOS request that proceeded under a MITM_DETECTED policy arrived with approov-token: MITM detected.

Using fetchWithApproov (Alternative to Swizzling)​

By default, the @approov/approov-service-react-native package uses swizzling (iOS) and OkHttpClient factory overrides (Android) to automatically intercept all React Native fetch() calls and inject Approov tokens.

However, in some complex applications, other observability SDKs (like New Relic, Datadog, or Firebase) might aggressively hook into the same networking layer in a way that conflicts with or bypasses Approov's security checks.

If you encounter such conflicts, you can use the ApproovService.fetchWithApproov API. It is a secure fetch-compatible API for sensitive calls, executed natively on isolated, protected HTTP clients that cannot be interfered with by other React Native modules.

import { ApproovService } from '@approov/approov-service-react-native';

// Use it exactly like you would use standard `fetch`
const response = await ApproovService.fetchWithApproov('https://api.yourdomain.com/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ key: 'value' })
});

const data = await response.json();

Limitations of fetchWithApproov​

The fetchWithApproov wrapper is designed for standard JSON and text API payloads. Because it bypasses React Native's full NetworkingModule stack, it does not support:

  • Multipart form uploads (FormData, including file/URI-backed form parts)
  • Binary request bodies (Blob or ArrayBuffer)
  • Request cancellation via AbortController
  • React Native networking event model features (such as upload/download progress hooks)
  • Streaming enormous files (the entire response is buffered into memory)

In practice, pass string bodies (JSON.stringify(...) or plain text) and use this API for security-critical REST calls rather than complex form/file transfer flows.

For critical security and authentication calls, fetchWithApproov provides guaranteed protection.

Default Behavior​

By default, the ApproovService processes requests based on the attestation status returned by the platform SDK. The SDK provides a cryptographically signed JWT token as proof of attestation. Requesting this token typically returns immediately from a local cache; however, a network connection to the Approov cloud is required on app launch or when the token is nearing expiration. The SDK can only determine whether a token was obtained — it cannot verify the token's validity, as that check is performed by your backend.

The behavior described here applies to the Approov token fetch that occurs inside the network interceptor for every protected request. For the complete set of fetch statuses and their meanings, see the official documentation: Approov Token Fetch Results. The equivalent behavior table for the approov-service-okhttp layer is documented in Default Behavior — approov-service-okhttp.

The table below covers only the statuses relevant to the network interceptor path:

Approov Fetch StatusActionResult
SuccessProceedThe request is sent with the Approov-Token header populated with the signed JWT.
No Network / Poor Network / MITM DetectedFail (temporary)fetch() rejects with TypeError: Network request failed. The request should be retried.
No Approov ServiceProceedThe request is sent with an empty Approov-Token header (or carries the fetch status string if setUseApproovStatusIfNoToken(true) is enabled).
Unknown URL / Unprotected URLProceed (unmodified)The request is forwarded as-is with no Approov-Token header added. The URL is not registered under Approov protection.

Note: REJECTION statuses are only relevant to explicit precheck() calls, not to the interceptor path.


Approov Service Mutator

The ApproovServiceMutator allows you to customize the behavior of the Approov network layer at key points in the request lifecycle.

Important Architecture Note: Because the React Native bridge does not support passing complex executable code or classes, a fully custom mutator must be implemented and registered directly in your platform-native code (Java/Kotlin for Android, Swift/Objective-C for iOS). For the common case — choosing which failure statuses proceed or block, with or without message signing — you can instead select a policy entirely from JavaScript with no native code; see Selecting a request policy from JavaScript (no native code) below.

Selecting a request policy from JavaScript (no native code)​

For the common case — choosing which Approov failure statuses are allowed to proceed, and whether the outbound request is still signed — you do not need to write a native mutator. ApproovService.setServiceMutatorType(mask, options?) installs the matching policy mutator natively from a single JavaScript call, so the whole decision lives in your app's JS/TypeScript. Drop down to a native ApproovServiceMutator only when you need fully custom per-request logic (host scoping, custom headers, and so on). Behaviour is identical on Android and iOS.

Most apps: use a named preset.

import { ApproovService } from '@approov/approov-service-react-native';

await ApproovService.initialize('<config>');
// proceed even if the Approov service is unavailable (e.g. offline), still signed:
await ApproovService.setServiceMutatorType(ApproovService.MutatorPreset.PROCEED_IF_UNAVAILABLE);

ApproovService.MutatorPreset values:

PresetEffect
DEFAULTRestore the built-in default mutator (the standard behaviour described under Default Behavior above). Requests are forwarded unsigned.
ALWAYS_PROCEEDProceed on every maskable failure status — includes MITM_DETECTED and REJECTED (read the warning below). Note: any status with no proceed bit still blocks even under this preset; on iOS this applies to notInitialized/badKey/badPayload.
PROCEED_IF_UNAVAILABLEProceed only when the Approov service itself is unavailable (NO_APPROOV_SERVICE). Note this is not the offline case: a device without connectivity yields NO_NETWORK, which this preset blocks — add ReturnDecision.NO_NETWORK to the mask if you also want to tolerate offline devices.
PROCEED_DEV_CLEARTEXTProceed on BAD_URL (non-https traffic, e.g. Metro dev servers); the request still runs through the Approov pipeline. Development only.

What "proceed" sends on a failure status. On any tolerated failure status nothing Approov-derived is available: there is no Approov token (it is only added on SUCCESS), and no substituted secrets or API keys (each substitution requires its own fetch from the Approov service, which just failed). Proceeding therefore never leaks credentials — the request goes out without them. The only difference from the default mutator's behaviour on NO_APPROOV_SERVICE (which forwards the raw request untouched) is that a proceeding policy mutator still runs the request pipeline, so HTTP Message Signing with the local install key is still applied (unless { sign: false }).

Full control: build a raw bitmask.

For any other policy, OR together the individual ApproovService.ReturnDecision flags. The mask lists exactly which failure statuses may proceed; any failure status not in the mask blocks the request.

const RD = ApproovService.ReturnDecision;
await ApproovService.setServiceMutatorType(RD.NO_APPROOV_SERVICE | RD.BAD_URL);
// unsigned variant:
await ApproovService.setServiceMutatorType(RD.NO_APPROOV_SERVICE, { sign: false });

ApproovService.ReturnDecision exposes one bit flag per maskable failure status: NO_APPROOV_SERVICE, BAD_URL, MITM_DETECTED, NO_NETWORK, POOR_NETWORK, REJECTED, UNKNOWN_KEY, INTERNAL_ERROR, NO_NETWORK_PERMISSION, MISSING_LIB_DEPENDENCY, and DISABLED. Only these failure statuses are maskable. SUCCESS always proceeds (with the signed token added), and UNKNOWN_URL / UNPROTECTED_URL always proceed unmodified (forwarded without a token); those three are never maskable and are not exposed as flags.

⚠️ Security warning. The mask decides which failures the app will tolerate. Putting MITM_DETECTED or REJECTED in the mask removes Approov's protection for those cases: the request proceeds without proof of attestation (no valid Approov token). Masking MITM_DETECTED lets a request the SDK reports as man-in-the-middle intercepted go out anyway; masking REJECTED lets a request from an app that failed attestation (tampered, repackaged, or running in a compromised environment) go out anyway. Most apps should use the named presets. Reach for the raw ReturnDecision bitmask only deliberately, and never ship MITM_DETECTED or REJECTED in a production mask unless you fully intend to accept that risk.

Reporting the status to your backend. When a failure status is in the mask, the request proceeds. If you also enable ApproovService.setUseApproovStatusIfNoToken(true), the fetch-status string is written into the token header so the backend can see why no real token was sent; otherwise the token header is emitted empty. The exact string differs per platform — see Use Approov Status as Token — so match on it loosely or normalise it at your backend.

ApproovService.setUseApproovStatusIfNoToken(true);

A blocked request surfaces as a failed fetch. A failure status that is not in the mask blocks the request: it fails as a rejected fetch() — an IOException / Network request failed on Android, and an NSError failure on iOS. The outcome is identical on both platforms.

Signing. options.sign defaults to true, so a policy mutator installed with setServiceMutatorType applies HTTP Message Signing. This is the opt-in route: the out-of-box default does not sign. Pass { sign: false } to proceed per the mask and send the request unsigned, for apps that sign elsewhere or must not double-sign. sign is ignored for MutatorPreset.DEFAULT, which restores the unsigned default mutator.

Signature type. options.signature selects which HTTP Message Signature the installed mutator produces: 'install' (the default, the per-installation Secure Enclave/TEE key, strong non-repudiation) or 'account' (the shared account HMAC-SHA256 key delivered on a successful attestation). It is ignored when sign is false and for MutatorPreset.DEFAULT. See Enabling message signing from JavaScript for worked examples.

const RD = ApproovService.ReturnDecision;
await ApproovService.setServiceMutatorType(RD.NO_APPROOV_SERVICE, { signature: 'account' });

Replace semantics (last wins). setServiceMutatorType replaces any previously installed mutator, including a native one. Use the JavaScript API or a native custom mutator, not both.

Reset on re-initialization. Any successful initialization or re-initialization resets the mutator back to the built-in default, including same-config re-initialization and empty-bootstrap-to-protected upgrades. A warning is logged whenever a custom mutator is discarded this way. Re-apply setServiceMutatorType afterwards if you still need a custom policy.

Do not set the policy in ApproovProvider's onInit — that callback runs before initialize, so the policy is immediately wiped by the reset. Apply it afterwards, keyed on approovInitCount:

const { approovReady, approovInitCount } = useApproov();
useEffect(() => {
if (approovReady) {
ApproovService.setServiceMutatorType(ApproovService.MutatorPreset.PROCEED_IF_UNAVAILABLE);
}
}, [approovReady, approovInitCount]);

approovReady alone is not enough: it latches true on the first success and never changes again, so an effect keyed only on it will not re-run when a later re-initialization resets the mutator. approovInitCount increments on every successful initialization, so the effect re-runs and the policy is re-applied.

If you call ApproovService.initialize() yourself rather than through ApproovProvider (Option 2 above), the counter does not apply — re-apply setServiceMutatorType after each of those calls.

Invalid masks are rejected, not applied. Build the mask from ReturnDecision flags or a MutatorPreset. A mask that is not a finite 32-bit integer, or that sets a bit outside the defined flags (bits 0-10), rejects the promise instead of installing anything. This matters because an undefined bit names no Approov status: it grants proceed to nothing, so applying it would silently produce a block-everything policy that looks intentional.

iOS note: NO_NETWORK_PERMISSION and MISSING_LIB_DEPENDENCY have no equivalent Approov status on iOS, so those two bits are inert there (harmless if included).

Development-only: let Metro's cleartext bundle through. Metro serves the dev bundle over cleartext http://, which the SDK reports as BAD_URL. In development builds you can forward it with the dedicated preset (issue #30):

if (__DEV__) await ApproovService.setServiceMutatorType(ApproovService.MutatorPreset.PROCEED_DEV_CLEARTEXT);

Guard it with __DEV__ so it can never take effect in a release build.

Default Behavior​

By default, the Approov Service is configured with the standard pass-through mutator. It fetches the Approov token, adds the Approov-Token header to requests, and applies pinning, and it forwards the request unsigned. HTTP Message Signing is opt-in: enable it with setServiceMutatorType(mask, { sign: true }) from JavaScript, or by installing ApproovDefaultMessageSigning natively.

If you need to change other networking behaviors, you can do so natively by setting a custom ApproovServiceMutator.

Customizing Request Handling with Native Mutators​

You may want to modify the network behavior to suit specific app requirements. A common use case is handling NO_APPROOV_SERVICE statuses to enforce that an Approov Token must always be present, or proceeding on failures while preserving HTTP Message Signing.

Proceed On Selected Failure Statuses And Sign The Request​

The example below shows a custom mutator that:

  • uses the normal Approov token flow on SUCCESS
  • allows requests to continue on MITM_DETECTED and NO_APPROOV_SERVICE
  • signs the final outbound request with ApproovDefaultMessageSigning

To send the failure reason in the token header when the request is allowed to continue without a real token, you must also enable:

ApproovService.setUseApproovStatusIfNoToken(true);

With that setting enabled, the service layer places the failure status string into the configured token header. The format is not the same on both platforms: Android sends the SDK enum name (Approov-Token: MITM_DETECTED), while iOS sends the SDK's human-readable description (Approov-Token: MITM detected). See Use Approov Status as Token.

Android Implementation (Java)​

package com.yourcompany.yourapp;

import com.criticalblue.approovsdk.Approov;

import io.approov.reactnative.ApproovDefaultMessageSigning;
import io.approov.reactnative.ApproovException;
import io.approov.reactnative.ApproovService;
import io.approov.reactnative.ApproovServiceMutator;

public class ProceedOnSelectedStatusesMutator extends ApproovDefaultMessageSigning {

public ProceedOnSelectedStatusesMutator() {
setDefaultFactory(ApproovDefaultMessageSigning.generateDefaultSignatureParametersFactory());
}

@Override
public boolean handleInterceptorFetchTokenResult(ApproovService service,
Approov.TokenFetchResult approovResults,
String url) throws ApproovException {
Approov.TokenFetchStatus status = approovResults.getStatus();

if (status == Approov.TokenFetchStatus.SUCCESS ||
status == Approov.TokenFetchStatus.MITM_DETECTED ||
status == Approov.TokenFetchStatus.NO_APPROOV_SERVICE) {
return true;
}

return ApproovServiceMutator.super
.handleInterceptorFetchTokenResult(service, approovResults, url);
}
}

Register it before React Native networking starts:

import android.app.Application;

import io.approov.reactnative.ApproovService;

public class MainApplication extends Application {
@Override
public void onCreate() {
super.onCreate();

ApproovService.setServiceMutator(new ProceedOnSelectedStatusesMutator());
}
}

iOS Implementation (Swift)​

import Foundation
import approov_service_react_native

final class ProceedOnSelectedStatusesMutator: ApproovServiceMutator {
private let signer: ApproovDefaultMessageSigning

init() {
let signer = ApproovDefaultMessageSigning()
_ = signer.setDefaultFactory(
ApproovDefaultMessageSigning.generateDefaultSignatureParametersFactory()
)
self.signer = signer
}

func handleInterceptorFetchTokenResult(_ approovResults: ApproovTokenFetchResult,
url: String) throws -> Bool {
switch approovResults.status {
case .success, .mitmDetected, .noApproovService:
return true
default:
return try ApproovServiceMutatorDefault.shared
.handleInterceptorFetchTokenResult(approovResults, url: url)
}
}

func handleInterceptorProcessedRequest(_ request: URLRequest,
changes: ApproovRequestMutations) throws -> URLRequest {
return try signer.handleInterceptorProcessedRequest(request, changes: changes)
}
}

Register it during app startup:

import approov_service_react_native

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
ApproovServiceMutatorBridge.shared.serviceMutator = ProceedOnSelectedStatusesMutator()
return true
}
}

In JavaScript, enable status-as-token before making protected requests:

import { ApproovService } from '@approov/approov-service-react-native';

await ApproovService.initialize('<your-config-string>');
ApproovService.setUseApproovStatusIfNoToken(true);

Customizing Mutators with Message Signing​

Since ApproovDefaultMessageSigning is the default mutator, you must ensure that your custom mutator continues to invoke the message signer. If you want custom logic (like enforcing tokens), you pass the message signer into your custom mutator so they compose together.

Below are examples of how to implement and register a custom mutator that enforces tokens and simultaneously preserves message signing.

Android Implementation (Java)​

On Android, create a class implementing io.approov.reactnative.ApproovServiceMutator.

package com.yourcompany.yourapp;

import io.approov.reactnative.ApproovServiceMutator;
import io.approov.reactnative.ApproovServiceMutatorDefault;
import com.approov.service.ApproovService;
import com.approov.service.TokenFetchResult;
import okhttp3.Request;
import java.io.IOException;

public class EnforceTokenMutator implements ApproovServiceMutator {

private ApproovServiceMutator signer;

public EnforceTokenMutator(ApproovServiceMutator signer) {
this.signer = signer;
}

@Override
public boolean handleInterceptorFetchTokenResult(TokenFetchResult approovResults, String url) throws IOException {
if (approovResults.getStatus() == TokenFetchResult.Status.NO_APPROOV_SERVICE) {
throw new IOException("Approov service not available. Token required.");
}
return ApproovServiceMutatorDefault.getInstance().handleInterceptorFetchTokenResult(approovResults, url);
}

@Override
public boolean handleInterceptorShouldProcessRequest(Request request) throws IOException {
return ApproovServiceMutatorDefault.getInstance().handleInterceptorShouldProcessRequest(request);
}

@Override
public Request handleInterceptorProcessedRequest(Request request, ApproovServiceMutator.ApproovRequestMutations changes) throws IOException {
// We MUST call the signer here so that message signing still works!
if (signer != null) {
return signer.handleInterceptorProcessedRequest(request, changes);
}
return request;
}
}

Register this mutator combined with the message signer configuration before initializing your React Native application (e.g., in MainApplication.java inside onCreate):

import io.approov.reactnative.ApproovService;
import io.approov.reactnative.ApproovDefaultMessageSigning;

public class MainApplication extends Application implements ReactApplication {
@Override
public void onCreate() {
super.onCreate();

// 1. Configure the signer natively (or skip if disabling signing)
ApproovDefaultMessageSigning.SignatureParametersFactory factory =
ApproovDefaultMessageSigning.generateDefaultSignatureParametersFactory();
ApproovDefaultMessageSigning signer = new ApproovDefaultMessageSigning();
signer.setDefaultFactory(factory);

// 2. Wrap it with your custom mutator
EnforceTokenMutator myMutator = new EnforceTokenMutator(signer);

// 3. Register custom mutator BEFORE React Native networking starts
ApproovService.setServiceMutator(myMutator);

// ... remaining React Native init
}
}

iOS Implementation (Swift)​

On iOS, you can define your mutator in Swift using the ApproovServiceMutator protocol from approov_service_react_native.

import Foundation
import approov_service_react_native

class EnforceTokenMutator: ApproovServiceMutator {
private let signer: ApproovServiceMutator?

init(signer: ApproovServiceMutator?) {
self.signer = signer
}

func handleInterceptorFetchTokenResult(_ approovResults: ApproovTokenFetchResult, url: String) throws -> Bool {
if approovResults.status == .noApproovService {
throw NSError(domain: "ApproovService", code: 1, userInfo: [NSLocalizedDescriptionKey: "Approov service not available."])
}
return true
}

func handleInterceptorProcessedRequest(_ request: URLRequest, changes: ApproovRequestMutations) throws -> URLRequest {
if let signer = signer {
return try signer.handleInterceptorProcessedRequest(request, changes: changes)
}
return request
}

func handleInterceptorShouldProcessRequest(_ request: URLRequest) throws -> Bool { return true }
func handlePrecheckResult(_ approovResults: ApproovTokenFetchResult) throws {}
func handleFetchTokenResult(_ approovResults: ApproovTokenFetchResult) throws {}
func handleFetchSecureStringResult(_ approovResults: ApproovTokenFetchResult, operation: String, key: String) throws {}
func handleFetchCustomJWTResult(_ approovResults: ApproovTokenFetchResult) throws {}
func handleInterceptorHeaderSubstitutionResult(_ approovResults: ApproovTokenFetchResult, header: String) throws -> Bool { return true }
func handleInterceptorQueryParamSubstitutionResult(_ approovResults: ApproovTokenFetchResult, queryKey: String) throws -> Bool { return true }
func handlePinningShouldProcessRequest(_ request: URLRequest) -> Bool { return true }
}

Register your mutator in AppDelegate.swift or AppDelegate.mm:

import approov_service_react_native

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

// 1. Configure the signer natively
let factory = ApproovDefaultMessageSigning.generateDefaultSignatureParametersFactory()
let signer = ApproovDefaultMessageSigning()
_ = signer.setDefaultFactory(factory)

// 2. Wrap it
let myMutator = EnforceTokenMutator(signer: signer)

// 3. Register it with the React Native Bridge BEFORE React Native init
ApproovServiceMutatorBridge.shared.serviceMutator = myMutator

return true
}
}

Real-world examples​

Policy-driven mutator (host scoping, offline fallback, message signing, pinning)​

This example implementation demonstrates how to customize the native ApproovServiceMutator to apply different options to API requests based on the hostname. Note that because custom mutators run directly on the underlying native HTTP client hooks, you must implement them in Java/Kotlin (Android) and Swift/Objective-C (iOS).

Android Implementation (Java)

package com.yourcompany.yourapp;

import io.approov.reactnative.ApproovServiceMutator;
import io.approov.reactnative.ApproovService;
import io.approov.reactnative.ApproovException;
import com.criticalblue.approovsdk.Approov;
import okhttp3.Request;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.net.URL;

public class PolicyDrivenMutator implements ApproovServiceMutator {
private ApproovServiceMutator signer;
private Set<String> protectedHosts;
private Set<String> allowOfflineForHosts;
private Set<String> skipPinningHosts;

public PolicyDrivenMutator(ApproovServiceMutator signer) {
this.signer = signer;
this.protectedHosts = new HashSet<>(Arrays.asList("api.example.com"));
this.allowOfflineForHosts = new HashSet<>(Arrays.asList("status.example.com"));
this.skipPinningHosts = new HashSet<>(Arrays.asList("metrics.example.com"));
}

@Override
public boolean handleInterceptorShouldProcessRequest(ApproovService service, Request request) throws ApproovException {
String host = request.url().host();
if (!protectedHosts.contains(host)) {
return false;
}
return ApproovServiceMutator.DEFAULT.handleInterceptorShouldProcessRequest(service, request);
}

@Override
public boolean handleInterceptorFetchTokenResult(ApproovService service, Approov.TokenFetchResult approovResults, String url) throws ApproovException {
Approov.TokenFetchStatus status = approovResults.getStatus();
if (status == Approov.TokenFetchStatus.NO_NETWORK || status == Approov.TokenFetchStatus.POOR_NETWORK) {
try {
String host = new URL(url).getHost();
if (allowOfflineForHosts.contains(host)) {
return false;
}
} catch (Exception e) {
// Ignore URL parsing errors and fall through
}
}
return ApproovServiceMutator.DEFAULT.handleInterceptorFetchTokenResult(service, approovResults, url);
}

@Override
public Request handleInterceptorProcessedRequest(ApproovService service, Request request, ApproovServiceMutator.ApproovRequestMutations changes) throws ApproovException {
Request req = request;
if (signer != null) {
req = signer.handleInterceptorProcessedRequest(service, req, changes);
}
return req.newBuilder().header("X-Client-Platform", "android").build();
}

@Override
public boolean handlePinningShouldProcessRequest(Request request) {
String host = request.url().host();
return !skipPinningHosts.contains(host);
}
}

iOS Implementation (Swift)

import Foundation
import approov_service_react_native

class PolicyDrivenMutator: ApproovServiceMutator {
private let signer: ApproovServiceMutator?
private let protectedHosts: Set<String>
private let allowOfflineForHosts: Set<String>
private let skipPinningHosts: Set<String>

init(
signer: ApproovServiceMutator? = nil,
protectedHosts: Set<String> = ["api.example.com"],
allowOfflineForHosts: Set<String> = ["status.example.com"],
skipPinningHosts: Set<String> = ["metrics.example.com"]
) {
self.signer = signer
self.protectedHosts = protectedHosts
self.allowOfflineForHosts = allowOfflineForHosts
self.skipPinningHosts = skipPinningHosts
}

func handleInterceptorShouldProcessRequest(_ request: URLRequest) throws -> Bool {
guard let host = request.url?.host, protectedHosts.contains(host) else { return false }
return try ApproovServiceMutatorDefault.shared.handleInterceptorShouldProcessRequest(request)
}

func handleInterceptorFetchTokenResult(_ approovResults: ApproovTokenFetchResult,
url: String) throws -> Bool {
if approovResults.status == .noNetwork || approovResults.status == .poorNetwork,
let host = URL(string: url)?.host, allowOfflineForHosts.contains(host) {
return false
}
return try ApproovServiceMutatorDefault.shared
.handleInterceptorFetchTokenResult(approovResults, url: url)
}

func handleInterceptorProcessedRequest(_ request: URLRequest,
changes: ApproovRequestMutations) throws -> URLRequest {
var req = request
if let signer = signer {
req = try signer.handleInterceptorProcessedRequest(req, changes: changes)
}
req.setValue("ios", forHTTPHeaderField: "X-Client-Platform")
return req
}

func handlePinningShouldProcessRequest(_ request: URLRequest) -> Bool {
guard let host = request.url?.host else { return true }
return !skipPinningHosts.contains(host)
}

// Other required protocol methods with default behavior:
func handlePrecheckResult(_ approovResults: ApproovTokenFetchResult) throws {}
func handleFetchTokenResult(_ approovResults: ApproovTokenFetchResult) throws {}
func handleFetchSecureStringResult(_ approovResults: ApproovTokenFetchResult, operation: String, key: String) throws {}
func handleFetchCustomJWTResult(_ approovResults: ApproovTokenFetchResult) throws {}
func handleInterceptorHeaderSubstitutionResult(_ approovResults: ApproovTokenFetchResult, header: String) throws -> Bool { return true }
func handleInterceptorQueryParamSubstitutionResult(_ approovResults: ApproovTokenFetchResult, queryKey: String) throws -> Bool { return true }
}

Log rejections with ARC + device ID to your telemetry​

Monitoring and analyzing rejections is a key part of your security strategy. Ideally, your backend should be customized to include the ARC (Approov Rejection Code) and Device ID in its error responses (e.g., in a JSON body or a custom header) when it rejects a request due to a missing or invalid Approov token.

Why Server-Side Logging is Preferred​

While you can obtain these values directly from the SDK using ApproovService.getLastARC(), it is generally safer and more reliable to log them from the server response for several reasons:

  1. Avoid Misleading Network Events: On poor network connections, a call to getLastARC() can inadvertently trigger a background network event that successfully completes a delayed attestation. This might provide an ARC associated with a successful attestation that occurred after your original request failed, creating confusing telemetry.
  2. Corporate Firewall & MITM Bypass: If your custom native mutator allows a request to proceed on MITM_DETECTED (a common result of corporate firewalls), the request is sent without a token. In this state, getLastARC() will not yet have a rejection code available for that specific attempt.
  3. Accuracy and Correlation: Logging the ARC that the server actually observed and used as the basis for rejection ensures perfect correlation in your monitoring dashboards.

If you must log from the client, ensure you have a fallback strategy for when the server doesn't provide the code.

import { ApproovService } from '@approov/approov-service-react-native';

async function makeProtectedRequest(url, options) {
try {
const response = await fetch(url, options);
if (response.ok) {
return response;
} else {
// Preferred: Extract ARC and Device ID from your own server's response
const serverArc = response.headers.get("X-Approov-Error-ARC");

// ALTERNATIVE: (DISCOURAGED) Obtain from SDK ONLY if server-side retrieval is impossible.
// Note: This may trigger background network events and return misleading results.
const arc = serverArc || await ApproovService.getLastARC();
const deviceID = await ApproovService.getDeviceID();

console.log(`Request rejected. ARC: ${arc}, Device ID: ${deviceID}`);
return response;
}
} catch (error) {
console.error("Network request failed", error);
throw error;
}
}

Tips​

  • Keep mutator logic fast and side-effect safe. These native hooks run on the request path and blocking them will hang the network traffic.
  • To add message signing on top of your own behaviour, extend ApproovDefaultMessageSigning or compose your mutator with the message signer as shown in Customizing Mutators with Message Signing above. Basing a custom mutator on ApproovServiceMutator.DEFAULT (Android) gives you the pass-through behaviour, which performs no message signing.
  • If you override multiple hooks, keep them focused (one concern per hook) for easier testing and maintenance.