Skip to content

Instantly share code, notes, and snippets.

@apermo
Last active July 31, 2026 13:02
Show Gist options
  • Select an option

  • Save apermo/d97512cae6e158136d593f3f7fc7fe7e to your computer and use it in GitHub Desktop.

Select an option

Save apermo/d97512cae6e158136d593f3f7fc7fe7e to your computer and use it in GitHub Desktop.
MU Plugin: signs all users out at a set time, warns beforehand with a JS countdown, and restricts logins to chosen user IDs, roles or super admins for the maintenance window
<?php
/**
* Timed Maintenance Lockdown.
*
* Plugin Name: Timed Maintenance Lockdown
* Description: Locks everyone out of a site at a set time, warns beforehand with a live countdown, and restricts
* logins to chosen user IDs, roles or super admins until the maintenance window closes.
* Version: 1.0.0
* Author: Christoph Daum
* Requires PHP: 8.3
*
* Drop this single file into wp-content/mu-plugins/ and edit the constants at the top of the class. Nothing is
* written to the database: no options, no cron events, no cleanup. Once MAINTENANCE_END has passed the plugin does
* nothing at all, so a forgotten file is harmless - though deleting it is still the tidier ending.
*
* Sessions are not destroyed, only ignored. Locked out users are treated as anonymous for the duration of the
* window and are signed back in by their existing cookie the moment it ends, with no second login. If you need the
* sessions themselves gone - a leaked password, a migration that rewrites user data - this is the wrong tool; call
* WP_Session_Tokens::destroy_all() for the accounts in question instead.
*
* @package MaintenanceLockdown
*/
declare(strict_types=1);
namespace Apermo;
use DateTimeImmutable;
use Exception;
use WP_Error;
use WP_User;
if ( ! \defined( 'ABSPATH' ) ) {
exit();
}
/**
* Locks users out at a set time and restricts logins for the duration of a maintenance window.
*
* @package MaintenanceLockdown
*/
final class Maintenance_Lockdown {
/*
* -----------------------------------------------------------------------
* Configuration - this is the only block you need to touch.
* -----------------------------------------------------------------------
*/
/**
* Window start, read in the site's timezone (Settings -> General).
*
* Everyone outside the allowlist loses access at this moment.
*/
public const MAINTENANCE_START = '2026-08-15 22:00:00';
/** Window end. Until then, only the allowlist may log in. */
public const MAINTENANCE_END = '2026-08-16 02:00:00';
/** Users who keep their session and may still log in. Integers, no quotes. */
public const ALLOWED_USER_IDS = [ 1 ];
/** Roles that keep their session and may still log in. */
public const ALLOWED_ROLES = [ 'administrator' ];
/** Whether network super admins are always allowed. On single site this covers anyone holding delete_users. */
public const ALLOW_SUPER_ADMINS = true;
/** How long before the start the warning notice and countdown appear. */
public const NOTICE_LEAD_TIME = 2 * \HOUR_IN_SECONDS;
/** Below this remaining time the warning notice turns red. */
public const NOTICE_URGENT_THRESHOLD = 10 * \MINUTE_IN_SECONDS;
/**
* Whether the admin screen reloads itself when the countdown reaches zero.
*
* Off by default: a reload throws away unsaved block editor content, and the forced logout happens on the next
* request either way.
*/
public const RELOAD_ON_LOGOUT = false;
/** Date format used in every message. */
public const DISPLAY_FORMAT = 'D, d.m.Y H:i T';
/** Text shown in place of the countdown once it has run out. */
public const EXPIRED_LABEL = 'any moment now';
/*
* -----------------------------------------------------------------------
* No configuration below this line.
* -----------------------------------------------------------------------
*/
/**
* Resolved window as a start and end timestamp, null when the configuration is unusable.
*
* @var array|null
*/
private static ?array $window = null;
/**
* Whether the window has already been resolved.
*
* @var bool
*/
private static bool $resolved = false;
/**
* Whether the countdown script has already been emitted this request.
*
* @var bool
*/
private static bool $script_printed = false;
/**
* Registers the hooks, unless this request is exempt.
*
* @return void
*/
public static function init(): void {
// WP-CLI is never locked out, so a mistake in the configuration can always be undone from the command line.
if ( \defined( 'WP_CLI' ) && \WP_CLI ) {
return;
}
add_filter( 'determine_current_user', [ self::class, 'maybe_revoke_session' ], 100 );
add_action( 'auth_redirect', [ self::class, 'maybe_block_admin' ] );
add_filter( 'authenticate', [ self::class, 'maybe_block_login' ], 31 );
add_action( 'admin_notices', [ self::class, 'render_notice' ] );
add_action( 'network_admin_notices', [ self::class, 'render_notice' ] );
add_filter( 'login_message', [ self::class, 'render_login_message' ] );
}
/**
* Treats everyone outside the allowlist as anonymous while the window is running.
*
* The auth cookie is deliberately left alone. Nobody is signed out, they are only not recognised for as long as
* the window lasts, so the same cookie logs them straight back in afterwards without a second login. It also
* covers every entry point at once - front end, admin, AJAX, REST and application passwords all resolve the
* current user through this filter.
*
* @param mixed $user_id Resolved user ID, or false for an anonymous request.
*
* @return mixed
*/
public static function maybe_revoke_session( mixed $user_id ): mixed {
// Core hands on an int or false, but the filter is public, so anything can arrive here.
if ( ! \is_numeric( $user_id ) || (int) $user_id <= 0 ) {
return $user_id;
}
if ( ! self::is_locked_down() ) {
return $user_id;
}
// Building the object directly avoids re-entering this filter, which wp_get_current_user() would do.
return self::is_user_allowed( new WP_User( (int) $user_id ) ) ? $user_id : false;
}
/**
* Sends locked out users from an admin screen to the login form.
*
* Core validates the auth cookie inside auth_redirect() directly rather than going through
* determine_current_user, so a locked out user passes it and would otherwise land on a bare "you are not
* allowed" screen. This action fires there with the cookie's user ID, which is the moment to bounce them.
*
* @param int $user_id User ID the auth cookie resolves to.
*
* @return void
*/
public static function maybe_block_admin( int $user_id ): void {
if ( ! self::is_locked_down() || self::is_user_allowed( new WP_User( $user_id ) ) ) {
return;
}
wp_safe_redirect( wp_login_url() );
exit();
}
/**
* Rejects logins from users outside the allowlist while the window is running.
*
* Priority 31 is deliberate. Core authenticates at 20 (wp_authenticate_username_password,
* wp_authenticate_email_password, wp_authenticate_application_password) and at 30 (wp_authenticate_cookie), so
* 31 is the first slot where every core authenticator has had its say and $user is a resolved WP_User.
*
* Security trade-off: this only fires once the credentials have already been accepted, so the message below
* confirms to the visitor that the password was correct. That is intentional - a locked out colleague should
* not be left believing their password broke - but a brute forcer learns the same thing, which a generic
* failure would not reveal. Put the login screen behind HTTP auth, an IP allowlist or a rate limiter if that
* matters for your site.
*
* @param mixed $user Null, WP_User or WP_Error, depending on what the previous authenticators returned. Typed
* as mixed rather than as a union, because third party authenticators may hand on anything.
*
* @return mixed
*/
public static function maybe_block_login( mixed $user ): mixed {
if ( ! $user instanceof WP_User || ! self::is_locked_down() || self::is_user_allowed( $user ) ) {
return $user;
}
$window = self::window();
return new WP_Error(
'maintenance_lockdown_blocked',
\sprintf(
'<strong>Maintenance in progress.</strong><br>Hello %1$s, your credentials are correct, but %2$s '
. 'is locked for maintenance until %3$s. Logins are restricted to the maintenance team until '
. 'then.',
esc_html( $user->display_name ),
esc_html( get_bloginfo( 'name' ) ),
esc_html( wp_date( self::DISPLAY_FORMAT, $window[1] ) ),
),
);
}
/**
* Displays the warning countdown, and the running state for whoever is still allowed in.
*
* @return void
*/
public static function render_notice(): void {
$window = self::window();
if ( $window === null ) {
wp_admin_notice(
'<strong>Maintenance Lockdown:</strong> the configured window is invalid. Check MAINTENANCE_START '
. 'and MAINTENANCE_END in mu-plugins/mu-maintenance-lockdown.php.',
[ 'type' => 'error' ],
);
return;
}
$current = \time();
if ( $current >= $window[1] ) {
return;
}
// Anyone still looking at an admin screen during the window is on the allowlist by definition.
if ( $current >= $window[0] ) {
wp_admin_notice(
\sprintf(
'<strong>Maintenance mode is active.</strong> Everyone outside the maintenance team is locked '
. 'out and regains access at %1$s, in %2$s.',
esc_html( wp_date( self::DISPLAY_FORMAT, $window[1] ) ),
self::countdown_markup( $window[1] - $current ),
),
[ 'type' => 'info' ],
);
echo self::countdown_script(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built by wp_get_inline_script_tag().
return;
}
$remaining = $window[0] - $current;
if ( $remaining <= self::NOTICE_LEAD_TIME ) {
self::render_pending_notice( $window, $remaining );
}
}
/**
* Prepends the maintenance window to the login form.
*
* @param string $message Message rendered by core and other plugins.
*
* @return string
*/
public static function render_login_message( string $message ): string {
$window = self::window();
if ( $window === null ) {
return $message;
}
$current = \time();
if ( $current >= $window[1] ) {
return $message;
}
if ( $current >= $window[0] ) {
$notice = \sprintf(
'<strong>Maintenance in progress.</strong><br>%1$s is locked from %2$s until %3$s. Only the '
. 'maintenance team can sign in right now.',
esc_html( get_bloginfo( 'name' ) ),
esc_html( wp_date( self::DISPLAY_FORMAT, $window[0] ) ),
esc_html( wp_date( self::DISPLAY_FORMAT, $window[1] ) ),
);
} elseif ( $window[0] - $current <= self::NOTICE_LEAD_TIME ) {
$notice = \sprintf(
'<strong>Scheduled maintenance.</strong><br>%1$s goes into maintenance at %2$s, in %3$s. Everyone '
. 'outside the maintenance team will be locked out.',
esc_html( get_bloginfo( 'name' ) ),
esc_html( wp_date( self::DISPLAY_FORMAT, $window[0] ) ),
self::countdown_markup( $window[0] - $current ),
) . self::countdown_script();
} else {
return $message;
}
// Appended rather than replaced, so password reset and interim-login messaging survives.
return $message . '<div class="message">' . $notice . '</div>';
}
/**
* Displays the countdown that runs up to the start of the window.
*
* The allowlist keeps working throughout, so the two audiences get different wording: warning the maintenance
* team about a lockout that will not apply to them would be misleading, and they have no work to save.
*
* @param array $window Start and end timestamp of the window.
* @param int $remaining Seconds left until it opens.
*
* @return void
*/
private static function render_pending_notice( array $window, int $remaining ): void {
if ( self::is_user_allowed( wp_get_current_user() ) ) {
$message = \sprintf(
'<strong>Scheduled maintenance at %1$s.</strong> Everyone outside the maintenance team will be '
. 'locked out in %2$s. Your own access is unaffected.',
esc_html( wp_date( self::DISPLAY_FORMAT, $window[0] ) ),
self::countdown_markup( $remaining ),
);
$type = 'info';
} else {
$message = \sprintf(
'<strong>Scheduled maintenance at %1$s.</strong> You will lose access in %2$s and get it back '
. 'automatically at %3$s, without logging in again. Please save your work.',
esc_html( wp_date( self::DISPLAY_FORMAT, $window[0] ) ),
self::countdown_markup( $remaining ),
esc_html( wp_date( self::DISPLAY_FORMAT, $window[1] ) ),
);
$type = $remaining <= self::NOTICE_URGENT_THRESHOLD ? 'error' : 'warning';
}
wp_admin_notice( $message, [ 'type' => $type ] );
echo self::countdown_script(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built by wp_get_inline_script_tag().
}
/**
* Resolves the configured window to a pair of Unix timestamps.
*
* Reading the constants through wp_timezone() keeps them in site time and avoids the UTC drift that hardcoded
* mktime() values suffer from. The lookup is deferred and memoised because wp_timezone() calls get_option(),
* which should not happen while mu-plugins are still loading.
*
* @return array|null Start and end timestamp, or null when the configuration is unusable.
*/
private static function window(): ?array {
if ( self::$resolved ) {
return self::$window;
}
self::$resolved = true;
try {
$timezone = wp_timezone();
$start_date = new DateTimeImmutable( self::MAINTENANCE_START, $timezone );
$end_date = new DateTimeImmutable( self::MAINTENANCE_END, $timezone );
} catch ( Exception ) {
return self::$window;
}
if ( $end_date > $start_date ) {
self::$window = [ $start_date->getTimestamp(), $end_date->getTimestamp() ];
}
return self::$window;
}
/**
* Determines whether the maintenance window is running right now.
*
* @return bool
*/
private static function is_locked_down(): bool {
$window = self::window();
if ( $window === null ) {
return false;
}
$current = \time();
return $current >= $window[0] && $current < $window[1];
}
/**
* Determines whether a user is exempt from the lockdown.
*
* @param WP_User $user User to test.
*
* @return bool
*/
private static function is_user_allowed( WP_User $user ): bool {
if ( ! $user->exists() ) {
return false;
}
// Cast every entry, so a quoted ID in the configuration does not silently lock someone out.
foreach ( self::ALLOWED_USER_IDS as $allowed_id ) {
if ( (int) $allowed_id === $user->ID ) {
return true;
}
}
if ( self::ALLOW_SUPER_ADMINS && is_super_admin( $user->ID ) ) {
return true;
}
// On multisite $user->roles holds the roles for the site being requested, not a network wide set.
return (bool) \array_intersect( self::ALLOWED_ROLES, $user->roles );
}
/**
* Builds the countdown placeholder.
*
* The value is rendered server side and carried in a data attribute, so it stays correct without JavaScript and
* never flashes an empty element.
*
* @param int $remaining Seconds left until the window opens.
*
* @return string
*/
private static function countdown_markup( int $remaining ): string {
$remaining = \max( 0, $remaining );
return \sprintf(
'<span class="apermo-lockdown-countdown" data-remaining="%1$d">%2$s</span>',
$remaining,
esc_html( self::format_duration( $remaining ) ),
);
}
/**
* Builds the countdown script tag, once per request.
*
* @return string
*/
private static function countdown_script(): string {
if ( self::$script_printed ) {
return '';
}
self::$script_printed = true;
return wp_get_inline_script_tag( self::countdown_javascript() );
}
/**
* Builds the countdown JavaScript.
*
* The clock starts from the seconds remaining according to the server and advances with performance.now(),
* which is monotonic. A workstation with a wrong or shifting system clock still shows the correct countdown.
*
* @return string
*/
private static function countdown_javascript(): string {
return \sprintf(
'( function () {
var nodes = document.querySelectorAll( ".apermo-lockdown-countdown" );
if ( ! nodes.length ) {
return;
}
var expired = %1$s;
var reload = %2$s;
var started = performance.now();
var items = Array.prototype.map.call( nodes, function ( node ) {
return { node: node, start: parseInt( node.getAttribute( "data-remaining" ), 10 ) || 0 };
} );
function pad( value ) {
return value < 10 ? "0" + value : String( value );
}
function format( total ) {
var days = Math.floor( total / 86400 );
var clock = pad( Math.floor( total %% 86400 / 3600 ) ) + ":" +
pad( Math.floor( total %% 3600 / 60 ) ) + ":" + pad( total %% 60 );
return days > 0 ? days + "d " + clock : clock;
}
var timer = setInterval( function () {
var gone = Math.round( ( performance.now() - started ) / 1000 );
var running = false;
items.forEach( function ( item ) {
var left = item.start - gone;
running = running || left > 0;
item.node.textContent = left > 0 ? format( left ) : expired;
} );
if ( ! running ) {
clearInterval( timer );
if ( reload ) {
window.location.reload();
}
}
}, 1000 );
}() );',
wp_json_encode( self::EXPIRED_LABEL ),
wp_json_encode( self::RELOAD_ON_LOGOUT ),
);
}
/**
* Formats a number of seconds the way the countdown displays it.
*
* @param int $seconds Seconds to format.
*
* @return string
*/
private static function format_duration( int $seconds ): string {
if ( $seconds <= 0 ) {
return self::EXPIRED_LABEL;
}
$clock = \sprintf(
'%02d:%02d:%02d',
\intdiv( $seconds % \DAY_IN_SECONDS, \HOUR_IN_SECONDS ),
\intdiv( $seconds % \HOUR_IN_SECONDS, \MINUTE_IN_SECONDS ),
$seconds % \MINUTE_IN_SECONDS,
);
$days = \intdiv( $seconds, \DAY_IN_SECONDS );
return $days > 0 ? $days . 'd ' . $clock : $clock;
}
}
Maintenance_Lockdown::init();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment