iTranslated by AI
[Flutter] Implementing Security Threat Detection and Handling with freerasp and Riverpod
Introduction
Enhancing application security is essential. In mobile applications, threats such as sensitive information leakage, data tampering, and API abuse via reverse engineering—often enabled by Android rooting or iOS jailbreaking—are significant risks. Firebase App Check is a well-known service for detecting and preventing such threats from malicious users.
However, I personally found it lacking in stability, and recently I discovered a package called freerasp provided by Talsec.
In this article, I will introduce a sample implementation that combines freerasp with Riverpod.
Target Audience
- Flutter developers interested in security measures, including rooting/jailbreak protection.
- Those who have used services like Firebase App Check but are looking for alternative or supplementary approaches.
- Flutter engineers who want to understand what freerasp can do and how to implement it.
- Riverpod users who want to learn how to design state management and notifications for security-related processes.
- Those interested in an architecture that cleanly separates "detection logic (infrastructure-related)" from "UI and navigation."
Environment at the time of writing
[✓] Flutter (Channel stable, 3.35.4, on macOS 26.0.1 25A362 darwin-arm64, locale ja-JP)
[!] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] VS Code (version 1.104.3)
[✓] Connected device (5 available)
[✓] Network resources
Related Packages
Sample Project
- Upon app launch, the background scan by freerasp begins.
- Once the scan is complete and safe, the UI updates to show the login button.
- After logging in (mocked), pressing the floating button on the home screen triggers a simulated threat state.
- The UI detects the state change and redirects back to the login screen.
Source Code
What You Can Do with freerasp
Overview
This package can detect not only OS modifications like rooting/jailbreaking but also various operations that could indicate malicious intent toward the application.
While I will leave the details to the official documentation, the package provides a function to notify you when such threats are detected. There are two primary timing points for detection:
- During app startup (when initialization is executed)
- Regularly while the app is running
It also provides notification features and a dashboard to review detected threats.
Considerations During Development
When enabling detection features during development, I found that "hot restart" did not work correctly. If you need to restart the app, you will need to stop it and rebuild it.
Note: Hot reload worked fine.
Design Architecture
To operate freerasp, the following three components must be implemented:
- Callback configuration for scan completion.
- Callback configuration for threat detection.
- Basic configuration and scan execution.
While you could perform database deletions or navigation directly inside the callbacks, doing so leads to bloated responsibilities. Therefore, I designed it so that callbacks only notify the state.
Additionally, since a scan completion callback is provided, I wanted to ensure the app remains locked until the initial scan is finished.
To achieve this, the scanning state is delivered via a Stream, which the UI subscribes to.
Implementing the Scan Execution and Notification Mechanism
DeviceSecurityStatus Representing the Check Status
First, we define a type representing the device check status using a sealed class. Considering its use with Riverpod, we define this using freezed for better compatibility.
/// Device security status
@freezed
sealed class DeviceSecurityStatus with _$DeviceSecurityStatus {
/// Checking
const factory DeviceSecurityStatus.checking() = DeviceSecurityStatusChecking;
/// Safe after check completion
const factory DeviceSecurityStatus.safe() = DeviceSecurityStatusSafe;
/// Compromised device
const factory DeviceSecurityStatus.threat({
required String message,
}) = DeviceSecurityStatusThreat;
}
talsecProvider: The Instance Providing Functionality
Next, we define the package instance that provides the functionality using a provider. It is slightly confusing that the package name is "freerasp" but the class name is the company name, "Talsec".
@Riverpod(keepAlive: true)
Talsec talsec(Ref ref) => Talsec.instance;
Overview of DeviceSecurityRepository: Handling Execution, Status Notification, and Monitoring
As the title suggests, we implement three major parts:
-
init: Initialization that sets up detection features, configures callbacks, and starts the scan. -
_statusController: Notifies the security status. -
watch: Monitors the security status.
Here is the complete overview:
class DeviceSecurityRepository {
DeviceSecurityRepository(this.ref, {required this.enableThreatInDebug});
final Ref ref;
/// Whether to enable threat detection in debug mode
///
/// Injected via constructor for testing purposes
final bool enableThreatInDebug;
/// Stream controller for security status
final _statusController = _StatusStreamController();
/// Talsec instance
Talsec get _talsec => ref.read(talsecProvider);
/// Configuration
///
/// Defined as a getter to allow injection during testing
TalsecConfig get config => _TalsecConfig.value;
/// Stream to monitor security status
///
/// Immediately emits the current state upon subscription, then notifies on state changes
Stream<DeviceSecurityStatus> watch() => _statusController.watch();
/// Resource cleanup
Future<void> dispose() async => _statusController.close();
/// Initialization: Start freeRASP + configure callbacks
Future<void> init() async {
// Register scan completion callback: treat as safe and emit via stream
_talsec.attachExecutionStateListener(_createExecutionStateCallback());
// Register threat detection callback: emit based on threat content
await _talsec.attachListener(_createThreatCallback());
// Start freeRASP
//
// Generally called only once.
// After this, it continues to run in the background while the app is active.
await _talsec.start(config);
}
}
While I mentioned three parts earlier, init handles two of them. Specifically, init performs the following flow:
- Configure callbacks for scan completion/threat detection. These callbacks emit status updates via
_statusController. - Configure basic settings and execute the scan.
Setting Up the Mechanism to Monitor and Update DeviceSecurityStatus
The primary role of the callbacks registered in init is to update the DeviceSecurityStatus. The watch method is provided to detect these changes.
Stream<DeviceSecurityStatus> watch() => _statusController.watch();
To implement watch, we define a _StatusStreamController class within the repository to manage DeviceSecurityStatus in one place, allowing us to define the logic for emitting status updates via callbacks and determining whether to emit them.
final class _StatusStreamController {
/// Current value (initial value is checking)
DeviceSecurityStatus _value = const DeviceSecurityStatus.checking();
final _streamController = StreamController<DeviceSecurityStatus>.broadcast();
/// Whether the state is safe (no threats detected)
bool get isSafe => _value is! DeviceSecurityStatusThreat;
/// Update the value and emit to the stream
void _add(DeviceSecurityStatus value) {
_value = value;
_streamController.add(value);
}
/// Notify safe state
void addSafe() => _add(const DeviceSecurityStatus.safe());
/// Notify threat detection
void addThreat(String message) =>
_add(DeviceSecurityStatus.threat(message: message));
/// Return a stream after emitting the current value
Stream<DeviceSecurityStatus> watch() {
return Stream<DeviceSecurityStatus>.multi((sink) {
// 1. Subscribe to controller.stream
final subscription = _streamController.stream.listen(
sink.add,
onError: sink.addError,
onDone: sink.close,
);
sink
// 2. Immediately emit the current value
..add(_value)
// 3. Cleanup upon cancellation
..onCancel = subscription.cancel;
});
}
/// Resource cleanup
Future<void> close() => _streamController.close();
}
Within the class, we first hold the current state, initializing it to "checking".
DeviceSecurityStatus _value = const DeviceSecurityStatus.checking();
Next, we define a controller to emit the state via a stream:
final _streamController = StreamController<DeviceSecurityStatus>.broadcast();
When a threat or completion is detected within the callbacks, we save the state and then emit it:
void _add(DeviceSecurityStatus value) {
_value = value;
_streamController.add(value);
}
In watch, we use Stream.multi to:
- Subscribe to
_streamController.streamto receive future values. - Immediately emit the "current value (cached
_value)". - Properly release the internal subscription when the stream is cancelled.
This ensures that the stream receives both the "latest state at the start of subscription" and "subsequent changes" without gaps.
/// Return a stream after emitting the current value
Stream<DeviceSecurityStatus> watch() {
return Stream<DeviceSecurityStatus>.multi((sink) {
final subscription = _streamController.stream.listen(
sink.add,
onError: sink.addError,
onDone: sink.close,
);
sink
..add(_value)
..onCancel = subscription.cancel;
});
}
Inspection Completion Callback
For the inspection completion callback, we pass a callback function to the dedicated class RaspExecutionStateCallback.
extension _CallbackExtension on DeviceSecurityRepository {
/// Generate execution state callback
RaspExecutionStateCallback _createExecutionStateCallback() {
return RaspExecutionStateCallback(
onAllChecksDone: () {
// If safe upon completion of initial scan
if (_statusController.isSafe) {
_statusController.addSafe();
}
},
);
}
// ...
}
Here, we check the current state using isSafe from the _StatusStreamController defined earlier.
bool get isSafe => _value is! DeviceSecurityStatusThreat;
If the state has not detected a threat == safe
Therefore, we emit DeviceSecurityStatus.safe(). It is important to note that this is because the callback execution order generally involves the threat detection callback first, and the completion callback last. Be aware that the completion callback is not necessarily a guarantee that the system was safe from the start.
And the implementation of _statusController.addSafe(); which emits the state is written as follows:
void addSafe() => _add(const DeviceSecurityStatus.safe());
We register the completion callback function defined in this way within init.
// Register callback for inspection completion: treat as safe and emit to stream
_talsec.attachExecutionStateListener(_createExecutionStateCallback());
Centralized Management of Detectable Threats by Severity
There are 18 types of threats that this package can detect. However, whether all of these are genuinely "dangerous" depends on the context. As noted in the official documentation, what constitutes a "threat" varies based on app requirements.
Therefore, we categorize the severity of detectable threats into three levels.
enum _ThreatLevel {
/// Danger: Block the app
block,
/// Monitor: Continue running the app, but collect via Crashlytics etc.
monitor,
/// Ignore: Do not even send logs (defined for coverage)
ignore,
}
While grouping detectable threats using an enum, we set the following fields:
- Message
- Severity level
- Whether to ignore in debug mode
Here is an excerpt:
enum _ThreatType {
/// Detect root/Jailbreak
privilegedAccess(
'Root/Jailbreak detected',
level: _ThreatLevel.block,
),
/// Detect unset passcode
passcode(
'Passcode not set on device',
level: _ThreatLevel.monitor,
),
/// Detect insecure WiFi
unsecureWifi(
'Insecure WiFi detected',
level: _ThreatLevel.ignore,
),
//...
;
const _ThreatType(
this.message, {
required this.level,
this.ignoreInDebugMode = false,
});
/// Message for log output
final String message;
/// Threat severity level
final _ThreatLevel level;
/// Whether to ignore in debug mode
final bool ignoreInDebugMode;
}
_ThreatType Full Text
enum _ThreatType {
/// Detect dynamic hooking (Frida etc.)
hooks(
'Dynamic hooking (Frida etc.) detected',
level: _ThreatLevel.block,
),
/// Detect debugger connection
debug(
'Debugger detected',
level: _ThreatLevel.block,
ignoreInDebugMode: true,
),
/// Detect unset passcode
passcode(
'Passcode not set on device',
level: _ThreatLevel.monitor,
),
/// Detect app reinstallation (iOS only)
deviceId(
'App reinstalled',
level: _ThreatLevel.ignore,
),
/// Detect emulator/simulator
simulator(
'Emulator/Simulator detected',
level: _ThreatLevel.block,
ignoreInDebugMode: true,
),
/// Detect app integrity violation
appIntegrity(
'App integrity issue detected',
level: _ThreatLevel.block,
),
/// Detect lack of obfuscation
obfuscationIssues(
'App is not obfuscated',
level: _ThreatLevel.monitor,
ignoreInDebugMode: true,
),
/// Detect device binding violation
deviceBinding(
'Device binding violation',
level: _ThreatLevel.block,
),
/// Detect installation from unofficial store
unofficialStore(
'Installed from unofficial store',
level: _ThreatLevel.block,
ignoreInDebugMode: true,
),
/// Detect root/Jailbreak
privilegedAccess(
'Root/Jailbreak detected',
level: _ThreatLevel.block,
),
/// Detect lack of secure hardware support
secureHardwareNotAvailable(
'Secure hardware unavailable',
level: _ThreatLevel.monitor,
),
/// Detect system VPN
systemVpn(
'System VPN detected',
level: _ThreatLevel.ignore,
),
/// Detect developer mode
devMode(
'Developer mode is enabled',
level: _ThreatLevel.monitor,
ignoreInDebugMode: true,
),
/// Detect ADB (Android only)
adbEnabled(
'ADB is enabled',
level: _ThreatLevel.monitor,
ignoreInDebugMode: true,
),
/// Detect malware (Android only)
malware(
'Malware detected',
level: _ThreatLevel.block,
),
/// Detect screenshots
screenshot(
'Screenshot detected',
level: _ThreatLevel.ignore,
),
/// Detect screen recording
screenRecording(
'Screen recording detected',
level: _ThreatLevel.ignore,
),
/// Detect multiple instances
multiInstance(
'Multiple instances detected',
level: _ThreatLevel.block,
),
/// Detect insecure WiFi
unsecureWifi(
'Insecure WiFi detected',
level: _ThreatLevel.ignore,
),
/// Detect time spoofing
timeSpoofing(
'Time spoofing detected',
level: _ThreatLevel.block,
),
/// Detect location spoofing
locationSpoofing(
'Location spoofing detected',
level: _ThreatLevel.block,
);
//...
}
Callback When a Threat Is Detected
Unlike the completion callback, we vary our handling based on the type of threat and its severity level. First, pass the detected threat type to _handleThreat within various callbacks.
extension _CallbackExtension on DeviceSecurityRepository {
// ...
/// Generate threat detection callback
ThreatCallback _createThreatCallback() {
return ThreatCallback(
onHooks: () => _handleThreat(_ThreatType.hooks),
onDebug: () => _handleThreat(_ThreatType.debug),
onPasscode: () => _handleThreat(_ThreatType.passcode),
// Omitted
// ...
);
}
/// Common processing when a threat is detected
void _handleThreat(_ThreatType type) {
// Handle based on _ThreatType
}
}
Within _handleThreat, we first exclude those that should be ignored during development. Since features like debug modes or emulators are often flagged, we disable detection during development.
void _handleThreat(_ThreatType type) {
// If configured to ignore in debug mode
if (kDebugMode && type.ignoreInDebugMode) {
logger.d('${type.message} (ignored in debug mode)');
return;
}
// ...
}
Afterward, we switch processing based on the threat level.
switch (type.level) {
case _ThreatLevel.block:
// Assuming reporting to Crashlytics etc.
logger.w('Security threat: ${type.message}');
// Emit to stream only if no threat has been detected yet
// Do not notify for the second threat onwards
if (_statusController.isSafe) {
_statusController.addThreat(type.message);
}
case _ThreatLevel.monitor:
// Send to Crashlytics etc. (continue app usage)
logger.i('Security monitoring: ${type.message}');
case _ThreatLevel.ignore:
// Do nothing (defined for coverage)
break;
}
If it is a dangerous level, we report it to services like Crashlytics. Similar to before, if _statusController.isSafe is still true, we then emit the threat to the stream using _statusController.addThreat(type.message);.
The implementation of addThreat is as follows:
void addThreat(String message) =>
_add(DeviceSecurityStatus.threat(message: message));
For the app side, once a threat is detected, it is sufficient to handle subsequent threats internally (like logging), so we prevent multiple notifications for the same app lifecycle.
For other levels, we handle them by only logging or doing nothing.
We register the callback functions defined this way within init as shown below:
// Register callback for threat detection: emit to stream based on content when a threat is detected
await _talsec.attachListener(_createThreatCallback());
Registering Basic Settings and Executing the Inspection
At the end of init, we declare start to register the basic configuration and execute the inspection. The initial declaration is sufficient, as the app will continue to perform initial and periodic background checks as long as it is alive.
await _talsec.start(config);
Also, while the configuration value config could be written inside this init function, we define it in a private class as follows for clarity.
abstract final class _TalsecConfig {
/// Talsec configuration values
static final value = TalsecConfig(
watcherMail: _watcherMail,
androidConfig: _androidConfig,
iosConfig: _iosConfig,
// 💡 Enable if you want to force close the app when a threat is detected
// killOnBypass: true,
);
/// Mail for Talsec portal
static const _watcherMail = 'your_mail@example.com';
/// Android settings
static final _androidConfig = AndroidConfig(
packageName: 'com.base.sample.app.base_sample',
// Base64-SHA256 hash of the debug certificate
signingCertHashes: [
'tgjD7tTWEyd0juKHzWS6/Hf00Sl0hdPHSJ69Mm+LSOc=',
],
supportedStores: [],
);
/// iOS settings
static final _iosConfig = IOSConfig(
// Actual Bundle ID for iOS
bundleIds: ['your.bundle.id'],
// Team ID for iOS
teamId: 'YOUR_APPLE_TEAM_ID',
);
}
We define this via a getter in the repository instance so it can be injected during testing.
TalsecConfig get config => _TalsecConfig.value;
Receiving Notifications
We define a provider that consumes the stream delivered by the aforementioned DeviceSecurityRepository.
/// Provider for device security status
@Riverpod(keepAlive: true)
Stream<DeviceSecurityStatus> deviceSecurityStatus(Ref ref) {
final repository = ref.read(deviceSecurityRepositoryProvider);
return repository.watch();
}
Receiving Security Status on the UI Side
Execute Initialization
We call the previously defined init in the app startup provider.
@Riverpod(keepAlive: true)
Future<void> appStartup(Ref ref) async {
// Add other initialization processes like DB initialization here
// ...
// freeRASP initialization
final deviceSecurityRepository = ref.read(deviceSecurityRepositoryProvider);
await deviceSecurityRepository.init();
}
We assume this initialization is executed immediately after main.
class AppStartupConsumer extends ConsumerWidget {
const AppStartupConsumer({
required this.onLoaded,
required this.onLoading,
required this.onError,
super.key,
});
final Widget Function() onLoaded;
final Widget Function() onLoading;
final Widget Function(
Object,
StackTrace, {
required VoidCallback onRestart,
}) onError;
@override
Widget build(BuildContext context, WidgetRef ref) {
final appStartupState = ref.watch(appStartupProvider);
return appStartupState.when(
skipLoadingOnRefresh: false,
data: (_) => onLoaded(),
loading: onLoading,
error: (e, s) =>
onError(e, s, onRestart: () => ref.invalidate(appStartupProvider)),
);
}
}
/// App entry point
void main() {
runApp(
const ProviderScope(
child: AppStartupConsumer(
onLoaded: MainApp.new,
onLoading: MainAppLoading.new,
onError: MainAppError.new,
),
),
);
}
Watching on the Login Screen
We watch the deviceSecurityStatusProvider here. We expect DeviceSecurityStatus.checking() to be emitted initially.
class LoginScreen extends ConsumerWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncSecurityStatus = ref.watch(deviceSecurityStatusProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Login'),
),
body: Center(
child: switch (asyncSecurityStatus) {
AsyncValue(value: final status?) =>
_SecurityStatusView(status: status),
AsyncError(:final error) => Column(
// ...
),
AsyncLoading() => const Column(
// ...
),
},
),
);
}
}
Once the state of deviceSecurityStatusProvider is loaded, the state is updated whenever the inspection completes or a threat is detected. We switch the UI based on the updated state.
class _SecurityStatusView extends StatelessWidget {
const _SecurityStatusView({required this.status});
final DeviceSecurityStatus status;
@override
Widget build(BuildContext context) {
return switch (status) {
DeviceSecurityStatusChecking() => const _CheckingView(),
DeviceSecurityStatusSafe() => const _SafeView(),
DeviceSecurityStatusThreat(:final message) =>
_ThreatView(message: message),
};
}
}
// Actual UI implementation omitted
Handling Threats Detected During App Usage
For example, assume the user has migrated to the home screen after logging in. By listen-ing to the deviceSecurityStatusProvider in various screens, or at the root screen, you can trigger side effects such as:
Clear data and log the user out
class HomeScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Return to login screen if a threat is detected
ref.listen(
deviceSecurityStatusProvider,
(previous, next) async {
if (next.value case DeviceSecurityStatusThreat()) {
// Mock data deletion process
await mockDeleteAllData();
// Navigate to login screen
if (!context.mounted) return;
context.go(LoginScreen.path);
}
},
);
// ...
}
}
Conclusion
In this article, we introduced how to detect threats like rooting/jailbreaking using freerasp and manage the state with Riverpod.
Key takeaways:
- freerasp detects 18 types of threats and provides initial and periodic background checks.
- Represent inspection states (checking / safe / threat) in a type-safe manner using sealed classes.
- Designed to encapsulate detection logic in the Repository layer and notify the UI via a Stream.
- Flexibility in handling threats by classifying severity into three levels (block, monitor, ignore).
freerasp's strength lies in its ability to detect and handle threats directly on the client side, offering a different approach from Firebase App Check. Consider using it in combination or as an alternative based on your security requirements.
I hope this article is helpful to someone.
Tests
Although I didn't touch upon them in detail, I also wrote test code. If you are interested, please check the GitHub repository below.
Discussion