Implementing email 2FA in WHMCS: architecture, code, and best practices
Back to blog

Implementing email 2FA in WHMCS: architecture, code, and best practices

6/7/2026 · 3 min · Development

Implementing two-factor authentication (2FA) in WHMCS via email is one of those strategic solutions I adopt when I need to raise the security bar of the frontend without forcing the user to rely on external apps or physical tokens. It's practical, efficient, and, if done right, very secure.

In this article, I'm opening the hood of what I developed, detailing how WHMCS handles these modules internally and the technical decisions I made to ensure clean, functional code that is secured against common vulnerabilities.

1. The 2FA life cycle in WHMCS#

One thing I learned by working with the system is that WHMCS already has a native security framework. Modules are located in: /modules/security/

The flow the system executes under the hood is elegant:

  1. Primary credentials are validated.
  2. The system detects that 2FA is active for that user.
  3. It triggers the *_verify() function.
  4. It displays your custom template.
  5. It waits for the POST to execute the *_validate() function.

This means I didn't need to manually intercept the login process; I simply worked within the "sandbox" that WHMCS provides.

2. Module architecture#

For this project, I structured the directory as follows:

modules/
└── security/
    └── email2fa/
        ├── email2fa.php
        └── template.tpl

Technical security decisions (hardening)#

ComponentMy StrategyWhy?
Code Generationrandom_int()Avoids the predictability of common rand().
Expiration5 minutes (TTL)Enough time for the email to arrive, but short for attacks.
Storagepassword_hash() in $_SESSIONThe code is never stored in plaintext in the session, mitigating session hijacking/leaks.
SendingNative sendmail()Uses the correct WHMCS helper function to inherit SMTP settings from the core.
Session ProtectionIP Binding (REMOTE_ADDR)Prevents Session Hijacking by binding the validation to the originating IP address.
CSRF ProtectionUnique Cryptographic TokenPrevents attackers from forging the validation form submission.
Rate LimitingMax 5 attemptsBlocks brute force attacks by invalidating the session and writing to the audit log.

3. Operational code (email2fa.php)#

Here is the actual implementation focused entirely on security. Note how we apply cryptographic hashing to the code, validate request integrity using IP binding and CSRF tokens, limit attempts, and validate user input with regular expressions:

<?php
if (!defined("WHMCS")) {
    die("Direct access not allowed.");
}

function email2fa_config() {
    return [
        'name' => 'Custom Email 2FA',
        'description' => 'Technical module for secondary authentication via corporate email with security hardening.',
        'version' => '1.1',
        'author' => 'Percio Castelo',
    ];
}

function email2fa_verify($params) {
    $userId = $params['user_id'];
    
    // Generate CSRF token to prevent malicious form submissions
    $csrfToken = bin2hex(random_bytes(32));
    
    // Using random_int for cryptographic entropy
    $code = (string)random_int(100000, 999999);
    
    // Store with secure hashing (password_hash) instead of plaintext
    $_SESSION['email2fa_hash'] = password_hash($code, PASSWORD_DEFAULT);
    $_SESSION['email2fa_expire'] = time() + 300; // 5 minutes validity
    $_SESSION['email2fa_ip'] = $_SERVER['REMOTE_ADDR'] ?? '';
    $_SESSION['email2fa_csrf'] = $csrfToken;
    $_SESSION['email2fa_attempts'] = 0; // Initialize attempts counter
    
    // The 'email2fa_template' email template must be created in the admin panel.
    // We use the correct 'sendmail()' helper function from WHMCS core.
    sendmail("email2fa_template", $userId, [
        "code" => $code
    ]);
    
    return ['success' => true];
}

function email2fa_validate($params) {
    // 1. IP Validation to prevent Session Hijacking
    $remoteIp = $_SERVER['REMOTE_ADDR'] ?? '';
    if (!isset($_SESSION['email2fa_ip']) || $_SESSION['email2fa_ip'] !== $remoteIp) {
        return ['error' => 'Invalid source IP address for this authentication session.'];
    }
    
    // 2. CSRF Token Validation
    $csrfInput = $_POST['csrf_token'] ?? '';
    if (!isset($_SESSION['email2fa_csrf']) || !hash_equals($_SESSION['email2fa_csrf'], $csrfInput)) {
        return ['error' => 'Invalid or missing CSRF token.'];
    }
    
    // 3. Check if session data exists
    if (!isset($_SESSION['email2fa_hash']) || !isset($_SESSION['email2fa_expire'])) {
        return ['error' => 'Session expired or code not requested.'];
    }
    
    // 4. Rate Limiting (Maximum 5 attempts)
    if ($_SESSION['email2fa_attempts'] >= 5) {
        unset($_SESSION['email2fa_hash'], $_SESSION['email2fa_expire'], $_SESSION['email2fa_ip'], $_SESSION['email2fa_csrf'], $_SESSION['email2fa_attempts']);
        logActivity("2FA Blocked: Attempt limit exceeded for User ID " . $params['user_id']);
        return ['error' => 'Attempt limit exceeded. A new verification code must be requested for security.'];
    }
    
    // 5. Check expiration (TTL)
    if (time() > $_SESSION['email2fa_expire']) {
        unset($_SESSION['email2fa_hash'], $_SESSION['email2fa_expire'], $_SESSION['email2fa_ip'], $_SESSION['email2fa_csrf'], $_SESSION['email2fa_attempts']);
        return ['error' => 'The security code has expired. Request a new one.'];
    }
    
    $input = $_POST['email2fa_code'] ?? '';
    
    // 6. Input sanitization and regex validation (exactly 6 digits)
    if (!preg_match('/^[0-9]{6}$/', $input)) {
        $_SESSION['email2fa_attempts']++;
        return ['error' => 'Invalid code format. Enter exactly 6 numeric digits.'];
    }
    
    // 7. Secure comparison using password_verify to mitigate timing attacks and type juggling
    if (password_verify($input, $_SESSION['email2fa_hash'])) {
        unset($_SESSION['email2fa_hash'], $_SESSION['email2fa_expire'], $_SESSION['email2fa_ip'], $_SESSION['email2fa_csrf'], $_SESSION['email2fa_attempts']);
        return ['success' => true];
    }
    
    // Increment attempts on failure
    $_SESSION['email2fa_attempts']++;
    $remaining = 5 - $_SESSION['email2fa_attempts'];
    
    return ['error' => "Invalid code. You have {$remaining} attempt(s) remaining."];
}

4. The front-end: template.tpl#

The template needs to be clean, follow accessibility best practices, and avoid invalid CSS class names starting with digits. We incorporate the CSRF token injection and modern attributes to ease mobile usage:

<div class="fa-2fa-container">
    <h3>Security Verification</h3>
    <p>We've sent a unique code to your registered email address.</p>
    
    <form method="post" action="login.php?backupcode=1">
        <!-- Dynamically integrated CSRF token from session -->
        <input type="hidden" name="csrf_token" value="{$smarty.session.email2fa_csrf}">
        
        <!-- Input optimized with autocomplete, inputmode, and numeric pattern -->
        <input type="text" 
               name="email2fa_code" 
               class="form-control" 
               placeholder="000000" 
               maxlength="6" 
               autocomplete="one-time-code" 
               inputmode="numeric" 
               pattern="[0-9]*" 
               required 
               autofocus>
        
        <button type="submit" class="btn btn-primary btn-block">Validate Access</button>
    </form>
    
    {if $error}
        <div class="alert alert-danger mt-3">{$error}</div>
    {/if}
</div>

5. Lessons learned and security hardening#

During the module refinement, several critical security and architectural improvements were implemented to mitigate common production risks:

  1. Correct Sending Function: The sendMessage() function does not exist in WHMCS core for this purpose. The correct approach is using the global helper function sendmail($templateName, $userId, $mergeFields) or the local API command SendEmail to properly inherit core SMTP settings.
  2. Mitigating Type Juggling and Timing Attacks: Using loose comparisons (==) in token validation is highly insecure in PHP. Code verification must use password_verify against a bcrypt hash generated via password_hash() stored in the session.
  3. Session Hijacking Protection: Binding the 2FA session context to the remote IP address ($_SERVER['REMOTE_ADDR']) ensures that the verification cannot be completed from a different machine if the session ID is hijacked.
  4. CSRF Protection: Without a cross-site request forgery token, the 2FA validation remains vulnerable to execution forgery. We generated a secure random token in the session and checked it via hash_equals().
  5. Active Rate Limiting: To stop brute force attempts, we enforced a limit of 5 validation attempts. Exceeding this limit destroys the session, logs the event via logActivity(), and blocks further access until a new login cycle starts.

Production takeaways#

Developing this module showed me that WHMCS architecture is robust enough to allow security extensions without "hacking" the core. By implementing email 2FA following these hardening practices, you gain user buy-in (high UX) without compromising account integrity.

For me, security is the baseline of any professional delivery. I hope this technical deep dive helps you further shield your WHMCS!

Was this article helpful?

Leave a quick reaction to help prioritize future technical guides:

CC BY-NC

This post is licensed under CC BY-NC.

Comments

Join the discussion below.

0 comments