📱Securing Flutter App: Best Practices and Techniques (Part 4)🛡️
This is Part #4 of Securing Flutter App, you can Check the Part #1 Article & Part #2 Article & Part #3 Article for the first 6 Measures to secure your flutter app
In this Article We’ll continue Adding Security Layers to our Flutter App by Adding 3 more security measures to our App.
In Part #1 we used Code Obfuscation & Enforcing HTTPS and SSL Certificate Pinning.
In Part #2 we used App Integrity and Runtime Environment Checks & Secure Data Storage and Caching.
In Part #3 we used Biometric Authentication (Face ID/Fingerprint) & Preventing Screenshots and Screen Recording
In this Part We’ll talk about the following:
- Avoiding Hardcoding API Keys and Secrets
- Rate Limiting and CAPTCHA
- Clearing Logs and Non-Debugging prints
So, Let’s Start…
1. Avoiding Hardcoding API Keys and Secrets
In today’s world, securing mobile applications is more important than ever. As developers, we often rely on APIs to communicate with external services, and this requires us to use API keys, tokens, or other sensitive credentials. However, hardcoding these secrets directly into your Flutter app can leave your app vulnerable to security threats, making it an easy target for malicious actors to extract and misuse your sensitive data. In this article, we’ll explore why hardcoding API keys and secrets is a bad practice, the risks associated with it, and best practices to securely manage and protect your sensitive information in Flutter applications.
Why Avoid Hardcoding API Keys
Hardcoding API keys and secrets directly into your Flutter app might seem like an easy and quick solution, but it poses serious security risks. Here are a few key reasons why you should avoid this practice:
1. Easy Extraction by Hackers
When API keys are hardcoded into your app, they can be easily extracted by anyone with access to the app’s package file (APK for Android or IPA for iOS). Even if your code is obfuscated, skilled attackers can reverse-engineer your app, retrieve the keys, and misuse them.
2. Source Code Exposure
If your code is ever shared publicly, for example, pushed to a public repository on GitHub or shared among multiple teams, hardcoded API keys can be accidentally exposed. This can give malicious actors unauthorized access to sensitive services, APIs, or databases.
3. Difficult Key Rotation
Hardcoded keys make it harder to rotate or update API keys when needed. If a key is compromised, you’ll need to push out an entirely new version of your app with updated keys, which can be cumbersome and slow, especially if users do not update their apps quickly.
4. Violated Best Practices
Storing API keys in your source code violates fundamental security best practices, such as the principle of least privilege, where access to sensitive information should be minimized. Hardcoded secrets increase the attack surface and make your app an easier target for breaches.
5. API Usage Limits and Abuse
If your API keys are leaked, unauthorized users can start making requests using your credentials, possibly exceeding rate limits, incurring unnecessary costs, or even getting your access revoked by service providers due to abusive usage patterns.
To avoid these vulnerabilities, we have many approaches to implement
A. Use Environment Variables
We can use the regular flutter_dotenv or dotenv package but,
The security flaw in most dotenv packages, such as flutter_dotenv and dotenv, lies in their failure to protect sensitive data adequately. These packages typically store secrets as plain text in the .env file and include this file as an asset to the Flutter application.
So we will use secure_dotenv.
Steps to Add secure dotenv to Your Flutter App:
- Add the
secure_dotenvpackage to yourpubspec.yaml:
Link: secure_dotenv | Dart package (pub.dev)
To use the secure_dotenv package, you need to add it as a dependency in your Dart project's pubspec.yaml file along with the build_runner and secure_dotenv_generator packages as dev dependencies:
dependencies:
secure_dotenv: ^1.0.0
dev_dependencies:
build_runner: ^2.4.5
secure_dotenv_generator: ^1.0.02. Usage
To generate Dart classes from a .env file using the secure_dotenv package, follow the steps below:
A. Create a Dart file in your project and import the necessary dependencies:
import 'package:secure_dotenv/secure_dotenv.dart';
import 'enum.dart' as e;
part 'example.g.dart';B. Define the environment class and annotate it with @DotEnvGen:
@DotEnvGen(
filename: '.env',
fieldRename: FieldRename.screamingSnake,
)
abstract class Env {
const factory Env(String encryptionKey) = _$Env;
const Env._();
// Declare your environment variables as abstract getters
String get name;
@FieldKey(defaultValue: 1)
int get version;
e.Test? get test;
@FieldKey(name: 'TEST_2', defaultValue: e.Test.b)
e.Test get test2;
String get blah => '2';
}C. Generate the Dart classes by running the following command in your project’s root directory:
NOTE: Encryption keys must be 128, 192, or 256 bits long. If you want to encrypt sensitive values, you can run the following command:
$ dart run build_runner build --define secure_dotenv_generator:secure_dotenv=ENCRYPTION_KEY=encryption_key --define secure_dotenv_generator:secure_dotenv=IV=your_ivwhere encryption_key is the encryption key you want to use to encrypt sensitive values and your_iv is the initialization vector.
You can also ask secure_dotenv to generate these automatically and output them into a file:
$ dart run build_runner build --define secure_dotenv_generator:secure_dotenv=OUTPUT_FILE=encryption_key.jsonIf you don’t want to encrypt sensitive values, you can run the following command instead:
$ dart run build_runner buildThis command will generate the required Dart classes based on the .env file and the annotations in your code.
D. Use the generated class in your code:
void main() {
final env = Env('encryption_key'); // Provide the encryption key
print(env.name); // Access environment variables
print(env.version);
print(env.test);
print(env.test2);
print(env.blah);
}The secure_dotenv package simplifies the process of generating Dart classes from a .env file while encrypting sensitive values. By using this package, you can ensure that your environment variables are securely stored and accessed in your Dart application.
B. Use Environment Variables
When developing mobile apps, maintaining security while managing app configurations is a top priority. Firebase Remote Config not only allows developers to update app behaviour and features in real-time but also offers a secure way to manage and store sensitive configurations in the cloud. With Remote Config, you can dynamically control features without exposing API keys, secrets, or other sensitive data directly in your app’s code. Additionally, Firebase provides robust security features, such as user authentication and access control, ensuring that only authorized users can modify configurations. In this article, we’ll explore how Firebase Remote Config enhances security in Flutter apps, offering a secure and flexible way to manage app settings and sensitive data remotely.
Steps to Add Firebase Remote Config to Your Flutter App:
1. Creating Firebase project:
Before everything, we need to create a Firebase project. You can follow the steps in this document.
2. Enabling Remote config in Firebase:
After creating the project, open the drop down for Run option on the left side, then choose Remote Config.
Then Press on Create Configuration Button
Side window will appear, the window contains several items:
1- Parameter name (Key): the key we will use later inside our Flutter App
2- Data type: The data type of the value this key will have, it could be (String, Number, Boolean, JSON)
3- Default Value: the initial value this key will have and will be the same type as the data type item selected.
there is a Use in-app default toogle which indicate that the default value that is assigned or configured inside the flutter app will be used or not.
4- After Saving, you will see the following screen, at the bottom is the configuration keys and values you created, and at the top there is publish changes button.
You must press Publish Changes button every time you create or update a value in the firebase dashboard to make it available for the app to fetch it.
Now we are ready to start coding in our flutter app to use this configuration.
3. Add the needed dependencies:
- Add the dependencies to your
pubspec.yaml:
dependencies:
firebase_core: ^3.4.1
firebase_remote_config: ^5.1.12. Create FirebaseRemoteConfigKeysclass in the utils/remote_config/FirebaseRemoteConfigKeys.dart
class FirebaseRemoteConfigKeys {
// This is the Key we added in the configuration in firebase remote config
static const String isAppSecurityEnabledKey = 'isAppSecurityControllsEnabled';
static const String apiKeyFromRemoteConfig = 'API_KEY_FROM_REMOTE_CONFIG';
}
}3. Create FirebaseRemoteConfigServiceclass in the utils/remote_config/FirebaseRemoteConfigService.dart
import 'package:firebase_remote_config/firebase_remote_config.dart';
import 'package:flutter/material.dart';
import 'FirebaseRemoteConfigKeys.dart';
class FirebaseRemoteConfigService {
FirebaseRemoteConfigService._()
: _remoteConfig = FirebaseRemoteConfig.instance;
static FirebaseRemoteConfigService? _instance;
factory FirebaseRemoteConfigService() =>
_instance ??= FirebaseRemoteConfigService._();
final FirebaseRemoteConfig _remoteConfig;
/*
Create get classes for each Data type you have in the configuration,
for example here, I have 2 configs, 1 boolean and 1 String
So I created 2 functions, one for String and one for boolean
*/
String getString(String key) => _remoteConfig.getString(key);
bool getBool(String key) => _remoteConfig.getBool(key);
Future<void> _setConfigSettings() async => _remoteConfig.setConfigSettings(
RemoteConfigSettings(
// this is the duration for the fetching request before timeout
fetchTimeout: const Duration(minutes: 10),
// this is the duration for checking and fetching the new values
minimumFetchInterval: const Duration(seconds: 5),
),
);
// Setting default value to use if failed to fetch from firebase
Future<void> _setDefaults() async => _remoteConfig.setDefaults(
const {
FirebaseRemoteConfigKeys.isAppSecurityEnabledKey: false,
FirebaseRemoteConfigKeys.apiKeyFromRemoteConfig: ""
},
);
Future<void> fetchAndActivate() async {
bool updated = await _remoteConfig.fetchAndActivate();
if (updated) {
debugPrint('The config has been updated.');
} else {
debugPrint('The config is not updated..');
}
}
Future<void> initialize() async {
await _setConfigSettings();
await _setDefaults();
await fetchAndActivate();
}
}4. Let’s see this class in Action, In your main function
import 'package:firebase_core/firebase_core.dart';
import 'core/utils/src/remote_config_helper/FirebaseRemoteConfigKeys.dart';
import 'core/utils/src/remote_config_helper/FirebaseRemoteConfigService.dart';
import 'firebase_options.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// your code .....
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
await FirebaseRemoteConfigService().initialize();
final remoteConfig = FirebaseRemoteConfigService();
bool? securityEnabled=
remoteConfig.getBool(FirebaseRemoteConfigKeys.isAppSecurityEnabledKey);
String? api_key=
remoteConfig.getString(FirebaseRemoteConfigKeys.apiKeyFromRemoteConfig);
if (securityEnabled != null && securityEnabled) {
// your code to be executed if value is true
} else{
// your code to be executed if value is false or null
}
if (api_key!= null && api_key.isNotEmpty) {
// your code to be executed if api key fetched and not an empty string
} else{
// your code to be executed if value is null or empty string
}
// your code .....
}That’s it, you now can use the values fetched from the remote config, and also change it from the firebase project directly and it will be updated inside the app without the need to store the keys in the code or need to build a new APK or bundle to upload to store every time the key expired or need to be changed.
C. Getting the API Keys and Secrets from API:
Of course, you can store the keys or secrets you want in the server and retrieve it at the beginning of the app from a REST API and then use it but be sure to handle the security of the connection with the Endpoint to prevent the leak of the keys.
2. Rate Limiting and CAPTCHA
In the world of mobile app development, preventing abuse and ensuring security are critical concerns, especially when handling sensitive APIs and user-generated content. Rate limiting and CAPTCHA are two essential techniques used to protect your Flutter app from malicious activity, such as spamming, DDoS attacks, and bot interactions. Rate limiting helps control the number of requests a user can make within a specific time frame, while CAPTCHA ensures that users interacting with your app are genuine humans and not automated bots.
A. Rate Limiting:
Implementing rate limiting from the Flutter app side requires managing the number of API requests that can be made within a specific time frame. Although rate limiting is typically enforced server-side, you can simulate this behaviour client-side by using a combination of timers and counters to track requests and ensure limits are respected.
Examples:
1- Sending OTP Requests can be handled from the server-side to only be executed every 2 minutes, but we can handle it also from the Client-side by tracking the time between the previous request of this Endpoint and if the 2 minutes passed, the user can request a new OTP.
2- Limiting how many times the App can call an API per minute (try to implement this in a simple way by yourself 😉)
B. Captcha:
CAPTCHA (Completely Automated Public Turing test to tell Computers and Humans Apart) is a method used to determine whether a user is a human or a bot. CAPTCHAs are used to protect apps from spam, automated scripts, and brute-force attacks by adding an extra layer of security. Common CAPTCHA type is Google reCAPTCHA.
Benefits of CAPTCHA:
- Preventing Automated Abuse: Bots can flood your app with fake registrations, spamming, and other malicious activities. CAPTCHA ensures only human users can perform certain actions.
- Reducing Brute-Force Attacks: CAPTCHAs can be used during login attempts to prevent bots from repeatedly trying different passwords.
- Mitigating Fake Traffic: CAPTCHA helps filter out fake traffic that could slow down or crash your app due to excessive load caused by bots.
Implementing CAPTCHA in Flutter:
To add CAPTCHA in a Flutter app, the most popular solution is Google reCAPTCHA, which provides an easy-to-integrate and secure way of verifying users.
Steps to Implement Google reCAPTCHA:
- Sign up for reCAPTCHA: Go to the Google reCAPTCHA site and register your app. You’ll get a site key and a secret key, but make sure to do the following:
A. Add localhost to the Domains Section
B. Add your APIs server domain to the Domains Section
C. Use Score based (v3) ReCAPTCHA Type - In your Flutter App, in the assets folder, create HTML file for example
assets/recaptcha.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://www.google.com/recaptcha/api.js?render=YOUR SITE KEY" async defer></script>
<title>reCAPTCHA</title>
<style>
/* Apply the background color immediately */
body {
background-color: #FFFFFF;
margin: 0;
padding: 0;
width: 100%;
height: 50%;
display: flex;
align-items: center;
justify-content: center;
}
#recaptcha {
display: none; /* Hide the div until recaptcha is loaded */
}
</style>
</head>
<body>
<div >
<div id="recaptcha"></div>
</div>
<script type="text/javascript">
function executeRecaptcha() {
grecaptcha.ready(function() {
grecaptcha.execute('YOUR SITE KEY', { action: 'homepage' }).then(function(token) {
// Send the token back to Flutter using a JavaScript channel
if (typeof Captcha !== "undefined") {
Captcha.postMessage(token);
}
document.getElementById('recaptcha').style.display = 'block';
});
});
}
// Delay execution until the script has fully loaded
window.onload = function() {
executeRecaptcha();
};
</script>
</body>
</html>3. Let’s Create a functions to handle the recaptcha token
bool showRecaptcha = false; // Controls the visibility of the reCAPTCHA widget
Completer<String?>? _completer;
// Method to handle the token received from the reCAPTCHA widget
void setRecaptchaToken(String token) {
if (_completer != null && !_completer!.isCompleted) {
_completer?.complete(token);
}
}
Future<bool> checkRecaptcha(context) async {
_completer = Completer<String?>();
// Show the reCAPTCHA widget
showRecaptcha = true;
notifyListeners();
try {
// Store the token recieved from the widget in the captchaToke
captchaToken = await _completer?.future;
log('new recaptcha : ${captchaToken} \n \n');
showRecaptcha = false; // Hide the reCAPTCHA widget
notifyListeners();
return true;
} on PlatformException catch (e) {
log("Failed Recaptcha: '${e.message}'.");
return false;
}
}4. Now Let’s create the widget to show the reCAPTCHA verification:
bool isRecaptchaLoading = true;
@override
Widget build(BuildContext context) {
return Column(
children: [
// Your Widgets .....
Selector<UserAuthProvider, bool>(
selector: (_, provider) => provider.showRecaptcha,
builder: (_, showRecaptcha, __) {
return showRecaptcha
? Column(
children: [
SizedBox(height: AppDimensions.convertToH(25)),
_buildRecaptchaWidget(context),
if (isRecaptchaLoading)
AppLoadingWidget(
loadingHeight: AppDimensions.convertToH(50)),
],
)
: SizedBox.shrink();
},
),
]);
}
_buildRecaptchaWidget(BuildContext context) {
return Container(
height: AppDimensions.convertToH(100),
color: Colors.transparent,
child: WebViewPlus(
zoomEnabled: false,
javascriptMode: JavascriptMode.unrestricted,
onPageFinished: (url) {
setState(() {
isRecaptchaLoading = false;
});
},
onWebViewCreated: (controller) {
controller.loadUrl("assets/recaptcha.html");
},
javascriptChannels: Set.from([
JavascriptChannel(
name: 'Captcha',
onMessageReceived: (JavascriptMessage receiver) {
String token = receiver.message;
if (token.contains("verify")) {
token = token.substring(7);
}
// Send the token back to the provider
Provider.of<UserAuthProvider>(context, listen: false)
.setRecaptchaToken(token);
},
),
]),
),
);
}5. Finally, the call for all this to operate for example when pressing Login Button:
onTap: () {
if (await Provider.of<UserAuthProvider>(context, listen: false)
.checkRecaptcha(context)) {
print('Token Retrieved from Google reCAPTCHA');
}
}6. Now, we generated a reCAPTCHA Token and all what remains is to send it to the Backend to verify that, this token is valid (Server-side will use the site key and secret key to verify the validity of the token you send from the client-side)
We are done now and the Google reCAPTCHA is added to our App ✅
3. Clearing Logs and Non-Debugging prints
During development, logging and print statements are essential for debugging and monitoring the behaviour of your Flutter app. However, leaving unnecessary logs and print statements in production code can lead to performance issues, security risks, and cluttered logs that make it harder to diagnose real problems. Clearing logs and non-debugging print statements before releasing your app ensures that your production environment remains clean, secure, and efficient.
This is super simple, all you have to do is to make sure that any log or print must be executed only in debug mode, you can do so by creating a simple function to log or print what you want for example:
void logMessage(String message) {
if (!kReleaseMode) {
// Only log or print during development
print(message);
}
}if you already have a project with too many files and too many print statements without handling to print in debug mode only, you can use this package : print_remover | Dart package (pub.dev) to handle it for you.
this Package will delete every print statement in your project.
1. Add the dependency to your pubspec.yaml:
dependencies:
print_remover: ^1.0.12. Run the following command in terminal:
flutter pub run print_remover:mainThat’s it, now all the print statements in your project will be deleted ✅
Conclusion
Securing Flutter apps requires a multi-layered approach to protect sensitive data and ensure users’ privacy. By implementing methods like code obfuscation and enforcing HTTPS with SSL pinning, you can safeguard your app from reverse engineering and man-in-the-middle attacks. App integrity checks and secure data storage further enhance protection against tampering and data leaks. Adding biometric authentication and preventing screen captures helps maintain the privacy of user interactions, while avoiding hardcoding secrets, rate limiting, and CAPTCHA protect your APIs from unauthorized access. Finally, clearing logs and removing non-debugging prints ensure that no sensitive information is left exposed in production.
By adopting these security practices, you create a robust, secure environment that not only protects your Flutter app but also fosters trust with your users. Remember, app security is an ongoing process, and as threats evolve, so must your strategies. Regularly audit and update your app’s security measures to keep it resilient against emerging risks.
Your Journey Through Flutter Security Ends Here — But the Conversation Doesn’t Have To! 🙋♂️
Thank you so much for sticking with me through all four parts of this article series on securing Flutter apps! 🎉 I hope you found the tips useful and feel more confident in keeping your apps secure. If you have any questions, suggestions, or just want to chat about anything in the article, feel free to connect with me on LinkedIn: Ahmed Ayman | LinkedIn I’d love to hear your thoughts and ideas 💬😊.
🛡️ This is Part 4/ 4 for Securing Flutter App🛡️
📱Securing Flutter App: Best Practices and Techniques:
Part #1: 📱Securing Flutter App: Best Practices and Techniques (Part 1)🛡️
Part #2: 📱Securing Flutter App: Best Practices and Techniques (Part 2)🛡️
Part #3: 📱Securing Flutter App: Best Practices and Techniques (Part 3)🛡️
Part #4: 📱Securing Flutter App: Best Practices and Techniques (Part 4)🛡️
