Implement TOTP Two-Factor Authentication in WordPress Without a Plugin

Time-based One-Time Passwords (TOTP) are the backbone of authenticator-app two-factor authentication (2FA) — defined in RFC 6238, TOTP generates a 6-digit code by computing an HMAC-SHA1 hash of the combination of a shared secret and the current Unix timestamp divided into 30-second windows, then truncating the result to 6 decimal digits. The same algorithm runs in the user’s authenticator app (Google Authenticator, Authy, 1Password) and on the server — if both compute the same code for the current time window, authentication succeeds without any network call to a third-party service. Implementing TOTP in a WordPress plugin requires three steps: (1) generating a base32-encoded shared secret for each user using a cryptographically secure random source (random_bytes()), storing it encrypted in user meta; (2) displaying the secret as a QR code that the user scans with their authenticator app — the QR code encodes an otpauth://totp/ URI containing the issuer, account name, and secret; (3) validating the TOTP code submitted at login using the same TOTP algorithm, accepting codes from the current 30-second window and one window on either side to account for clock drift. The login flow requires hooking into WordPress’s authenticate filter to intercept the credential check, setting a short-lived session cookie after the password step passes but before the user is fully authenticated, and presenting a second form requesting the TOTP code. If the TOTP code is valid, the session is promoted to fully authenticated; if it fails 3 times, the session is destroyed and the user must re-enter their password. Backup codes (a set of 10 single-use random strings stored hashed in the database) allow account recovery when the authenticator app is unavailable. The file uploads security post hardened the upload attack surface; 2FA hardens the authentication attack surface, which is the most common entry point for WordPress compromises.

Problem: A WordPress site for a legal firm has 8 admin-level user accounts — despite strong password policies, one account was compromised via a credential stuffing attack using a password reused from a data breach on another site. The firm needs 2FA mandatory for all users with the edit_posts capability.

Solution: Implement a TOTP 2FA plugin that generates per-user secrets, validates TOTP codes at login via the authenticate filter, and enforces 2FA setup before granting full admin access using a redirect on admin_init.

/**
 * TOTP implementation (RFC 6238).
 * In production, use a peer-reviewed library such as spomky-labs/otphp or
 * paragonie/constant_time_encoding. This is a reference implementation.
 */
class MyPlugin_TOTP {
    private const PERIOD = 30;
    private const DIGITS = 6;

    /** Verify a TOTP code — accepts current window ±1 for clock drift */
    public static function verify( string $secret, string $code ): bool {
        $key   = self::base32_decode( $secret );
        $time  = (int) floor( time() / self::PERIOD );

        foreach ( [ -1, 0, 1 ] as $offset ) {
            $expected = self::generate( $key, $time + $offset );
            // Constant-time comparison to prevent timing attacks
            if ( hash_equals( $expected, $code ) ) {
                return true;
            }
        }
        return false;
    }

    private static function generate( string $key, int $counter ): string {
        $msg  = pack( 'J', $counter );       // 64-bit big-endian
        $hash = hash_hmac( 'sha1', $msg, $key, true );
        $off  = ord( $hash[19] ) & 0x0F;
        $code = ( ( ord( $hash[ $off ] ) & 0x7F ) << 24 )
              | ( ( ord( $hash[ $off + 1 ] ) & 0xFF ) << 16 )
              | ( ( ord( $hash[ $off + 2 ] ) & 0xFF ) << 8 )
              | (   ord( $hash[ $off + 3 ] ) & 0xFF );
        return str_pad( (string) ( $code % (10 ** self::DIGITS) ), self::DIGITS, '0', STR_PAD_LEFT );
    }

    /** Generate a random base32-encoded secret for a new user */
    public static function generate_secret(): string {
        return self::base32_encode( random_bytes( 20 ) );
    }

    /** Minimal base32 encoder (RFC 4648) */
    private static function base32_encode( string $input ): string {
        $map   = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
        $out   = '';
        $bits  = 0;
        $value = 0;
        foreach ( str_split( $input ) as $byte ) {
            $value  = ( $value << 8 ) | ord( $byte );
            $bits  += 8;
            while ( $bits >= 5 ) {
                $out  .= $map[ ( $value >> ( $bits - 5 ) ) & 0x1F ];
                $bits -= 5;
            }
        }
        if ( $bits > 0 ) {
            $out .= $map[ ( $value << ( 5 - $bits ) ) & 0x1F ];
        }
        return $out;
    }

    private static function base32_decode( string $input ): string {
        $map   = array_flip( str_split( 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' ) );
        $out   = '';
        $bits  = 0;
        $value = 0;
        foreach ( str_split( strtoupper( $input ) ) as $char ) {
            if ( ! isset( $map[ $char ] ) ) continue;
            $value  = ( $value << 5 ) | $map[ $char ];
            $bits  += 5;
            if ( $bits >= 8 ) {
                $out  .= chr( ( $value >> ( $bits - 8 ) ) & 0xFF );
                $bits -= 8;
            }
        }
        return $out;
    }
}

// ── Intercept login: validate TOTP after password passes ─────────────────
add_filter( 'authenticate', 'myplugin_totp_authenticate', 30, 3 );

function myplugin_totp_authenticate( $user, string $username, string $password ) {
    if ( ! ( $user instanceof WP_User ) ) {
        return $user; // password failed — let WP handle the error
    }

    $secret = get_user_meta( $user->ID, '_totp_secret', true );
    if ( ! $secret ) {
        return $user; // 2FA not set up — allow login (redirect to setup on admin_init)
    }

    $code = sanitize_text_field( $_POST['totp_code'] ?? '' );
    if ( ! $code ) {
        // Password correct but no TOTP code yet — set a transient and show TOTP form
        set_transient( 'totp_pending_' . $user->ID, 1, 90 );
        wp_redirect( wp_login_url() . '?action=totp&uid=' . $user->ID );
        exit;
    }

    if ( ! MyPlugin_TOTP::verify( $secret, $code ) ) {
        return new WP_Error( 'totp_invalid', __( 'Invalid two-factor code.', 'myplugin' ) );
    }

    return $user;
}

NOTE: The TOTP implementation above is provided as a learning reference — for a production WordPress site, use the peer-reviewed Two Factor plugin (by George Stephanis and contributors, available in the WordPress Plugin Directory) or integrate spomky-labs/otphp via Composer. The reference implementation above omits replay attack protection (storing used codes to prevent the same code being accepted twice within a 30-second window), which is required by RFC 6238 for full compliance. Also store the TOTP secret encrypted at rest using PHP’s sodium_crypto_secretbox() with a key derived from a server-side secret, not stored as plaintext in user meta — a database dump should not expose TOTP secrets.