From bd9e8e3d11a995ee770ba46c2f0bce52eb9564d5 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Wed, 28 Oct 2026 11:06:49 -0700 Subject: [PATCH 1/2] Secrets API: Generate `WP_SECRETS_KEY` during installation. Without `WP_SECRETS_KEY`, the Secrets API derives its key from `LOGGED_IN_KEY` and `LOGGED_IN_SALT`, so rotating the salts makes every stored secret unreadable. `setup-config.php` now writes a base64-encoded random 32-byte `WP_SECRETS_KEY` after the keys and salts, outside their group, whose advice to change them at any time does not apply to it. The key is left out when no CSPRNG is available, or when `wp-config-sample.php` already defines it. `wp-config-sample.php` itself is unchanged: the Secrets API accepts any `WP_SECRETS_KEY` string, so a placeholder there would become a public key on hand-edited installs. Follow-up to [64337]. See #66399, #66187, #66380. --- src/wp-admin/setup-config.php | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/wp-admin/setup-config.php b/src/wp-admin/setup-config.php index aaa91fc24b72b..ba3f7d09b2b8c 100644 --- a/src/wp-admin/setup-config.php +++ b/src/wp-admin/setup-config.php @@ -390,6 +390,19 @@ function setup_config_display_header( $body_classes = array() ) { } } + /* + * Generate a dedicated key for the Secrets API, so that stored secrets do not depend on + * LOGGED_IN_KEY and LOGGED_IN_SALT, which are routinely changed. Without a CSPRNG the key is + * left out, and the Secrets API derives its key from those salts instead. + */ + try { + $secrets_key = base64_encode( random_bytes( 32 ) ); + } catch ( Exception $ex ) { + $secrets_key = ''; + } + + $add_secrets_key = '' !== $secrets_key && ! preg_grep( '/^define\(\s*\'WP_SECRETS_KEY\',/', $config_file ); + $key = 0; foreach ( $config_file as $line_num => $line ) { if ( str_starts_with( $line, '$table_prefix =' ) ) { @@ -397,6 +410,11 @@ function setup_config_display_header( $body_classes = array() ) { continue; } + if ( isset( $nonce_salt_line ) && ! isset( $salts_end_line ) && '/**#@-*/' === trim( $line ) ) { + $salts_end_line = $line_num; + continue; + } + if ( ! preg_match( '/^define\(\s*\'([A-Z_]+)\',([ ]+)/', $line, $match ) ) { continue; } @@ -427,9 +445,31 @@ function setup_config_display_header( $body_classes = array() ) { $config_file[ $line_num ] = "define( '" . $constant . "'," . $padding . "'" . $secret_keys[ $key++ ] . "' );\r\n"; break; } + + if ( 'NONCE_SALT' === $constant ) { + $nonce_salt_line = $line_num; + } } unset( $line ); + // Add the key after the keys and salts, outside their group, whose advice to change them does not apply to it. + if ( $add_secrets_key && isset( $nonce_salt_line ) ) { + $secrets_key_lines = array( + "\r\n", + "/**\r\n", + " * Encryption key for the Secrets API.\r\n", + " *\r\n", + " * Secrets stored by WordPress and plugins are encrypted with this key.\r\n", + " * Unlike the keys and salts above, changing it makes every stored secret unreadable.\r\n", + " *\r\n", + " * @since 7.2.0\r\n", + " */\r\n", + "define( 'WP_SECRETS_KEY', '" . $secrets_key . "' );\r\n", + ); + + array_splice( $config_file, ( $salts_end_line ?? $nonce_salt_line ) + 1, 0, $secrets_key_lines ); + } + if ( ! is_writable( ABSPATH ) ) : setup_config_display_header(); ?> From 6c93419bd4a77f956485309344ba4a4727e491a1 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Fri, 30 Oct 2026 11:00:03 -0700 Subject: [PATCH 2/2] Secrets API: Write `WP_SECRETS_KEY` from a placeholder in `wp-config-sample.php`. Addresses review. `wp-config-sample.php` now defines `WP_SECRETS_KEY` with the placeholder, after the keys and salts and outside their group, and `setup-config.php` replaces it in the same way it fills in the salts. A custom or older localized sample without the line still gets it added after the salts. Without a CSPRNG the key comes from `wp_generate_password()`, as the salts do, rather than being left out. This depends on the Secrets API rejecting the placeholder, which the patch on #66187 that requires `WP_SECRETS_KEY` adds. See #66399, #66187. --- src/wp-admin/setup-config.php | 20 +++++++++++--------- wp-config-sample.php | 11 +++++++++++ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/wp-admin/setup-config.php b/src/wp-admin/setup-config.php index ba3f7d09b2b8c..67561c7f41bec 100644 --- a/src/wp-admin/setup-config.php +++ b/src/wp-admin/setup-config.php @@ -391,19 +391,17 @@ function setup_config_display_header( $body_classes = array() ) { } /* - * Generate a dedicated key for the Secrets API, so that stored secrets do not depend on - * LOGGED_IN_KEY and LOGGED_IN_SALT, which are routinely changed. Without a CSPRNG the key is - * left out, and the Secrets API derives its key from those salts instead. + * The Secrets API key is generated like the salts above, but written as base64-encoded + * random bytes, the form the Secrets API uses as raw key material. */ try { $secrets_key = base64_encode( random_bytes( 32 ) ); } catch ( Exception $ex ) { - $secrets_key = ''; + $secrets_key = wp_generate_password( 64, true, true ); } - $add_secrets_key = '' !== $secrets_key && ! preg_grep( '/^define\(\s*\'WP_SECRETS_KEY\',/', $config_file ); - - $key = 0; + $key = 0; + $has_secrets_key = false; foreach ( $config_file as $line_num => $line ) { if ( str_starts_with( $line, '$table_prefix =' ) ) { $config_file[ $line_num ] = '$table_prefix = \'' . addcslashes( $prefix, "\\'" ) . "';\r\n"; @@ -444,6 +442,10 @@ function setup_config_display_header( $body_classes = array() ) { case 'NONCE_SALT': $config_file[ $line_num ] = "define( '" . $constant . "'," . $padding . "'" . $secret_keys[ $key++ ] . "' );\r\n"; break; + case 'WP_SECRETS_KEY': + $config_file[ $line_num ] = "define( '" . $constant . "'," . $padding . "'" . $secrets_key . "' );\r\n"; + $has_secrets_key = true; + break; } if ( 'NONCE_SALT' === $constant ) { @@ -452,8 +454,8 @@ function setup_config_display_header( $body_classes = array() ) { } unset( $line ); - // Add the key after the keys and salts, outside their group, whose advice to change them does not apply to it. - if ( $add_secrets_key && isset( $nonce_salt_line ) ) { + // A custom or older localized wp-config-sample.php may not define the key: add it after the salts. + if ( ! $has_secrets_key && isset( $nonce_salt_line ) ) { $secrets_key_lines = array( "\r\n", "/**\r\n", diff --git a/wp-config-sample.php b/wp-config-sample.php index 8f96fb0f4c857..1fd29f56b6155 100644 --- a/wp-config-sample.php +++ b/wp-config-sample.php @@ -59,6 +59,17 @@ /**#@-*/ +/** + * Encryption key for the Secrets API. + * + * Secrets stored by WordPress and plugins are encrypted with this key. Change it to + * a unique phrase, different from the keys and salts above. Unlike them, it must not + * be changed once secrets are stored: that makes every stored secret unreadable. + * + * @since 7.2.0 + */ +define( 'WP_SECRETS_KEY', 'put your unique phrase here' ); + /** * WordPress database table prefix. *