Index: src/wp-includes/class-wp-recovery-mode-key-service.php
===================================================================
--- src/wp-includes/class-wp-recovery-mode-key-service.php	(revision 45210)
+++ src/wp-includes/class-wp-recovery-mode-key-service.php	(working copy)
@@ -1,6 +1,6 @@
 <?php
 /**
- * Error Protection API: WP_Recovery_Mode_Key_service class
+ * Error Protection API: WP_Recovery_Mode_Key_Service class
  *
  * @package WordPress
  * @since   5.2.0
@@ -14,6 +14,25 @@
 final class WP_Recovery_Mode_Key_Service {
 
 	/**
+	 * The option name used to store the keys.
+	 *
+	 * @since 5.2.0
+	 * @var string
+	 */
+	private $option_name = 'recovery_keys';
+
+	/**
+	 * Creates a recovery mode token.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @return string $token A random string to identify its associated key in storage.
+	 */
+	public function generate_recovery_mode_token() {
+		return wp_generate_password( 22, false );
+	}
+
+	/**
 	 * Creates a recovery mode key.
 	 *
 	 * @since 5.2.0
@@ -20,23 +39,15 @@
 	 *
 	 * @global PasswordHash $wp_hasher
 	 *
-	 * @return string Recovery mode key.
+	 * @param string $token A token generated by {@see generate_recovery_mode_token()}.
+	 * @return string $key Recovery mode key.
 	 */
-	public function generate_and_store_recovery_mode_key() {
+	public function generate_and_store_recovery_mode_key( $token ) {
 
 		global $wp_hasher;
 
 		$key = wp_generate_password( 22, false );
 
-		/**
-		 * Fires when a recovery mode key is generated for a user.
-		 *
-		 * @since 5.2.0
-		 *
-		 * @param string $key The recovery mode key.
-		 */
-		do_action( 'generate_recovery_mode_key', $key );
-
 		if ( empty( $wp_hasher ) ) {
 			require_once ABSPATH . WPINC . '/class-phpass.php';
 			$wp_hasher = new PasswordHash( 8, true );
@@ -44,14 +55,25 @@
 
 		$hashed = $wp_hasher->HashPassword( $key );
 
-		update_option(
-			'recovery_key',
-			array(
-				'hashed_key' => $hashed,
-				'created_at' => time(),
-			)
+		$records = $this->get_keys();
+
+		$records[ $token ] = array(
+			'hashed_key' => $hashed,
+			'created_at' => time(),
 		);
 
+		$this->update_keys( $records );
+
+		/**
+		 * Fires when a recovery mode key is generated.
+		 *
+		 * @since 5.2.0
+		 *
+		 * @param string $token The recovery data token.
+		 * @param string $key   The recovery mode key.
+		 */
+		do_action( 'generate_recovery_mode_key', $token, $key );
+
 		return $key;
 	}
 
@@ -58,20 +80,27 @@
 	/**
 	 * Verifies if the recovery mode key is correct.
 	 *
+	 * Recovery mode keys can only be used once; the key will be consumed in the process.
+	 *
 	 * @since 5.2.0
 	 *
-	 * @param string $key The unhashed key.
-	 * @param int    $ttl Time in seconds for the key to be valid for.
+	 * @param string $token The token used when generating the given key.
+	 * @param string $key   The unhashed key.
+	 * @param int    $ttl   Time in seconds for the key to be valid for.
 	 * @return true|WP_Error True on success, error object on failure.
 	 */
-	public function validate_recovery_mode_key( $key, $ttl ) {
+	public function validate_recovery_mode_key( $token, $key, $ttl ) {
 
-		$record = get_option( 'recovery_key' );
+		$records = $this->get_keys();
 
-		if ( ! $record ) {
-			return new WP_Error( 'no_recovery_key_set', __( 'Recovery Mode not initialized.' ) );
+		if ( ! isset( $records[ $token ] ) ) {
+			return new WP_Error( 'token_not_found', __( 'Recovery Mode not initialized.' ) );
 		}
 
+		$record = $records[ $token ];
+
+		$this->remove_key( $token );
+
 		if ( ! is_array( $record ) || ! isset( $record['hashed_key'], $record['created_at'] ) ) {
 			return new WP_Error( 'invalid_recovery_key_format', __( 'Invalid recovery key format.' ) );
 		}
@@ -86,4 +115,69 @@
 
 		return true;
 	}
+
+	/**
+	 * Removes expired recovery mode keys.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param int $ttl Time in seconds for the keys to be valid for.
+	 */
+	public function clean_expired_keys( $ttl ) {
+
+		$records = $this->get_keys();
+
+		foreach ( $records as $key => $record ) {
+			if ( ! isset( $record['created_at'] ) || time() > $record['created_at'] + $ttl ) {
+				unset( $records[ $key ] );
+			}
+		}
+
+		$this->update_keys( $records );
+	}
+
+	/**
+	 * Removes a used recovery key.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param string $token The token used when generating a recovery mode key.
+	 */
+	private function remove_key( $token ) {
+
+		$records = $this->get_keys();
+
+		if ( ! isset( $records[ $token ] ) ) {
+			return;
+		}
+
+		unset( $records[ $token ] );
+
+		$this->update_keys( $records );
+	}
+
+	/**
+	 * Gets the recovery key records.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @return array Associative array of $token => $data pairs, where $data has keys 'hashed_key'
+	 *               and 'created_at'.
+	 */
+	private function get_keys() {
+		return (array) get_option( $this->option_name, array() );
+	}
+
+	/**
+	 * Updates the recovery key records.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param array $keys Associative array of $token => $data pairs, where $data has keys 'hashed_key'
+	 *                    and 'created_at'.
+	 * @return bool True on success, false on failure.
+	 */
+	private function update_keys( array $keys ) {
+		return update_option( $this->option_name, $keys );
+	}
 }
Index: src/wp-includes/class-wp-recovery-mode-link-service.php
===================================================================
--- src/wp-includes/class-wp-recovery-mode-link-service.php	(revision 45210)
+++ src/wp-includes/class-wp-recovery-mode-link-service.php	(working copy)
@@ -37,10 +37,11 @@
 	 * @since 5.2.0
 	 *
 	 * @param WP_Recovery_Mode_Cookie_Service $cookie_service Service to handle setting the recovery mode cookie.
+	 * @param WP_Recovery_Mode_Key_Service    $key_service    Service to handle generating recovery mode keys.
 	 */
-	public function __construct( WP_Recovery_Mode_Cookie_Service $cookie_service ) {
+	public function __construct( WP_Recovery_Mode_Cookie_Service $cookie_service, WP_Recovery_Mode_Key_Service $key_service ) {
 		$this->cookie_service = $cookie_service;
-		$this->key_service    = new WP_Recovery_Mode_Key_Service();
+		$this->key_service    = $key_service;
 	}
 
 	/**
@@ -53,9 +54,10 @@
 	 * @return string Generated URL.
 	 */
 	public function generate_url() {
-		$key = $this->key_service->generate_and_store_recovery_mode_key();
+		$token = $this->key_service->generate_recovery_mode_token();
+		$key   = $this->key_service->generate_and_store_recovery_mode_key( $token );
 
-		return $this->get_recovery_mode_begin_url( $key );
+		return $this->get_recovery_mode_begin_url( $token, $key );
 	}
 
 	/**
@@ -70,7 +72,7 @@
 			return;
 		}
 
-		if ( ! isset( $_GET['action'], $_GET['rm_key'] ) || self::LOGIN_ACTION_ENTER !== $_GET['action'] ) {
+		if ( ! isset( $_GET['action'], $_GET['rm_token'], $_GET['rm_key'] ) || self::LOGIN_ACTION_ENTER !== $_GET['action'] ) {
 			return;
 		}
 
@@ -78,7 +80,7 @@
 			require_once ABSPATH . WPINC . '/pluggable.php';
 		}
 
-		$validated = $this->key_service->validate_recovery_mode_key( $_GET['rm_key'], $ttl );
+		$validated = $this->key_service->validate_recovery_mode_key( $_GET['rm_token'], $_GET['rm_key'], $ttl );
 
 		if ( is_wp_error( $validated ) ) {
 			wp_die( $validated, '' );
@@ -96,15 +98,17 @@
 	 *
 	 * @since 5.2.0
 	 *
-	 * @param string $key Recovery Mode key created by {@see generate_and_store_recovery_mode_key()}
+	 * @param string $token Recovery Mode token created by {@see generate_recovery_mode_token()}.
+	 * @param string $key   Recovery Mode key created by {@see generate_and_store_recovery_mode_key()}.
 	 * @return string Recovery mode begin URL.
 	 */
-	private function get_recovery_mode_begin_url( $key ) {
+	private function get_recovery_mode_begin_url( $token, $key ) {
 
 		$url = add_query_arg(
 			array(
-				'action' => self::LOGIN_ACTION_ENTER,
-				'rm_key' => $key,
+				'action'   => self::LOGIN_ACTION_ENTER,
+				'rm_token' => $token,
+				'rm_key'   => $key,
 			),
 			wp_login_url()
 		);
@@ -114,9 +118,10 @@
 		 *
 		 * @since 5.2.0
 		 *
-		 * @param string $url
-		 * @param string $key
+		 * @param string $url   The generated recovery mode begin URL.
+		 * @param string $token The token used to identify the key.
+		 * @param string $key   The recovery mode key.
 		 */
-		return apply_filters( 'recovery_mode_begin_url', $url, $key );
+		return apply_filters( 'recovery_mode_begin_url', $url, $token, $key );
 	}
 }
Index: src/wp-includes/class-wp-recovery-mode.php
===================================================================
--- src/wp-includes/class-wp-recovery-mode.php	(revision 45210)
+++ src/wp-includes/class-wp-recovery-mode.php	(working copy)
@@ -16,14 +16,22 @@
 	const EXIT_ACTION = 'exit_recovery_mode';
 
 	/**
-	 * Service to handle sending an email with a recovery mode link.
+	 * Service to handle cookies.
 	 *
 	 * @since 5.2.0
-	 * @var WP_Recovery_Mode_Email_Service
+	 * @var WP_Recovery_Mode_Cookie_Service
 	 */
-	private $email_service;
+	private $cookie_service;
 
 	/**
+	 * Service to generate a recovery mode key.
+	 *
+	 * @since 5.2.0
+	 * @var WP_Recovery_Mode_Key_Service
+	 */
+	private $key_service;
+
+	/**
 	 * Service to generate and validate recovery mode links.
 	 *
 	 * @since 5.2.0
@@ -32,12 +40,12 @@
 	private $link_service;
 
 	/**
-	 * Service to handle cookies.
+	 * Service to handle sending an email with a recovery mode link.
 	 *
 	 * @since 5.2.0
-	 * @var WP_Recovery_Mode_Cookie_Service
+	 * @var WP_Recovery_Mode_Email_Service
 	 */
-	private $cookie_service;
+	private $email_service;
 
 	/**
 	 * Is recovery mode initialized.
@@ -70,7 +78,8 @@
 	 */
 	public function __construct() {
 		$this->cookie_service = new WP_Recovery_Mode_Cookie_Service();
-		$this->link_service   = new WP_Recovery_Mode_Link_Service( $this->cookie_service );
+		$this->key_service    = new WP_Recovery_Mode_Key_Service();
+		$this->link_service   = new WP_Recovery_Mode_Link_Service( $this->cookie_service, $this->key_service );
 		$this->email_service  = new WP_Recovery_Mode_Email_Service( $this->link_service );
 	}
 
@@ -84,7 +93,12 @@
 
 		add_action( 'wp_logout', array( $this, 'exit_recovery_mode' ) );
 		add_action( 'login_form_' . self::EXIT_ACTION, array( $this, 'handle_exit_recovery_mode' ) );
+		add_action( 'recovery_mode_clean_expired_keys', array( $this, 'clean_expired_keys' ) );
 
+		if ( ! wp_next_scheduled( 'recovery_mode_clean_expired_keys' ) && ! wp_installing() ) {
+			wp_schedule_event( time(), 'daily', 'recovery_mode_clean_expired_keys' );
+		}
+
 		if ( defined( 'WP_RECOVERY_MODE_SESSION_ID' ) ) {
 			$this->is_active  = true;
 			$this->session_id = WP_RECOVERY_MODE_SESSION_ID;
@@ -233,6 +247,17 @@
 	}
 
 	/**
+	 * Cleans any recovery mode keys that have expired according to the link TTL.
+	 *
+	 * Executes on a daily cron schedule.
+	 *
+	 * @since 5.2.0
+	 */
+	public function clean_expired_keys() {
+		$this->key_service->clean_expired_keys( $this->get_link_ttl() );
+	}
+
+	/**
 	 * Handles checking for the recovery mode cookie and validating it.
 	 *
 	 * @since 5.2.0
Index: tests/phpunit/tests/error-protection/recovery-mode-key-service.php
===================================================================
--- tests/phpunit/tests/error-protection/recovery-mode-key-service.php	(revision 45210)
+++ tests/phpunit/tests/error-protection/recovery-mode-key-service.php	(working copy)
@@ -10,7 +10,8 @@
 	 */
 	public function test_generate_and_store_recovery_mode_key_returns_recovery_key() {
 		$service = new WP_Recovery_Mode_Key_Service();
-		$key     = $service->generate_and_store_recovery_mode_key();
+		$token   = $service->generate_recovery_mode_token();
+		$key     = $service->generate_and_store_recovery_mode_key( $token );
 
 		$this->assertNotWPError( $key );
 	}
@@ -20,20 +21,49 @@
 	 */
 	public function test_validate_recovery_mode_key_returns_wp_error_if_no_key_set() {
 		$service = new WP_Recovery_Mode_Key_Service();
-		$error   = $service->validate_recovery_mode_key( 'abcd', HOUR_IN_SECONDS );
+		$error   = $service->validate_recovery_mode_key( '', 'abcd', HOUR_IN_SECONDS );
 
 		$this->assertWPError( $error );
-		$this->assertEquals( 'no_recovery_key_set', $error->get_error_code() );
+		$this->assertEquals( 'token_not_found', $error->get_error_code() );
 	}
 
 	/**
 	 * @ticket 46130
 	 */
+	public function test_validate_recovery_mode_key_returns_wp_error_if_data_missing() {
+		update_option( 'recovery_keys', 'gibberish' );
+
+		$service = new WP_Recovery_Mode_Key_Service();
+		$error   = $service->validate_recovery_mode_key( '', 'abcd', HOUR_IN_SECONDS );
+
+		$this->assertWPError( $error );
+		$this->assertEquals( 'token_not_found', $error->get_error_code() );
+	}
+
+	/**
+	 * @ticket 46130
+	 */
+	public function test_validate_recovery_mode_key_returns_wp_error_if_bad() {
+		update_option( 'recovery_keys', array( 'token' => 'gibberish' ) );
+
+		$service = new WP_Recovery_Mode_Key_Service();
+		$error   = $service->validate_recovery_mode_key( 'token', 'abcd', HOUR_IN_SECONDS );
+
+		$this->assertWPError( $error );
+		$this->assertEquals( 'invalid_recovery_key_format', $error->get_error_code() );
+	}
+
+
+	/**
+	 * @ticket 46130
+	 */
 	public function test_validate_recovery_mode_key_returns_wp_error_if_stored_format_is_invalid() {
-		update_option( 'recovery_key', 'gibberish' );
 
+		$token = wp_generate_password( 22, false );
+		update_option( 'recovery_keys', array( $token => 'gibberish' ) );
+
 		$service = new WP_Recovery_Mode_Key_Service();
-		$error   = $service->validate_recovery_mode_key( 'abcd', HOUR_IN_SECONDS );
+		$error   = $service->validate_recovery_mode_key( $token, 'abcd', HOUR_IN_SECONDS );
 
 		$this->assertWPError( $error );
 		$this->assertEquals( 'invalid_recovery_key_format', $error->get_error_code() );
@@ -44,8 +74,9 @@
 	 */
 	public function test_validate_recovery_mode_key_returns_wp_error_if_empty_key() {
 		$service = new WP_Recovery_Mode_Key_Service();
-		$service->generate_and_store_recovery_mode_key();
-		$error = $service->validate_recovery_mode_key( '', HOUR_IN_SECONDS );
+		$token   = $service->generate_recovery_mode_token();
+		$service->generate_and_store_recovery_mode_key( $token );
+		$error = $service->validate_recovery_mode_key( $token, '', HOUR_IN_SECONDS );
 
 		$this->assertWPError( $error );
 		$this->assertEquals( 'hash_mismatch', $error->get_error_code() );
@@ -56,8 +87,9 @@
 	 */
 	public function test_validate_recovery_mode_key_returns_wp_error_if_hash_mismatch() {
 		$service = new WP_Recovery_Mode_Key_Service();
-		$service->generate_and_store_recovery_mode_key();
-		$error = $service->validate_recovery_mode_key( 'abcd', HOUR_IN_SECONDS );
+		$token   = $service->generate_recovery_mode_token();
+		$service->generate_and_store_recovery_mode_key( $token );
+		$error = $service->validate_recovery_mode_key( $token, 'abcd', HOUR_IN_SECONDS );
 
 		$this->assertWPError( $error );
 		$this->assertEquals( 'hash_mismatch', $error->get_error_code() );
@@ -68,13 +100,14 @@
 	 */
 	public function test_validate_recovery_mode_key_returns_wp_error_if_expired() {
 		$service = new WP_Recovery_Mode_Key_Service();
-		$key     = $service->generate_and_store_recovery_mode_key();
+		$token   = $service->generate_recovery_mode_token();
+		$key     = $service->generate_and_store_recovery_mode_key( $token );
 
-		$record               = get_option( 'recovery_key' );
-		$record['created_at'] = time() - HOUR_IN_SECONDS - 30;
-		update_option( 'recovery_key', $record );
+		$records                         = get_option( 'recovery_keys' );
+		$records[ $token ]['created_at'] = time() - HOUR_IN_SECONDS - 30;
+		update_option( 'recovery_keys', $records );
 
-		$error = $service->validate_recovery_mode_key( $key, HOUR_IN_SECONDS );
+		$error = $service->validate_recovery_mode_key( $token, $key, HOUR_IN_SECONDS );
 
 		$this->assertWPError( $error );
 		$this->assertEquals( 'key_expired', $error->get_error_code() );
@@ -85,7 +118,66 @@
 	 */
 	public function test_validate_recovery_mode_key_returns_true_for_valid_key() {
 		$service = new WP_Recovery_Mode_Key_Service();
-		$key     = $service->generate_and_store_recovery_mode_key();
-		$this->assertTrue( $service->validate_recovery_mode_key( $key, HOUR_IN_SECONDS ) );
+		$token   = $service->generate_recovery_mode_token();
+		$key     = $service->generate_and_store_recovery_mode_key( $token );
+		$this->assertTrue( $service->validate_recovery_mode_key( $token, $key, HOUR_IN_SECONDS ) );
 	}
+
+	/**
+	 * @ticket 46595
+	 */
+	public function test_validate_recovery_mode_key_returns_error_if_token_used_more_than_once() {
+		$service = new WP_Recovery_Mode_Key_Service();
+		$token   = $service->generate_recovery_mode_token();
+		$key     = $service->generate_and_store_recovery_mode_key( $token );
+
+		$this->assertTrue( $service->validate_recovery_mode_key( $token, $key, HOUR_IN_SECONDS ) );
+
+		// data should be remove by first call
+		$error = $service->validate_recovery_mode_key( $token, $key, HOUR_IN_SECONDS );
+
+		$this->assertWPError( $error );
+		$this->assertEquals( 'token_not_found', $error->get_error_code() );
+	}
+
+	/**
+	 * @ticket 46595
+	 */
+	public function test_validate_recovery_mode_key_returns_error_if_token_used_more_than_once_more_than_key_stored() {
+		$service = new WP_Recovery_Mode_Key_Service();
+
+		// create an extra key
+		$token = $service->generate_recovery_mode_token();
+		$service->generate_and_store_recovery_mode_key( $token );
+
+		$token = $service->generate_recovery_mode_token();
+		$key   = $service->generate_and_store_recovery_mode_key( $token );
+
+		$this->assertTrue( $service->validate_recovery_mode_key( $token, $key, HOUR_IN_SECONDS ) );
+
+		// data should be remove by first call
+		$error = $service->validate_recovery_mode_key( $token, $key, HOUR_IN_SECONDS );
+
+		$this->assertWPError( $error );
+		$this->assertEquals( 'token_not_found', $error->get_error_code() );
+	}
+
+	/**
+	 * @ticket 46595
+	 */
+	public function test_clean_expired_keys() {
+		$service = new WP_Recovery_Mode_Key_Service();
+		$token   = $service->generate_recovery_mode_token();
+		$service->generate_and_store_recovery_mode_key( $token );
+
+		$records = get_option( 'recovery_keys' );
+
+		$records[ $token ]['created_at'] = time() - HOUR_IN_SECONDS - 30;
+
+		update_option( 'recovery_keys', $records );
+
+		$service->clean_expired_keys( HOUR_IN_SECONDS );
+
+		$this->assertEmpty( get_option( 'recovery_keys' ) );
+	}
 }
