Building Secure Fintech Apps with React Native (Part 1): Fortifying Data Storage and Network Connections


The financial technology (fintech) industry has really taken off over the past ten years. With the ease of mobile banking, the convenience of digital wallets, and the exciting realm of cryptocurrency trading, fintech apps have completely transformed how we handle our finances on a global scale.
But with this rapid expansion comes a more concerning reality: the fintech, banking, and wider financial services sectors are prime targets for cybercriminals. A striking statistic from 2023 shows that financial institutions were hit with about 27.32% of all global phishing attacks. Considering that a whopping 45% of consumers now rely on mobile apps for finance-related tasks at least once a day, the importance of app security has never been greater.
At the heart of a truly secure fintech application are three essential pillars, often referred to as the 'security triangle':
Confidentiality: Making sure that sensitive financial and personal data is only accessible to those who are authorized.
Integrity: Ensuring that all data remains untouched and accurate, from transactions to user profiles.
Availability: Guaranteeing that the service is consistently accessible whenever users need it, without interruptions from malicious attacks.
On the tech side, many founders and teams are understandably eager to get their apps up and running quickly and efficiently. Creating separate native applications for both iOS and Android can be a lengthy, expensive, and resource-heavy process, often requiring two different development teams. This is where cross-platform solutions really shine, and React Native stands out as one of the top choices, offering impressive speed and code reusability.
However, here's the important catch: while React Native provides a fantastic foundation, it doesn't automatically come with built-in security for every complex scenario, especially for the demanding needs of a financial application.
So, join us for this three-part series where we’ll explore how to make the most of React Native and the right external tools to create secure and reliable fintech or banking applications. In the first part, we’ll concentrate on the key security layers: ensuring data is protected both when it’s stored on the device and during transmission. This isn’t just theoretical; these insights are drawn from real-world experiences and extensive research, along with input from seasoned developers who collaborate with banks and fintech companies. By the end of this series, you’ll have a clear strategy to enhance your app’s security and gain your users’ trust.
Core Data & Communication Security: Guarding the Digital Highway
A. Secure Data Storage: Your App's Fort Knox
In fintech, data is currency. Storing sensitive details like user tokens or cached personal data on the device demands the highest protection. If a user's phone is lost or stolen, easily accessible data can lead to disaster.
Why it matters (Business Benefit): Secure data storage directly protects user privacy and financial assets from device breaches. It's crucial for maintaining trust, ensuring regulatory compliance (e.g., GDPR, PCI DSS), and preventing significant financial/reputational damage.
How to Implement (React Native Perspective): Forget AsyncStorage
for sensitive data; it's unencrypted. For true security, use a layered approach with encrypted solutions:
For Small, Highly Sensitive Data (Keys, Tokens): Use Native Secure Storage.
Leverage iOS Keychain and Android Keystore.
react-native-keychain
: This library seamlessly bridges to these native secure enclaves.
For Fast, Encrypted Key-Value Storage:
react-native-mmkv
: A lightning-fast, C++ based, encrypted key-value solution, ideal for a secureAsyncStorage
replacement.
Example Concept: Storing an Authentication Token Securely (using react-native-keychain
)
import * as Keychain from 'react-native-keychain';
interface StoreAuthTokenOptions extends Keychain.Options {
service?: string;
}
interface GetAuthTokenOptions extends Keychain.Options {
service?: string;
}
// --- Storing a token ---
export const storeAuthToken = async (
username: string,
token: string,
options: StoreAuthTokenOptions = {}
): Promise<void> => {
const { service = 'auth_token_service', ...rest } = options;
try {
await Keychain.setGenericPassword(username, token, {
service,
...rest,
});
console.log(`Token stored for service: ${service}`);
} catch (error) {
console.error('Error storing token:', error);
}
};
// --- Retrieving a token ---
export const getAuthToken = async (
options: GetAuthTokenOptions = {}
): Promise<string | null> => {
const { service = 'auth_token_service', ...rest } = options;
try {
const credentials = await Keychain.getGenericPassword({
service,
...rest,
});
return credentials ? credentials.password : null;
} catch (error) {
console.error('Error retrieving token:', error);
return null;
}
};
// Example Usage:
// await storeAuthToken('user@example.com', 'your_super_secret_jwt_token', { service: 'my_service' });
// const token = await getAuthToken({ service: 'my_service' });
Example concept: react-native-mmkv
setup
import { MMKV, Mode } from 'react-native-mmkv'
export const storage = new MMKV({
id: ``,
path: `${USER_DIRECTORY}/storage`,
encryptionKey: 'YOUR ENCRYPTED KEY FROM KEYCHAIN',// GET KEY FROM KEYCHAIN
mode: Mode.MULTI_PROCESS,
readOnly: false
})
By combining react-native-keychain
, react-native-mmkv
, you create a robust, multi-layered defense. This ensures your app's local data is encrypted and often hardware-protected, making compromise extremely difficult and solidifying user trust in your fintech application.
B. End-to-End (E2E) Encryption: Protecting Sensitive Payloads
Even with secure connections in place, debugging tools can quietly pose a risk, making it easy to intercept API calls and expose sensitive data in plain text. This vulnerability underscores the importance of End-to-End Encryption (E2EE) as a crucial security feature for any fintech app. E2EE ensures that sensitive information—like personal details, transaction data, and payment credentials—is encrypted right from the moment it leaves the user's device until it reaches the server. This way, even if someone is monitoring network traffic, they can't intercept or access the data without authorization.
Why it matters (Business Benefit): Implementing E2EE builds profound customer trust by guaranteeing data privacy. It significantly reduces the risk of data breaches and mitigates exposure from common vulnerabilities like intercepted network traffic or debugging tool access. For a fintech business, this translates directly into enhanced reputation, reduced financial liability, and stronger compliance with data protection regulations.
How to Implement (React Native Perspective): In React Native, you can achieve this application-layer encryption for your sensitive data using a combination of powerful libraries:
Cryptographic Logic (
crypto-js
):crypto-js
: This versatile JavaScript library provides the algorithms (e.g., AES) needed to encrypt and decrypt your data payloads within the app.
Secure Key Management (
react-native-keychain
/react-native-encrypted-storage
):The encryption key used by
crypto-js
is critical and must be stored securely on the device.react-native-keychain
: Ideal for storing the master encryption key, leveraging native secure storage (Keychain/Keystore).react-native-encrypted-storage
: Can be used to store larger encrypted data blobs, potentially secured with a key retrieved fromreact-native-keychain
.
Example Concept: Encrypting a Transaction Payload with crypto-js
import CryptoJS from 'crypto-js';
import * as Keychain from 'react-native-keychain';
/**
* @interface TransactionDetails
* Defines the structure for transaction data that will be encrypted.
* You should replace this with the actual structure of your transaction objects.
*/
interface TransactionDetails {
amount: number;
currency: string;
recipient: string;
description?: string;
[key: string]: any; // Allow for additional properties
}
/**
* @interface DecryptedTransaction
* Defines the structure for the decrypted transaction data.
* This should match TransactionDetails or a subset of it, based on what's expected after decryption.
*/
interface DecryptedTransaction extends TransactionDetails {}
/**
* Service name for storing the encryption key in Keychain.
* It's good practice to make this a constant.
*/
const ENCRYPTION_KEY_SERVICE = 'app_data_encryption_key';
/**
* Placeholder for a hardcoded key.
* IMPORTANT: In a production environment, never use a hardcoded key like this.
* Keys should be securely generated, derived (e.g., from a strong master password),
* or exchanged via a secure key agreement protocol with a backend.
* This is for demonstration purposes only.
*/
const DEMO_FALLBACK_KEY = 'YOUR_SUPER_SECRET_FALLBACK_KEY_DEMO_ONLY'; // REPLACE THIS IN PRODUCTION!
/**
* Retrieves the symmetric encryption key from the device's secure Keychain.
* If no key is found, it attempts to set a new one (e.g., a derived key or a fallback).
* In a real application, you'd want more sophisticated key generation/derivation logic.
*
* @returns {Promise<string | null>} The encryption key as a string, or null if it cannot be retrieved or generated.
*/
const getEncryptionKey = async (): Promise<string | null> => {
try {
// Try to get the key from Keychain first
const credentials = await Keychain.getGenericPassword({ service: ENCRYPTION_KEY_SERVICE });
if (credentials && credentials.password) {
console.log('Encryption key retrieved successfully from Keychain.');
return credentials.password;
} else {
console.warn('No encryption key found in Keychain. Generating a new one (DEMO FALLBACK).');
// In a real app:
// 1. Generate a strong, random key (e.g., using a crypto library's secure random byte generator)
// or derive it from user's biometrics/PIN with a KDF (Key Derivation Function).
// 2. Store this newly generated key securely in Keychain.
// For demonstration, we'll store the fallback key if none exists.
// NEVER DO THIS IN PRODUCTION.
await Keychain.setGenericPassword(ENCRYPTION_KEY_SERVICE, DEMO_FALLBACK_KEY, { service: ENCRYPTION_KEY_SERVICE });
console.log('Demo fallback key stored in Keychain.');
return DEMO_FALLBACK_KEY;
}
} catch (error) {
console.error('Failed to retrieve or set encryption key from Keychain:', error);
return null;
}
};
/**
* Encrypts transaction details and prepares them for secure transmission.
* Uses AES encryption. CryptoJS automatically handles Salt and IV generation
* when encrypting with a passphrase and includes them in the output string,
* which it then uses for decryption.
*
* @param {TransactionDetails} transactionDetails - The unencrypted transaction data.
* @returns {Promise<string | null>} The ciphertext as a string, or null if encryption fails.
*/
export const sendEncryptedTransaction = async (
transactionDetails: TransactionDetails
): Promise<string | null> => {
try {
const encryptionKey = await getEncryptionKey();
if (!encryptionKey) {
console.error('Encryption aborted: No secure encryption key available.');
return null;
}
const plaintext = JSON.stringify(transactionDetails);
const ciphertext = CryptoJS.AES.encrypt(plaintext, encryptionKey).toString();
// --- Conceptual API Call ---
// In a real application, you would send this 'ciphertext' to your backend.
// Example (replace `api` with your actual API client):
// const response = await api.post('/transactions/secure', { payload: ciphertext });
// console.log('Encrypted transaction payload sent successfully!', response.data);
// --- End Conceptual API Call ---
console.log('Encrypted transaction payload generated and ready to send.');
return ciphertext;
} catch (error) {
console.error('Error during transaction encryption:', error);
return null;
}
};
/**
* Decrypts an encrypted payload received from a secure source.
* Assumes the same encryption key used for encryption is available for decryption.
*
* @param {string} encryptedPayload - The ciphertext string to decrypt.
* @returns {Promise<DecryptedTransaction | null>} The decrypted transaction data as an object, or null if decryption fails.
*/
export const receiveAndDecryptPayload = async (
encryptedPayload: string
): Promise<DecryptedTransaction | null> => {
try {
const encryptionKey = await getEncryptionKey(); // Use the same key for decryption
if (!encryptionKey) {
console.error('Decryption aborted: No secure decryption key available.');
return null;
}
// CryptoJS.AES.decrypt expects the ciphertext string to contain the salt and IV
// if they were generated and included during encryption (which they are by default).
const bytes = CryptoJS.AES.decrypt(encryptedPayload, encryptionKey);
const decryptedDataString = bytes.toString(CryptoJS.enc.Utf8);
if (!decryptedDataString) {
console.error('Decryption resulted in empty data. Possible incorrect key or corrupted ciphertext.');
return null;
}
const decryptedObject: DecryptedTransaction = JSON.parse(decryptedDataString);
console.log('Decrypted data:', decryptedObject);
return decryptedObject;
} catch (error) {
console.error('Error during payload decryption. Possible reasons: incorrect key, corrupted ciphertext, or invalid JSON:', error);
return null;
}
};
// --- Example Usage (for testing in a React Native environment) ---
// This part is for demonstration and testing within your React Native app's logic.
// You would typically call sendEncryptedTransaction when a user submits a form,
// and receiveAndDecryptPayload when your app receives an encrypted payload.
/*
// Example of how you might use these functions:
(async () => {
const transactionData: TransactionDetails = {
amount: 123.45,
currency: 'USD',
recipient: 'John Doe',
description: 'Payment for services',
metadata: {
transactionId: 'TXN-12345',
timestamp: new Date().toISOString()
}
};
console.log('\n--- Initiating Encryption ---');
const encrypted = await sendEncryptedTransaction(transactionData);
if (encrypted) {
console.log('Encrypted Payload:', encrypted);
console.log('\n--- Initiating Decryption ---');
const decrypted = await receiveAndDecryptPayload(encrypted);
if (decrypted) {
console.log('Original vs Decrypted (should be identical):');
console.log('Original:', transactionData);
console.log('Decrypted:', decrypted);
// You can add a deep equality check here for robustness in tests
// For example, using a library like 'lodash.isequal'
// import isEqual from 'lodash.isequal';
// console.log('Are they equal?', isEqual(transactionData, decrypted));
} else {
console.error('Failed to decrypt data for example usage.');
}
} else {
console.error('Failed to encrypt data for example usage.');
}
// Example of trying to decrypt with a wrong key (for testing error handling)
// Temporarily override getEncryptionKey for this test
// const originalGetEncryptionKey = getEncryptionKey;
// (getEncryptionKey as any) = async () => 'AN_INCORRECT_KEY';
// console.log('\n--- Initiating Decryption with INCORRECT Key ---');
// const failedDecryption = await receiveAndDecryptPayload(encrypted as string);
// console.log('Decryption with incorrect key result:', failedDecryption);
// (getEncryptionKey as any) = originalGetEncryptionKey; // Restore original
})();
*/
C. SSL Pinning: Locking Down Your API Connections
You've got your sensitive data securely stored on the device and even encrypted critical payloads before sending them. Fantastic! But what about the journey your data takes across the internet? While SSL/TLS (which gives us HTTPS) encrypts communication and verifies a server's identity via Certificate Authorities (CAs), it has a critical Achilles' heel: Man-in-the-Middle (MITM) attacks.
An attacker can intercept your app's communication by acting as a proxy. They might trick your app into connecting to a fake Wi-Fi hotspot or, more dangerously, present a fraudulent SSL/TLS certificate. This fake certificate could even be issued by a compromised Certificate Authority. Your app, trusting the compromised CA, might then unknowingly send your users' sensitive financial data directly to the attacker. This digital eavesdropping is incredibly dangerous – allowing credit card details or transaction information to be stolen or altered.
Why it matters (Business Benefit): SSL Pinning is your app's robust bodyguard against these sophisticated MITM attacks. It goes beyond standard SSL/TLS by creating an unbreakable bond between your app and your legitimate backend servers. This directly protects sensitive data from interception and tampering during transmission, safeguarding customer financial details and ensuring transaction integrity. For your business, SSL Pinning means fortified trust, reduced fraud risk, and stronger compliance against network-based threats that could lead to massive financial and reputational damage.
How to Implement (React Native Perspective): SSL Pinning works by hardcoding the expected SSL/TLS certificate's public key (or the entire certificate) directly into your app's code. When your app tries to connect to a server, it performs an additional, critical check: it verifies if the server's presented certificate matches the specific "pin" (your hardcoded key or certificate) stored within the app. If they don't match, even if the certificate seems valid otherwise, the connection is immediately terminated.
There are primarily two types of pinning:
Certificate Pinning: You embed the exact X.509 certificate of your server into the app. This is simpler but requires an app update every time the server's certificate changes.
Public Key Pinning: You extract and embed only the public key from the server's certificate. This is generally preferred as it's more flexible; if your server renews its certificate but keeps the same public key, you don't need to update the app.
In React Native, implementing SSL Pinning is greatly simplified with libraries like react-native-ssl-pinning
. This library allows you to configure specific domains with their corresponding public key hashes (fingerprints) or certificate data.
Example Concept: Configuring SSL Pinning
import { fetch as pinnedFetch } from 'react-native-ssl-pinning'; // Renamed to avoid conflict with global fetch
/**
* @interface PinnedCertificateConfig
* Defines the structure for SSL pinning configuration for a given domain.
*/
interface PinnedCertificateConfig {
hashes: string[]; // Array of SHA256 hashes of the server's public key
// certificates?: string[]; // Optional: For certificate pinning (base64 encoded certs) if preferred
}
/**
* @interface PinnedCertificatesMap
* A map where keys are domain names and values are their respective pinning configurations.
*/
interface PinnedCertificatesMap {
[domain: string]: PinnedCertificateConfig;
}
// --- Important: You would get the SHA256 hash(es) of your server's public key ---
// These hashes are crucial for security and must be obtained from your actual server's
// SSL certificate.
//
// How to extract SHA256 public key hash:
// 1. Get your server's certificate (e.g., your_certificate.crt).
// 2. Use OpenSSL commands (replace `your_certificate.crt` with your actual cert file):
// openssl x509 -in your_certificate.crt -pubkey -noout | openssl rsa -pubin -outform DER | openssl dgst -sha256 -binary | openssl enc -base64
//
// It's wise to include a backup key hash if your server has one or if you plan for
// certificate rotation, so your app doesn't break during certificate updates.
const PinnedCertificates: PinnedCertificatesMap = {
'your-api-domain.com': { // <<< REPLACE WITH YOUR ACTUAL API DOMAIN >>>
hashes: [
'sha256/YOUR_SERVER_PUBLIC_KEY_HASH_1=', // <<< REPLACE WITH ACTUAL HASHES >>>
'sha256/YOUR_BACKUP_PUBLIC_KEY_HASH_2=', // <<< REPLACE WITH ACTUAL HASHES (optional) >>>
],
},
'another-secured-domain.com': { // Add other domains if your app communicates with multiple secured endpoints
hashes: [
'sha256/ANOTHER_SERVER_PUBLIC_KEY_HASH_1=',
],
},
// ... more domains
};
/**
* Makes an API request with SSL pinning enabled.
* This function handles fetching data from a specified URL, applying the
* pre-configured SSL certificate hashes to ensure the connection is secure
* and not subject to Man-in-the-Middle (MITM) attacks.
*
* @param {string} url - The full URL for the API request (e.g., 'https://your-api-domain.com/data').
* @param {RequestInit} [options] - Optional standard Fetch API options (method, headers, body, etc.).
* @returns {Promise<any | null>} The JSON response data on success, or null on failure (pinning error or network issue).
*/
export const makePinnedApiRequest = async (url: string, options?: RequestInit): Promise<any | null> => {
try {
const urlObject = new URL(url);
const domain = urlObject.hostname;
const domainCertConfig = PinnedCertificates[domain];
if (!domainCertConfig || !domainCertConfig.hashes || domainCertConfig.hashes.length === 0) {
console.error(`SSL Pinning Error: No certificate hashes configured for domain: ${domain}. Request aborted.`);
// In a real application, you might want to alert the user or log this critical event.
return null; // Abort the request if pinning config is missing
}
console.log(`Making pinned API request to: ${url} with hashes: ${domainCertConfig.hashes.join(', ')}`);
const response = await pinnedFetch(url, {
...options, // Spread any provided options (method, headers, body, etc.)
sslPinning: {
certs: domainCertConfig.hashes,
},
});
if (!response.ok) {
// Handle HTTP errors (e.g., 404, 500)
const errorBody = await response.text();
console.error(`API request failed with status ${response.status} for ${url}: ${errorBody}`);
return null;
}
const data = await response.json();
console.log('Successfully fetched data via pinned connection:', data);
return data;
} catch (error) {
// This catch block will specifically trigger if SSL pinning fails (MITM attempt)
// or for general network issues (e.g., no internet connection, DNS resolution failure).
console.error('SSL Pinning Error or Network Issue during API request:', error);
// For production, consider robust error reporting (e.g., to a crash analytics service)
// and potentially displaying a user-friendly error message.
return null;
}
};
// --- Example Usage (for testing in a React Native environment) ---
/*
(async () => {
const EXAMPLE_API_URL = 'https://your-api-domain.com/data'; // Replace with a URL matching your PinnedCertificates
console.log('\n--- Initiating Pinned API Request ---');
const fetchedData = await makePinnedApiRequest(EXAMPLE_API_URL, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
// Add any other necessary headers (e.g., Authorization)
},
});
if (fetchedData) {
console.log('\nSuccessfully received data:');
console.log(fetchedData);
} else {
console.error('\nFailed to fetch data via pinned connection.');
}
// Example of a POST request with a body (assuming 'your-api-domain.com' is configured)
const POST_API_URL = 'https://your-api-domain.com/submit-data';
const postData = {
message: 'This is a secure message.',
userId: 'user123'
};
console.log('\n--- Initiating Pinned POST Request ---');
const postResponse = await makePinnedApiRequest(POST_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(postData),
});
if (postResponse) {
console.log('\nSuccessfully sent data:');
console.log(postResponse);
} else {
console.error('\nFailed to send data via pinned connection.');
}
// Example of a request to a domain *not* configured for pinning (for testing error handling)
const UNPINNED_API_URL = 'https://jsonplaceholder.typicode.com/todos/1'; // Public API, unlikely to be pinned
console.log('\n--- Initiating Pinned API Request to UNCONFIGURED Domain ---');
const unpinnedData = await makePinnedApiRequest(UNPINNED_API_URL);
if (unpinnedData) {
console.log('This should not happen if config is strict:', unpinnedData);
} else {
console.log('Expected failure because domain is not configured for pinning.');
}
})();
*/
We've laid down the essential groundwork for securing your React Native fintech app. We took a close look at Secure Data Storage, utilizing tools like react-native-keychain, react-native-mmkv
to safeguard sensitive information stored on devices. Next, we dove into End-to-End Encryption with crypto-js
, demonstrating how it protects data payloads from being intercepted, even by debugging tools. Lastly, we discussed how SSL Pinning acts as a vital barrier against Man-in-the-Middle attacks, ensuring that server communications remain secure.
These protective layers—secure local data, encrypted payloads, and strong network authentication—create a solid foundation for data and communication security. By implementing these measures, you not only build user trust but also ensure compliance and shield your business from serious risks.
However, securing data and network connections is just the start. In Part 2 of this series, we’ll shift our focus to enhancing user access with advanced authentication methods and safeguarding your app's codebase from tampering. Stay tuned!
If you found this content helpful, feel free to show your support with 👏 claps!
Be sure to follow!
Subscribe to my newsletter
Read articles from Thomas Ejembi directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Thomas Ejembi
Thomas Ejembi
I'm a dedicated software engineer with a passion for solving business challenges through innovative software solutions. My technical stack spans React, React Native (for both Android and iOS), Kotlin, and Android development—tools I leverage to build dynamic user interfaces and architect efficient, scalable mobile applications that drive business success and elevate user experiences. In my previous role, I led a team of developers to create a high-performance delivery app for a major client in East Asia, as well as a cutting-edge meme coin launchpad. Currently, as a co-founder and mobile engineer at BlinkCore, I help build digital and contactless payment apps that empower users to make blink-fast, secure payments while enabling businesses to receive them seamlessly. Our technology integrates NFC, HCE, QR code scanning, and geolocation to deliver a next-generation payment experience. I thrive on tackling complex problems and consistently deliver scalable, innovative solutions that align with business needs. Let's connect and explore how we can turn challenging ideas into transformative digital experiences.