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.