You’ve probably used multi-factor authentication (MFA) on the device you’re using right now. Maybe you have to enter your password and then scan your fingerprint to access a banking app, or you log into your email with your password followed by a quick code from an authenticator app. We use MFA every day to prove that we are who we say we are.
But what about the actions after you log in?
For a business handling payments, the most sensitive operations aren't just at the front door; they're inside the house. Actions like issuing a refund, transferring funds, or changing a merchant's bank details carry immense risk and shouldn't be handled with standard permissions. Instead, they require you to apply MFA to specific, high-risk actions within your internal tools.
In this tutorial, you’ll learn how to build that extra security layer for your internal dashboards. You’ll also learn, step-by-step, how to protect sensitive Flutterwave operations like refunds with a token-based MFA approval flow.
Prerequisites
Before we dive in, make sure you have the following:
Before we jump right into some code implementation, let’s first understand what multi-factor authentication really is and how it relates to the commonly used term two-factor authentication (2FA).
What is Multi-Factor Authentication (MFA)?
Multi-factor authentication is a security practice that requires a user to provide at least two different pieces of evidence or "factors" to prove their identity. The core idea is that while just a password can be stolen, it's much harder for an attacker to gain unauthorized access when you require more than one factor to verify a user's identity. This is the same principle behind two-factor authentication (2FA), a subset of MFA.
You'll often hear the terms "Two-Factor Authentication" and "Multi-Factor Authentication" used interchangeably, but there's a subtle and important distinction.
- Two-Factor Authentication (2FA) is a specific type of MFA. As the name implies, it always uses exactly two factors (e.g., a password + a text message code).
- Multi-Factor Authentication (MFA) is the broader umbrella term. It means using two or more factors.
At its core, multi-factor authentication is a deliberate, architectural decision to never trust a single point of failure for identity verification. It’s about demanding separate, unrelated forms of proof before granting access. Setting up MFA isn’t just about asking for two different keys to the same lock; you can think of it as asking for a key to the lock, a combination to the safe inside, and maybe even a biometric scan to open the box within that safe.
A true multi-factor authentication system categorizes these proofs, the core authentication factors, into three distinct domains:
- Knowledge Factor (something you know): This is your classic secret. A password, a passphrase, a PIN. It lives in the user's mind.
- Possession Factor (something you have): This is a tangible or digital token that the user controls. A code generated by an authenticator app, a physical key like a YubiKey, or even a one-time code delivered via SMS. It's proof of control over a specific device.
- Inherence Factor (something you are): This is the user themselves, their unique biological data. Fingerprints, facial geometry, retinal patterns. It’s intrinsically tied to the individual.
A true MFA strategy layers factors from different categories, and its principles are non-negotiable when the action you’re securing is moving money.
So why does this matter to you as a developer? Because you need to assess risk. For example, securing an internal dashboard that can issue a ₦5,000 refund might be adequately served by a strong 2FA implementation. However, protecting the control panel that can change the settlement bank account for your entire enterprise might demand more. That's when you architect for true MFA, probably requiring a password, a code from an authenticator, and a tap from a registered hardware key.
All 2FA is a form of MFA, but thinking in terms of "MFA" gives you the strategic headspace to layer on more factors as the risk profile increases.
Now, let’s move from the principles we have discussed to implementation, showing you how to implement this security layer in code.
Building Your MFA Approval Layer
Let's walk through how we can add a powerful security layer to your payment dashboard. We'll cover the key concepts and then dive into the code step by step.
Step 1: Foundation — Access Management with Role-Based Access Control (RBAC)
First, before any other logic, we must check if the user has the correct role to perform a sensitive action. This is the first line of defense. You can either build this logic into your application directly or integrate an external service dedicated to managing user permissions, like Permit.io, which provides authorization-as-a-service.
Here is an example code of a middleware that helps decouple your authorization logic from your application code.
// Example using the Permit.io SDK for a RBAC check
import { Permit } from 'permitio';
const permit = new Permit({
token: process.env.PERMIT_API_KEY, // Your Permit.io environment API key
pdp: 'http://localhost:7766', // URL of your local Policy Decision Point
});
// This middleware will be our reusable gatekeeper
const checkPermission = (action, resource) => {
return async (req, res, next) => {
const user = req.user;
const permitted = await permit.check(user.key, action, resource);
if (permitted) {
return next(); // User has the role/permission, proceed.
} else {
// User is not authorized, stop the request here.
return res.status(403).json({ message: 'Forbidden: You do not have permission for this action.' });
}
};
};
In this middleware function, we connect to Permit.io, the example authorization framework we are using, and create a checkPermission
function that can be placed in front of any sensitive operation (like creating a refund).
When a user tries to perform an action, this middleware intercepts the request and checks if the user has the role to carry out the action they’re asking permission for.
Step 2: Onboarding — Setting Up MFA for a User
A user who can perform high-risk actions must first enroll in MFA. This is a one-time setup where they connect their account to an authenticator app. You'll need an endpoint that generates a secret and provides a QR code for them to scan.
// POST /api/mfa/enroll
// Generates a new MFA secret for the user and returns a QR code for scanning.
import { authenticator } from 'otplib';
import qrcode from 'qrcode';
app.post('/api/mfa/enroll', async (req, res) => {
const user = req.user;
const secret = authenticator.generateSecret();
// Make Sure to Encrypt and store this secret in your database against the user's record.
await db.users.update({ id: user.id }, { totpSecret: secret /* encrypted */ });
const otpauth = authenticator.keyuri(user.email, 'YourAppName', secret);
// Generate a QR code to be displayed to the user for setup
qrcode.toDataURL(otpauth, (err, imageUrl) => {
if (err) {
console.error('QR code generation failed:', err);
return res.status(500).json({ message: 'Failed to generate MFA setup.' });
}
// Send the QR code image data to the frontend
res.status(200).json({ qrCodeUrl: imageUrl });
});
});
The user scans this QR code with an app like Google Authenticator or Microsoft Authenticator, which then generates one-time passwords. This establishes the link for future checks, often considered one of the most secure MFA authentication methods because the codes are generated on one of the user's registered physical devices.
Step 3: Action — A Secure Two-Step Refund Process
In this step, we build the core flow for handling the refund itself. For security purposes, we'll avoid processing the refund immediately when a user clicks a button. Instead, we'll implement a two-step process that separates the user's intent (clicking "refund") from the final execution (making the API call to Flutterwave).
Part A: Intent — Initiating the Refund
This first endpoint is called when a user initiates the refund process from the frontend (e.g., by clicking a "Refund" button). Its only job is to check the user's permissions using our checkPermission
middleware from Step 1. If the user is authorized, the endpoint signals back to the frontend that an MFA token is now required to proceed. It does not process the actual refund at this stage.
// POST /api/refunds/initiate
// This endpoint only checks for permission and signals that MFA is the next step.
app.post('/api/refunds/initiate',
checkPermission('initiate', 'refund'), // Our RBAC gatekeeper from Step 1
(req, res) => {
// If we get here, it means the checkPermission middleware passed.
// The user is authorized to initiate a refund.
// Now tell the frontend to prompt for the MFA token.
res.status(200).json({ mfa_required: true });
}
);
Your frontend now knows to display a field asking for the 6-digit MFA token.
Part B: Verification and Execution — Completing the Refund
After the user enters their MFA token, the frontend calls the final endpoint, sending along the transaction details and the token. This endpoint performs all checks again before making the real call to Flutterwave.
Note: This implementation uses the Flutterwave v3 API.
// POST /api/refunds/execute
// Verifies the MFA token and, if valid, executes the refund via Flutterwave.
app.post('/api/refunds/execute',
checkPermission('initiate', 'refund'), // RBAC check happens again for security!
async (req, res) => {
const { transactionId, mfaToken } = req.body;
const user = req.user;
// Retrieve the user's stored secret from your database
const userSecret = await db.users.getSecret(user.id); // Implement this DB call
// Verify the MFA token provided by the user
const isTokenValid = authenticator.check(mfaToken, userSecret);
if (!isTokenValid) {
return res.status(401).json({ message: 'Unauthorized: Invalid MFA token.' });
}
// ALL CHECKS PASSED! Proceed with the Flutterwave API call.
try {
const refundPayload = { /* amount can be specified here */ };
const response = await fetch(`https://api.flutterwave.com/v3/transactions/${transactionId}/refund`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.FLUTTERWAVE_SECRET_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(refundPayload)
});
const data = await response.json();
if (!response.ok) throw new Error(data.message);
// IMPORTANT: Log this successful, verified action for auditing.
console.log(`[AUDIT] Refund for tx:${transactionId} executed by user:${user.key}`);
return res.status(200).json(data);
} catch (error) {
console.error('Flutterwave refund failed:', error);
return res.status(500).json({ message: 'An error occurred while processing the refund.' });
}
}
);
This code revalidates the user's permission and verifies the MFA token before making an API call to Flutterwave. Let’s tie all the code examples together with a complete employee user flow.
Step 4: Tying it Together — The Employee User Flow
The backend code provides the engine, but a smooth frontend is what makes this feature usable for your team. Here’s how you would translate the API endpoints we've built into a clear workflow for an employee on your internal dashboard.
The One-Time Enrollment:
First, an employee needs to enable MFA on their account. This is a one-time setup.
- UI Location: In your internal dashboard, you would create a "Security" or "Profile" page for each user. On this page, add a button labeled "Enable Multi-Factor Authentication."
-
The Action: When the employee clicks this button, your frontend makes a
POST
request to the/api/mfa/enroll
endpoint we created earlier. -
Displaying the QR Code: The API will respond with a
qrCodeUrl
. Your frontend should then display this as an image, likely within a modal pop-up, with clear instructions: "To secure your account, scan this QR code with an authenticator app (like Google Authenticator, Microsoft Authenticator, or Authy)." - Confirmation: Once the employee scans the code, their authenticator app is now linked to their account on your dashboard.
For Each Action Approval:
Now, when that employee performs a high-risk action, they'll use their newly linked authenticator app. Let's use our refund example:
- Initiating the Action: The employee goes to the payments page, finds a transaction, and clicks the "Refund" button.
-
The First API Call: Your frontend calls the
/api/refunds/initiate
endpoint. As we designed, this endpoint checks their role permissions. If they're authorized, it responds with{"mfa_required": true }
. -
The MFA Prompt: Seeing
mfa_required: true
, your frontend now knows to prompt for the second factor. A modal should appear saying something like, "Enter the 6-digit code from your authenticator app to approve this refund." This modal will have a text field for the code. -
Final Verification: The employee opens their authenticator app, gets the current 6-digit code, and types it into the modal. Upon submitting, your frontend makes the final
POST
request to/api/refunds/execute
, sending thetransactionId
and themfaToken
. If the token is valid, the refund is processed, and the frontend can show a success message.
By building these frontend components, you connect the backend logic to a tangible, secure, and user-friendly experience for your team members.
Step 5: Planning for Failure — Fallback Procedures
A system is only as strong as its recovery process. Users will inevitably lose or replace their phones. You need a secure plan for this. Here are some options:
- Backup Codes: When a user first enrolls in MFA, provide them with a list of 8–10 single-use backup codes. Instruct them to store these in a password manager or a secure physical location. This is their self-service recovery option.
- Administrator Reset: Define a strict, audited procedure for an administrator to manually verify a user's identity (e.g., via a video call or internal HR process) and reset their MFA secret. This action must create an immutable log entry.
With a complete MFA flow that includes enrollment, verification, and recovery, you've now built a powerful, application-specific security layer. Let's see how this fits into the broader security features provided by Flutterwave.
Use Flutterwave As Your Secure Security Foundation
It’s important to remember that the custom MFA layer you’ve just designed doesn’t operate in a vacuum. It serves as an additional, application-specific safeguard on top of the enterprise-grade security that Flutterwave builds into its platform by default. These features work automatically in the background, providing a powerful safety net for your payment operations. Some of these features include:
- IP Whitelisting: You can configure your Flutterwave account to only accept API requests from pre-approved IP addresses. This prevents unauthorized API usage even if your keys are somehow compromised.
- Mandatory Dashboard MFA: Access to the Flutterwave merchant dashboard and its most sensitive operations already requires MFA, protecting your core account.
- AI-Powered Fraud Engine: Flutterwave's system automatically monitors for suspicious transaction patterns in real-time, helping to block fraud before it happens.
- 3D Secure 2.0: This is automatically implemented for card payments, intelligently adding an extra layer of verification for high-risk transactions while keeping the process smooth for legitimate customers.
Wrapping Up
By embedding MFA directly into your high-risk workflows, you move from a perimeter-based security model to a far more robust, action-based one. This ensures that even if an authorized account is compromised, your most critical financial operations remain protected. It's a fundamental step in building a truly secure payments infrastructure.
Ready to build with an API that takes security as seriously as you do? Explore the Flutterwave documentation to get started.
Top comments (0)