* Manage the X-LiteSpeed-Vary behavior and vary cookie.
defined( 'WPINC' ) || exit();
* Handles detection of user state (guest, logged-in, commenter, etc.)
* and builds the X-LiteSpeed-Vary header and vary cookie accordingly.
class Vary extends Root {
* Log tag used in debug output.
const X_HEADER = 'X-LiteSpeed-Vary';
* Default vary cookie name (used for logged-in/commenter state).
private static $_vary_name = '_lscache_vary';
* Whether Ajax calls are permitted to change the vary cookie.
private static $_can_change_vary = false;
* Update the default vary cookie name if site settings require it.
* @since 7.0 Moved to after_user_init to allow ESI no-vary no conflict.
private function _update_vary_name() {
$db_cookie = $this->conf( Base::O_CACHE_LOGIN_COOKIE ); // network aware in v3.0.
// If no vary set in rewrite rule.
if ( ! isset( $_SERVER['LSCACHE_VARY_COOKIE'] ) ) {
// Check for ESI no-vary control.
if ( ! empty( $_GET[ ESI::QS_ACTION ] ) && ! empty( $_GET['_control'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$control_raw = wp_unslash( (string) $_GET['_control'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$control = array_map( 'sanitize_text_field', explode( ',', $control_raw ) );
if ( in_array( 'no-vary', $control, true ) ) {
self::debug( 'no-vary control existed, bypass vary_name update' );
$something_wrong = false;
self::$_vary_name = $db_cookie;
if ( defined( 'LITESPEED_CLI' ) || wp_doing_cron() ) {
$something_wrong = false;
if ( $something_wrong ) {
// Display cookie error msg to admin.
if ( is_multisite() ? is_network_admin() : is_admin() ) {
Admin_Display::show_error_cookie();
Control::set_nocache( '❌❌ vary cookie setting error' );
// DB setting does not exist – nothing to check.
// Beyond this point, ensure DB vary is present in $_SERVER env.
$server_raw = wp_unslash( (string) $_SERVER['LSCACHE_VARY_COOKIE'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$vary_arr = array_map( 'trim', explode( ',', $server_raw ) );
if ( in_array( $db_cookie, $vary_arr, true ) ) {
self::$_vary_name = $db_cookie;
if ( is_multisite() ? is_network_admin() : is_admin() ) {
Admin_Display::show_error_cookie();
Control::set_nocache( 'vary cookie setting lost error' );
* Run after user init to set up vary/caching for current request.
public function after_user_init() {
$this->_update_vary_name();
if ( Router::is_logged_in() ) {
// If not ESI, check cache logged-in user setting.
if ( ! $this->cls( 'Router' )->esi_enabled() ) {
// Cache logged-in => private cache.
if ( $this->conf( Base::O_CACHE_PRIV ) && ! is_admin() ) {
add_action( 'wp_logout', __NAMESPACE__ . '\Purge::purge_on_logout' );
$this->cls( 'Control' )->init_cacheable();
Control::set_private( 'logged in user' );
// No cache for logged-in user.
Control::set_nocache( 'logged in user' );
} elseif ( ! is_admin() ) {
// ESI is on; can be public cache, but ensure cacheable is initialized.
$this->cls( 'Control' )->init_cacheable();
// Clear login state on logout.
add_action( 'clear_auth_cookie', [ $this, 'remove_logged_in' ] );
// Only after vary init we can detect guest mode.
$this->_maybe_guest_mode();
// Set vary cookie when user logs in (to avoid guest vary).
add_action( 'set_logged_in_cookie', [ $this, 'add_logged_in' ], 10, 4 );
add_action( 'wp_login', __NAMESPACE__ . '\Purge::purge_on_logout' );
$this->cls( 'Control' )->init_cacheable();
// Check login-page cacheable setting — login page doesn't go through main WP logic.
add_action( 'login_init', [ $this->cls( 'Tag' ), 'check_login_cacheable' ], 5 );
// Optional lightweight guest vary updater.
if ( ! empty( $_GET['litespeed_guest'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
add_action( 'wp_loaded', [ $this, 'update_guest_vary' ], 20 );
add_filter( 'comments_array', [ $this, 'check_commenter' ] );
// Set vary cookie for commenter.
add_action( 'set_comment_cookies', [ $this, 'append_commenter' ] );
// REST: don't change vary because they don't carry on user info usually.
self::debug( 'Rest API init disabled vary change' );
add_filter( 'litespeed_can_change_vary', '__return_false' );
* Mark request as Guest mode when applicable.
private function _maybe_guest_mode() {
if ( defined( 'LITESPEED_GUEST' ) ) {
self::debug( '👒👒 Guest mode ' . ( LITESPEED_GUEST ? 'predefined' : 'turned off' ) );
if ( ! $this->conf( Base::O_GUEST ) ) {
// If vary is set, then not a guest.
if ( self::has_vary() ) {
// Admin QS present? not a guest.
if ( ! empty( $_GET[ Router::ACTION ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
// Request to update vary? not a guest.
if ( ! empty( $_GET['litespeed_guest'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
// User explicitly turned guest off.
if ( ! empty( $_GET['litespeed_guest_off'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
self::debug( '👒👒 Guest mode' );
! defined( 'LITESPEED_GUEST' ) && define( 'LITESPEED_GUEST', true );
if ( $this->conf( Base::O_GUEST_OPTM ) ) {
! defined( 'LITESPEED_GUEST_OPTM' ) && define( 'LITESPEED_GUEST_OPTM', true );
* @deprecated 4.1 Use independent lightweight guest.vary.php instead.
public function update_guest_vary() {
! defined( 'LSCACHE_NO_CACHE' ) && define( 'LSCACHE_NO_CACHE', true );
$_guest = new Lib\Guest();
if ( $_guest->always_guest() || self::has_vary() ) {
// If contains vary already, don't reload (avoid loops).
! defined( 'LITESPEED_GUEST' ) && define( 'LITESPEED_GUEST', true );
self::debug( '🤠🤠 Guest' );
self::debug( 'Will update guest vary in finalize' );
// Return JSON to trigger reload.
echo wp_json_encode( [ 'reload' => 'yes' ] );
* Filter callback on `comments_array` to mark commenter state.
* @param array $comments The comments to output.
* @return array Filtered comments.
public function check_commenter( $comments ) {
* Allow bypassing pending comment check for comment plugins.
if ( apply_filters( 'litespeed_vary_check_commenter_pending', true ) ) {
foreach ( $comments as $comment ) {
if ( ! $comment->comment_approved ) {
// No pending comments => ensure public cache state.
self::debug( 'No pending comment' );
$this->remove_commenter();
// Remove commenter prefilled info for public cache.
foreach ( $_COOKIE as $cookie_name => $cookie_value ) {
if ( strlen( $cookie_name ) >= 15 && 0 === strpos( $cookie_name, 'comment_author_' ) ) {
unset( $_COOKIE[ $cookie_name ] );
// Pending comments present — set commenter vary.
if ( $this->conf( Base::O_CACHE_COMMENTER ) ) {
Control::set_private( 'existing commenter' );
Control::set_nocache( 'existing commenter' );
* Check if default vary has a value
* @return false|string Cookie value or false if missing.
public static function has_vary() {
if ( empty( $_COOKIE[ self::$_vary_name ] ) ) {
// Cookie values are not user-displayed; unslash only.
return wp_unslash( (string) $_COOKIE[ self::$_vary_name ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
* Append user status with logged-in.
* @since 1.6.2 Removed static referral.
* @param string|false $logged_in_cookie The logged-in cookie value.
* @param int|false $expire Expiration timestamp.
* @param int|false $expiration Unused (WordPress signature).
* @param int|false $uid User ID.
public function add_logged_in( $logged_in_cookie = false, $expire = false, $expiration = false, $uid = false ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
self::debug( 'add_logged_in' );
// Allow Ajax vary change during login flow.
// NOTE: Run before `$this->_update_default_vary()` to make vary changeable
// Ensure vary cookie exists/updated.
$this->_update_default_vary( $uid, $expire );
* Remove user logged-in status.
* @since 1.6.2 Removed static referral.
public function remove_logged_in() {
self::debug( 'remove_logged_in' );
// Allow Ajax vary change during logout flow.
// Force update vary to remove login status.
$this->_update_default_vary( -1 );
* Allow vary to be changed for Ajax calls.
* @since 2.6 Changed to static.
public static function can_ajax_vary() {
self::debug( '_can_change_vary -> true' );
self::$_can_change_vary = true;
* Whether we can change the default vary right now.
private function can_change_vary() {
// Don't change on Ajax unless explicitly allowed (no webp header).
if ( Router::is_ajax() && ! self::$_can_change_vary ) {
self::debug( 'can_change_vary bypassed due to ajax call' );
// POST request can set vary to fix #820789 login "loop" guest cache issue.
isset( $_SERVER['REQUEST_METHOD'] )
&& 'GET' !== $_SERVER['REQUEST_METHOD']
&& 'POST' !== $_SERVER['REQUEST_METHOD']
self::debug( 'can_change_vary bypassed due to method not get/post' );
// Disable when crawler is making the request.
! empty( $_SERVER['HTTP_USER_AGENT'] )
&& 0 === strpos( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ), Crawler::FAST_USER_AGENT ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
self::debug( 'can_change_vary bypassed due to crawler' );
if ( ! apply_filters( 'litespeed_can_change_vary', true ) ) {
self::debug( 'can_change_vary bypassed due to litespeed_can_change_vary hook' );
* Update default vary cookie (idempotent within a request).
* @since 1.6.6.1 Guard to ensure single run.
* @param int|false $uid User ID or false.
* @param int|false $expire Expiration timestamp (default: +2 days).
private function _update_default_vary( $uid = false, $expire = false ) {
// Ensure header output only runs once.
if ( ! defined( 'LITESPEED_DID_' . __FUNCTION__ ) ) {
define( 'LITESPEED_DID_' . __FUNCTION__, true );
self::debug2( '_update_default_vary bypassed due to run already' );
// ESI shouldn't change vary (main page only).
if ( defined( 'LSCACHE_IS_ESI' ) && LSCACHE_IS_ESI ) {
self::debug2( '_update_default_vary bypassed due to ESI' );
$vary = $this->finalize_default_vary( $uid );
$current_vary = self::has_vary();
if ( $current_vary !== $vary && 'commenter' !== $current_vary && $this->can_change_vary() ) {
$expire = time() + 2 * DAY_IN_SECONDS;
$this->_cookie( $vary, (int) $expire );
* Get the current vary cookie name.
public function get_vary_name() {
return self::$_vary_name;
* Check if a user role is in a configured vary group.
* @since 3.0 Moved here from conf.cls.
* @param string $role User role(s), comma-separated.
* @return int|string Group ID or 0.
public function in_vary_group( $role ) {
$vary_groups = $this->conf( Base::O_CACHE_VARY_GROUP );
$roles = explode( ',', $role );
$found = array_intersect( $roles, array_keys( (array) $vary_groups ) );
foreach ( $found as $curr_role ) {
$groups[] = $vary_groups[ $curr_role ];
$group = implode( ',', array_unique( $groups ) );
} elseif ( in_array( 'administrator', $roles, true ) ) {
self::debug2( 'role in vary_group [group] ' . $group );
* Finalize default vary cookie value for current user.
* NOTE: Login process will also call this because it does not call wp hook as normal page loading.
* @param int|false $uid Optional user ID.
* @return false|string False for guests when no vary needed, or hashed vary.
public function finalize_default_vary( $uid = false ) {