One of the most important aspects of a banking app is security. Everybody wants to keep their wealth in a safe place. In today’s world, money drives almost everything, and we always want access to our cash. Well, how can we do that? One of the most common methods is using the device we take everywhere: our phones.
So, if we want to make an application that manages the users’ money, we have to ensure it is as secure as possible. In this article, we will learn about the essential aspects of building secure mobile banking apps with Flutter and look at sample codes on how to achieve them using the Flutter framework.
Jump ahead:
Mobile banking applications often communicate with backend servers. One of the most important things to consider is using a secure layer for this communication. Luckily, HTTPS
comes to the rescue and prevents others from sniffing the data we send and receive from our backend system.
HTTPS
works the same way as the simple HTTP
does, but inserts a secure layer between the TCP
communication layer and the HTTPS
protocols TLS
or SSL
.
HTTPS
with FlutterSecure Sockets Layer, SSL
was the first definition of how messages can be secured in network communication. After a couple of iterations, Transport Layer Security TSL
was defined and replaced SSL
. TLS
defines a secure communication method for client-server applications. It provides public and private key pairs to secure the messages between the client and the server.
When the client connects to the server, it will receive its public key. Every message that the client sends to the server is encrypted based on the public key. Only the server, which has the private key, can decrypt the messages. If somebody can sniff the network communication, they won’t be able to read the original content of the request.
How can we use this in our application? It is straightforward. We will only communicate with APIs that provide HTTPS
entry points. That’s it; we are done, right? Well, unfortunately, not.
To be sure that an endpoint with HTTPS
can be trusted, the server should provide a certificate signed by trusted certificate authorities. Browsers check this and decide whether the endpoint has a valid, trustable certificate. Based on this, they will show the user a warning.
When building Flutter applications that communicate with a server through HTTPS
, we can rely on packages like http and dio. The only thing we need to do is to use a URL starting with HTTPS
in our requests.
An example with dio
should look like this:
var response = await Dio().get('https://www.google.com');
Sometimes, the backend system can use a self-signed certificate. This happens when you build your own backend and run it on your machine while developing. A self-signed certificate means it is signed by you, not by a trusted authority.
It is easy and free to create but does not provide security. So, you will get an exception when you want to communicate with such an API. You can bypass the check of the certificate validity by assigning a custom certificate callback to your HTTP client
:
(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (client) { client.badCertificateCallback=(X509Certificate cert, String host, int port){ if(kDebugMode){ // Accept it only in debug mode. return true; } return false; }; };
Not every application wants to store its data outside the device. If you want to keep everything on the user’s phone and still store the data securely, you will need some kind of encrypted data storage. It’s also helpful to have a place to store tokens, which provides access to your API. The following packages can help in Flutter:
The flutter_secure_storage package provides a key-value store where you can keep your little secrets, which will be encrypted. It is very similar to shared_preferences, with the additional security layer.
Let’s see an example:
The following widget displays and modifies our little secret:
// my_secret.dart import 'package:flutter/material.dart'; /// This widget displays and modifies your little secret. class MySecret extends StatelessWidget { const MySecret({ super.key, required this.readMySecret, required this.changeMySecret, required this.deleteMySecret, }); /// A callback that can read the secret. final Future<String?> Function() readMySecret; /// A callback that can change the value of the secret. final Future<void> Function() changeMySecret; /// A callback that can delete your secret. final Future<void> Function() deleteMySecret; @override Widget build(BuildContext context) { return Center( child: Column( children: [ Text( 'My secret', style: Theme.of(context).textTheme.headline3, ), FutureBuilder( future: readMySecret(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { return Text(snapshot.data ?? 'Provide your little secret'); } return const CircularProgressIndicator(); }, ), ElevatedButton( onPressed: changeMySecret, child: const Text('Change'), ), ElevatedButton( onPressed: deleteMySecret, child: const Text('Delete'), ), ], ), ); } }
Let’s use the widget above and configure it with the flutter_secure_storage functionalities:
// secure_storage_demo.dart import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:secure_banking_app/change_secret_dialog.dart'; import 'package:secure_banking_app/my_secret.dart'; const _mySecretValueKey = 'mySecretValue'; /// Displays the usage of the flutter_secure_storage example. class SecureStorageDemo extends StatefulWidget { const SecureStorageDemo({super.key}); @override State<SecureStorageDemo> createState() => _SecureStorageDemoState(); } class _SecureStorageDemoState extends State<SecureStorageDemo> { /// Create a new instance of the secure storage late final _secureStorage = const FlutterSecureStorage(); /// Changes the current secret value by showing an edit dialog Future<void> _changeMySecret() async { final scaffoldMessenger = ScaffoldMessenger.of(context); /// Read the actual value final currentSecret = await _secureStorage.read(key: _mySecretValueKey); // Display a dialog with a text field where the user can change their secret. final newSecret = await showDialog( context: context, builder: (context) => ChangeSecretDialog( currentSecret: currentSecret, ), ); if (newSecret != null) { // Save the secret value in the secure storage. await _secureStorage.write(key: _mySecretValueKey, value: newSecret); setState(() {}); scaffoldMessenger.showSnackBar( const SnackBar( content: Text( 'Your little secret is updated.', ), ), ); } } /// Removes the current value. Future<void> _deleteMySecret() async { final scaffoldMessenger = ScaffoldMessenger.of(context); // Deletes a key from the secure storage. await _secureStorage.delete(key: _mySecretValueKey); setState(() {}); scaffoldMessenger.showSnackBar( const SnackBar( content: Text( 'Your little secret is removed.', ), ), ); } @override Widget build(BuildContext context) { return MySecret( readMySecret: () => _secureStorage.read(key: _mySecretValueKey), changeMySecret: _changeMySecret, deleteMySecret: _deleteMySecret, ); } }
It is very easy to use the library; you just have to import it and create a new instance to the secure storage:
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; late final secureStorage = const FlutterSecureStorage();
When you want to access a value in the secure storage saved under a particular key, you can call:
final currentSecret = await secureStorage.read(key: _mySecretValueKey);
To change it, you can write a new value. Beware, if the value is null
, it will delete the key from the storage:
await secureStorage.write(key: _mySecretValueKey, value: newSecret);
Or you can remove your little secret from the storage by:
await secureStorage.delete(key: _mySecretValueKey);
The biometric_storage provides a very similar approach with some additional features. It can store small data similar to the flutter_secure_storage package but optionally secures it with a biometric lock. The user can only access the data in the storage if they unlock it with their fingerprint, face id, pin, or pattern. You’ll need to follow the installation steps for each platform to use the package in your app.
We can reuse the MySecret
widget to display and modify the secrets in our biometric storage. We just need to create the callbacks for the operations:
// biometric_storage_demo.dart import 'package:biometric_storage/biometric_storage.dart'; import 'package:flutter/material.dart'; import 'package:secure_banking_app/my_secret.dart'; import 'change_secret_dialog.dart'; const _myStorageName = 'mystorage'; /// Displays the content of the secret storage and provides operations to /// change its value. class BiometricStorageDemo extends StatelessWidget { const BiometricStorageDemo({super.key}); @override Widget build(BuildContext context) { return FutureBuilder( // Let's check whether the device supports the biometric storage. future: BiometricStorage().canAuthenticate(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { if (snapshot.data != CanAuthenticateResponse.success) { return const Center( child: Text('Can not use biometric storage'), ); } // We have the functionality, show the content. return _BiometricStorageProvider( builder: (secureStorage) => _BiometricStorageDemoPage( secureStorage: secureStorage, ), ); } return const Center( child: CircularProgressIndicator(), ); }, ); } } /// Provides a secure storage through its builder. class _BiometricStorageProvider extends StatelessWidget { const _BiometricStorageProvider({ Key? key, required this.builder, }) : super(key: key); /// Bulder function that provides access to the secure storage. final Widget Function(BiometricStorageFile) builder; @override Widget build(BuildContext context) { return FutureBuilder( future: BiometricStorage().getStorage(_myStorageName), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) { return builder(snapshot.data!); } return const Center( child: CircularProgressIndicator(), ); }); } } /// Displays the content of the secure storage and operations to manipulate /// it. class _BiometricStorageDemoPage extends StatefulWidget { const _BiometricStorageDemoPage({ super.key, required this.secureStorage, }); final BiometricStorageFile secureStorage; @override State<_BiometricStorageDemoPage> createState() => _BiometricStorageDemoPageState(); } class _BiometricStorageDemoPageState extends State<_BiometricStorageDemoPage> { /// Changes the current secret value by showing an edit dialog Future<void> _changeMySecret() async { final scaffoldMessenger = ScaffoldMessenger.of(context); /// Read the actual value final currentSecret = await widget.secureStorage.read(); // Display a dialog with a text field where the user can change their secret. final newSecret = await showDialog( context: context, builder: (context) => ChangeSecretDialog( currentSecret: currentSecret, ), ); if (newSecret != null) { // Save the secret value in the secure storage. await widget.secureStorage.write(newSecret); setState(() {}); scaffoldMessenger.showSnackBar( const SnackBar( content: Text( 'Your little secret is updated.', ), ), ); } } /// Removes the current value. Future<void> _deleteMySecret() async { final scaffoldMessenger = ScaffoldMessenger.of(context); // Deletes a key from the secure storage. await widget.secureStorage.delete(); setState(() {}); scaffoldMessenger.showSnackBar( const SnackBar( content: Text( 'Your little secret is removed.', ), ), ); } @override Widget build(BuildContext context) { return MySecret( readMySecret: widget.secureStorage.read, changeMySecret: _changeMySecret, deleteMySecret: _deleteMySecret, ); } }
The package handles secrets in a file-based way. It can create an encrypted file that is protected by your biometrics, and you can put any string into it. If you want to store multiple values, you have to create various storages. This differs from the flutter_secure_storage package, where you can add multiple values with different keys to the store.
The first thing we should do is to check whether the device has any support for biometric authentication with BiometricStorage().canAuthenticate(), // Check whether the device supports biometric auth
.
If the device supports it, we need to create a new storage using BiometricStorage().getStorage(_myStorageName)
. This will create a myStorageName
file encrypted and protected by biometrics. You can save any string into this storage. If you need other types, you have to provide the conversion from and to type String
.
You can read the data from the storage with await secureStorage.read()
and change the value with write() await widget.secureStorage.write(newSecret);
.
If you don’t want to keep your secret
, you can remove it by using await widget.secureStorage.delete();
.
The packages mentioned before are great when we only want to store a small amount of data, like tokens or passwords. But sometimes, we need a way to store more than that. if we’re going to encrypt all of our data, we can use the hive database in our application.
Hive is a key-value database that provides fast access to your data and is written in dart. It is very easy to integrate it into a Flutter application.
Hive works with boxes
which are similar to Map
structures. Once you create a box, you can put and get the values based on its key. A box can contain any type of value, but you can restrict it to only one type:
var box = await Hive.openBox('account'); await box.put('balance', 42.0); double balance = box.get('balance'); await box.close();
Hive can also work with custom objects and provides a code generator to make your life easier:
@HiveType(typeId: 0) class Account extends HiveObject { @HiveField(0) double balance; } final account = Account(); account.balance = 42.0; box.add(account); // Hive assigns a key automatically account.balance = 21.0; account.save();
You can tell Hive to encrypt the content of a box when you open it:
final encryptedBox= await Hive.openBox( 'account', encryptionCipher: HiveAesCipher('encryption key'), );
Hive will only encrypt the data, not the keys
. You will need to use the same encryption key
because Hive does not check it when it opens the box. You can generate a new one with the following:
final key = Hive.generateSecureKey();
Since you need to have the same key
even after the application is closed and opened again, you will need to store it securely. You can use, for example, the flutter_secure_storage for it.
Let’s see a complete example:
import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:hive_flutter/hive_flutter.dart'; const _boxName = 'account'; const _encryptionKey = 'boxEncryptionKey'; const _balanceKey = 'balance'; class HiveDemo extends StatelessWidget { const HiveDemo({super.key}); /// Opens the Hive box Future<void> _openBox() async { // Initialize Hive for Flutter await Hive.initFlutter(); // Read the key used for encryption. const secureStorage = FlutterSecureStorage(); String? encryptionKey = await secureStorage.read(key: _encryptionKey); if (encryptionKey == null) { // Create a new key if it does not exists. final key = Hive.generateSecureKey(); // Add it to the secure storage. await secureStorage.write( key: _encryptionKey, value: base64UrlEncode(key), ); } // Read the key from the secure storage. final key = await secureStorage.read(key: _encryptionKey); final encryptionKeyBytes = base64Url.decode(key!); // Open the box with the encryption key. await Hive.openBox<double>( _boxName, encryptionCipher: HiveAesCipher(encryptionKeyBytes), ); } @override Widget build(BuildContext context) { return FutureBuilder( future: _openBox(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { return const _HiveDemoContent(); } return const Center( child: CircularProgressIndicator(), ); }, ); } } /// Displays the balance and modifies it. class _HiveDemoContent extends StatelessWidget { const _HiveDemoContent({super.key}); @override Widget build(BuildContext context) { // ValueListenableBuilder reacts to changes in the given box. // It will run its builder method when any value in the box changes. return ValueListenableBuilder( valueListenable: Hive.box<double>(_boxName).listenable(), builder: (context, box, child) => Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( 'Balance', style: Theme.of(context).textTheme.headline3, ), // Get the current value of the balance from the box. Text( box.get(_balanceKey, defaultValue: 0.0).toString(), ), ElevatedButton( onPressed: () { // Get the balance value from the box. final balance = box.get(_balanceKey, defaultValue: 0.0); // Update it with the new value. box.put(_balanceKey, balance! + 5.0); }, child: const Text('Add \$5'), ) ], ), ), ); } }
The critical part here is how you open your box. The _openBox()
method tries to read the key we used previously for the encryption by using the flutter_secure_storage package. If the key does not exist, we create a new one and save it in the storage. Once we have the key, we open the box. If the box is open, we can start to use it. The ValueListenableBuilder
can watch a box for changes and update the UI through its builder
method. All the data we put in the box will be encrypted so nobody can read the physical file that stores the box’s content on the file system.
When managing sensitive data, for example, managing the user’s money, it is essential to not allow anybody to enter the application. Storing its data in an encrypted way makes it safe to not allow reading the data outside of the application. You can use the device’s biometric lock to prevent access to the app’s features. You can add such authentication to your Flutter app by using the local_auth package. Follow the platform-specific installation instructions of the package before you start to use it:
// local_auth_demo.dart import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:local_auth/local_auth.dart'; /// Displays sensitive information only after the user has authenticated with /// biometrics. class LocalAuthDemo extends StatefulWidget { const LocalAuthDemo({super.key}); @override State<LocalAuthDemo> createState() => _LocalAuthDemoState(); } class _LocalAuthDemoState extends State<LocalAuthDemo> { final auth = LocalAuthentication(); /// Check whether the device supports authentication Future<bool> _isLocalAuthAvailableOnDevice() async { /// Check whether the device is capable of using biometrics final isSupported = await auth.isDeviceSupported(); if (!isSupported) { return false; } try { /// Check whether it is configured on the device. return auth.canCheckBiometrics; } on PlatformException catch (e) { return false; } } @override Widget build(BuildContext context) { return FutureBuilder( future: _isLocalAuthAvailableOnDevice(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { if (snapshot.hasData && snapshot.data!) { return _LocalAuthenticator(auth: auth); } return const Center( child: Text( 'Can not authenticate with biometrics. PLease enable it on your device.', ), ); } return const Center( child: CircularProgressIndicator(), ); }, ); } } /// Does the authentication and displays the sensitive information based on its /// result class _LocalAuthenticator extends StatelessWidget { const _LocalAuthenticator({ super.key, required this.auth, }); final LocalAuthentication auth; /// Authenticate the user with biometrics. The method will be chosen by /// the OS. Future<bool> _authenticate() => auth.authenticate( localizedReason: 'Please auth yourself', options: const AuthenticationOptions( stickyAuth: true, ), ); @override Widget build(BuildContext context) { return FutureBuilder( future: _authenticate(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { if (snapshot.data!) { return const Center( child: Text('Welcome! You can see your sensitive data.'), ); } return const Center( child: Text('Sorry, but you are not allowed to access to this page.'), ); } return const Center( child: CircularProgressIndicator(), ); }, ); } }
When using the local_auth package, we have to check whether the device is capable of biometric authentication and is configured correctly:
// Check whether the device is capable of using biometrics final isSupported = await auth.isDeviceSupported(); // ... try { /// Check whether it is configured on the device. return auth.canCheckBiometrics; } on PlatformException catch (e) { return false; }
Once we know we can use biometrics, we can authenticate the user with the authenticate()
function:
await authenticate( localizedReason: 'Please auth yourself', options: const AuthenticationOptions( stickyAuth: true, ), );
Another critical aspect of a secure application is to hide sensitive data when the app is not running. Usually, there is a preview of the application when the user switches among the running apps. With the secure_application package, you can add an overlay to your app to hide its content when the app is in the background.
In addition to the overlay, the package will lock different parts of the application, so when the user comes back, they need to unlock it. The unlock mechanism can be implemented by us, for example, with local_auth or with any type of authentication flow. Once we can grant access to the user, we can unlock the hidden content. Let’s see it in action:
// secure_application.dart import 'package:flutter/material.dart'; import 'package:secure_application/secure_application.dart'; class SecureApplicationDemo extends StatelessWidget { const SecureApplicationDemo({super.key}); @override Widget build(BuildContext context) { // Wrap the application and mark it as a secure one. return SecureApplication( nativeRemoveDelay: 800, // This callback method will be called when the app becomes available again // and its content was hidden with an overlay. We can provide logic to // unlock the content again. Here we can use any auth logic, for example // biometrics with the local_auth package. onNeedUnlock: (secureApplicationStateNotifier) { print( 'Needs to be unlocked. You can use any auth method, e.g local_auth to unlock the content', ); return null; }, child: _SecureApplicationContent(), ); } } class _SecureApplicationContent extends StatefulWidget { const _SecureApplicationContent({super.key}); @override State<_SecureApplicationContent> createState() => _SecureApplicationContentState(); } class _SecureApplicationContentState extends State<_SecureApplicationContent> { @override void didChangeDependencies() { super.didChangeDependencies(); // Enable the feautre. This will add overlay when our app goes background. SecureApplicationProvider.of(context)?.secure(); } @override Widget build(BuildContext context) { // Wrap the sensitive part with SecureGate. // This will hide the sensitive part until we unlock the content. return SecureGate( blurr: 60, opacity: 0.8, // The content of this builder will be displayed after our app // comes back to foreground and its content was hidden with the overlay. lockedBuilder: (context, secureApplicationController) => Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('Content is locked!'), ElevatedButton( onPressed: () { // Unlock the content manually. secureApplicationController?.unlock(); }, child: Text('Unlock'), ), ], ), ), child: const Center( child: Text( 'Your sensitive data', ), ), ); } }
The secure_application package provides two valuable widgets. The first one is the SecureApplication
, which is responsible for managing the overlay and providing a controller to manage the state of the security status. The other is SecureGate
which hides its children when the content is locked. This happens when the application comes back from the background.
You first have to enable the security feature by calling the secure()
method on the controller:
SecureApplicationProvider.of(context)?.secure();
You can access the controller in the descendant widgets of the SecureApplication
. Once it is enabled, it will take care of the overlay automatically.
You must unlock the secured content through the controller to reveal sensitive data. This can be done in the onNeedUnlock
callback of the SecureApplication
:
SecureApplication( // This callback method will be called when the app becomes available again // and its content was hidden with an overlay. We can provide logic to // unlock the content again. Here we can use any auth logic, for example // biometrics with the local_auth package. onNeedUnlock: (secureApplicationStateNotifier) { // Use any authentication method. }, child: _SecureApplicationContent(), );
In this callback, you can implement any authentication method that fits your application. You can use the local_auth to authenticate the user based on biometrics or check whether the stored JWT token
is still valid.
The SecureGate
widget has two essential attributes:
SecureGate( // The content of this builder will be displayed after our app // comes back to the foreground and its content was hidden with the overlay. lockedBuilder: (context, secureApplicationController) { // Return the widgets shown when it is locked. // Unlock the app through the controller. }, child: ..., );
The child
attribute holds the sensitive data. It will be hidden if the content is locked. The lockedBuilder
callback returns what the user should see in this case. You can manually unlock the content through the secureApplicationController
parameter.
In this post, I tried to collect the different security requirements needed when implementing a Flutter app that works with sensitive data, like a mobile banking app. It is important to make it hard to access the content, so we need to always use HTTPS
when communicating with a server. When we store data locally, we should try to encrypt it and only allow access once the user verifies themself.
Another important aspect is to hide sensitive data while the user is switching between the running applications. Fortunately, the great community of Flutter already made packages that we can easily integrate into our app to make it more secure.
All the source code is available here.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.