Guide

WordPress logout you didn't perform — detecting session hijacking

You were editing a post, hit Update, and WordPress bounced you to /wp-login.php with the message "Your session has expired. Please log in again." You log back in, go back to work, and within an hour it happens again.

1. Problem

You were editing a post, hit Update, and WordPress bounced you to /wp-login.php with the message "Your session has expired. Please log in again." You log back in, go back to work, and within an hour it happens again. Or you opened the dashboard this morning and your remembered session was simply gone — even though you ticked "Remember Me" two days ago. Sometimes you see a banner saying you have been logged out from another location.

If you are searching "wordpress logged me out" or "wordpress logged out unexpectedly multiple times," this is a WordPress logout you didn't perform, and the question is not how to stop the popup. The question is whether someone else triggered it. Repeated unexpected logouts are one of the few user-visible artifacts of session hijacking on WordPress, and the platform does almost nothing to help you tell the difference between a benign session expiry and an attacker forcing you out so they can keep their own session alive.

The evidence that distinguishes the two cases is in the logs. Specifically, in the pattern of auth.logout events around your account, correlated with auth.attempt successes from new IP addresses and any wp.state_change touching authentication keys.

2. Impact

A single unexpected logout is annoying. A pattern of them is a security incident in progress.

Concrete impact when an admin session is compromised:

  • Silent privilege use. An attacker with an active admin cookie can install plugins, edit functions.php, create new admin users, and exfiltrate customer data. They do not need your password again — the session cookie is the password.
  • Persistence implants. Competent attackers plant a way back in: a new admin user with an innocuous name, a malicious plugin, a backdoor in a theme file, or a scheduled task that re-creates access.
  • Customer data exposure. WooCommerce stores expose customer addresses, order history, and partial payment metadata to admins. A hijacked admin session is a data breach under GDPR.
  • SEO and reputation damage. Spam injection, redirect malware, and SEO poisoning are common monetization paths. The damage outlives the session.

Detecting the hijack at the logout event — not after the malicious plugin is installed — is the difference between an incident and an outage.

3. Why It’s Hard to Spot

WordPress was not built to surface this. The platform treats every logout as a routine event, and the user-facing UI gives you no way to tell apart "your cookie expired" from "your session was destroyed by an attacker."

  • The logout screen lies by omission. "Your session has expired. Please log in again" is the same message whether the cookie timed out, you logged out in another tab, or someone called wp_destroy_other_sessions() from a hijacked admin context.
  • Users tab is shallow. wp-admin/users.php shows a "Sessions" link only on your own profile. There is no per-session metadata — no IP, no user-agent, no creation timestamp surfaced in the UI.
  • No login-from-new-location alerts by default. Unlike Google or GitHub, WordPress does not email you when a successful login occurs from a new IP. A hijacker who steals a valid cookie can use it from anywhere.
  • Session tokens are stored opaquely. Session metadata lives in wp_usermeta under session_tokens as a serialized PHP array. Not human-readable without wp eval or a plugin.
  • 2FA does not protect existing cookies. 2FA validates auth.attempt, not the session that follows.
  • Most hosts do not log access by user. Web server logs show IPs hitting /wp-admin/. They do not tell you which authenticated user that request belonged to.

The result: WordPress is structurally bad at telling you that someone else is also using your account. The clearest signal you get is a logout you did not request — and that signal is buried in noise unless you are watching for it.

4. Cause

WordPress authentication is cookie-based. After you log in successfully, WordPress writes a signed cookie that includes your username, an expiration timestamp, and a hash. The hash is signed using two secrets stored in wp-config.php: AUTH_KEY and AUTH_SALT (and the matching LOGGED_IN_ and SECURE_AUTH_ pairs). On every subsequent request, WordPress validates the cookie by recomputing the hash with those keys.

A logout — or a forced logout — produces an auth.logout signal. There are four ways an auth.logout is generated:

  • User clicks "Log Out". Normal, expected, paired with a deliberate user action.
  • Cookie expiration. The cookie's timestamp is past. WordPress destroys the session and emits the signal. Default lifetime is 48 hours, or 14 days with "Remember Me".
  • wp_destroy_other_sessions() is called. WordPress invalidates all sessions except the current one. This is what happens when you change your password from the profile page — and what an attacker triggers to keep their session while killing yours.
  • Auth keys rotated. If AUTH_KEY or AUTH_SALT changes in wp-config.php, every existing cookie becomes invalid. Every session ends in an auth.logout event. This is sometimes intentional, often a side effect, and occasionally an attacker covering their tracks.

The hijacking pattern looks like this in auth.logout data:

  • Multiple auth.logout events for the same user account within a short window.
  • The IP address and user agent of the logout do not match any of that user's recent logins.
  • Just before or just after the logout, there is a successful auth.attempt for the same user from a new IP, often a new country or hosting-provider ASN.
  • Sometimes a wp.state_change event firing on the wp_options row holding auth_key or on the usermeta session_tokens row.

Each of those individually is noise. Together they are a hijack signature.

5. Solution

5.1 Diagnose (logs first)

The diagnostic move is to reconstruct the session timeline for the affected user from logs. You are looking for auth.logout events that lack a matching user click, paired with auth.attempt successes from unfamiliar IPs.

1. Pull authentication events from the audit log.

If you have an audit-log plugin (WP Activity Log, Simple History, Stream) the events live in their own tables or in a CSV export. Without one, you have web server logs and PHP error logs, and you reconstruct from POST requests to wp-login.php and /wp-admin/profile.php:

# Successful logins (302 redirect away from wp-login.php)
grep -E 'POST .*/wp-login\.php' /var/log/nginx/access.log \
  | grep ' 302 ' \
  | awk '{print $1, $4, $7, $NF}'

# Logout events (the wp-login.php?action=logout endpoint)
grep -E 'GET .*/wp-login\.php\?action=logout' /var/log/nginx/access.log \
  | awk '{print $1, $4, $7}'

Each wp-login.php 302 produces an auth.attempt signal (success or failure depending on the redirect target). Each logout request produces an auth.logout signal.

2. Cross-check against PHP error and slow logs.

# Look for session destruction calls and user meta writes
grep -E 'destroy_other_sessions|wp_destroy_all_sessions' \
  /var/log/php-fpm/error.log

Any hit there means code, not a user action, killed sessions. That code path firing for a non-admin context is suspicious. Each call corresponds to one or more auth.logout events.

3. Inspect session tokens directly.

# Show all active sessions for the user, including IPs and user-agents
wp user meta get USERNAME session_tokens --format=json | jq .

Each entry includes expiration, ip, ua, and login (creation time). Two active sessions for one admin user, originating from different countries, is the hijack confirmed. This data populates the same auth.attempt and auth.logout events Logystera tracks.

4. Check for auth-key rotation.

# Compare current AUTH_KEY against any backup of wp-config.php
diff <(grep AUTH_KEY /var/www/html/wp-config.php) \
     <(grep AUTH_KEY /backup/wp-config.php)

A change here invalidates every cookie at once and produces a flood of auth.logout signals across all users. If you did not rotate the keys yourself, someone else did. Auth-key rotation by an attacker is the giveaway that they wanted to silently end every other session — including admin sessions they could not see.

5. Correlate with auth.attempt from new IPs.

For every unexpected auth.logout for an admin user, look for an auth.attempt (success) for that same user within the surrounding 30 minutes from an IP not seen for that user before. If your audit data has it, also compare ASN. A login from a residential ISP followed minutes later by a successful login from a hosting-provider ASN is the canonical hijack pattern.

The signal symmetry: auth.logout tells you the session ended, auth.attempt tells you who started a new one, and the gap between them is the attacker's working window.

5.2 Root Causes

(see root causes inline in 5.3 Fix)

5.3 Fix

Order of operations matters. Sessions and credentials before everything else, then artifacts.

  • Force-logout every session, immediately. Rotate AUTH_KEY, AUTH_SALT, LOGGED_IN_KEY, LOGGED_IN_SALT, SECURE_AUTH_KEY, SECURE_AUTH_SALT, NONCE_KEY, NONCE_SALT in wp-config.php. Use the official generator at https://api.wordpress.org/secret-key/1.1/salt/. Every existing cookie dies on the next request — produces a wave of legitimate auth.logout signals.
  • Cause → signal: stolen cookie still in use → auth.attempt (success) from attacker IP → after rotation, attacker's next request produces failed cookie validation, no longer authenticated.
  • Reset every admin password. Do not just reset yours. Any admin account is a re-entry point. Use wp user reset-password $(wp user list --role=administrator --field=ID).
  • Cause → signal: phished or leaked admin password → repeated auth.attempt successes from attacker IP. Reset breaks reuse.
  • Audit admin users. wp user list --role=administrator --fields=ID,user_login,user_email,user_registered. Look for accounts you did not create. Attackers favour usernames like admin2, wp-support, or display names that mimic real staff.
  • Cause → signal: persistence via new admin user → wp.state_change on the wp_users table at the time of the hijack window.
  • Enforce 2FA on every admin account. A WordPress 2FA plugin (Two Factor, Wordfence Login Security) blocks the auth.attempt path even if the password leaks again. 2FA does not protect already-issued cookies, which is why the salt rotation has to happen first.
  • Check for malicious plugins or mu-plugins. wp plugin list --status=active and ls -la wp-content/mu-plugins/. Plugins that drop into mu-plugins activate silently and produce no admin-screen entry. Each plugin file change should have a matching wp.state_change signal — anything in mu-plugins/ without one is almost certainly hostile.
  • Cause → signal: backdoor planted via hijacked admin → wp.state_change on plugin file write.
  • Review scheduled tasks. wp cron event list. Look for events that recreate users, fetch remote payloads, or call eval. Hijackers often plant a cron job that re-installs their backdoor if you remove it.
  • Check wp-config.php and .htaccess for unfamiliar additions: redirects, auto_prepend_file directives, define('DISALLOW_FILE_EDIT', false) reverted, base64-encoded blocks. Each of these should have left a wp.state_change event when written.
  • Force HTTPS-only and HttpOnly cookies. Set define('FORCE_SSL_ADMIN', true) and confirm cookies are flagged Secure and HttpOnly. A site served over plain HTTP leaks cookies on every public network — it is the single most preventable hijack vector.

If your stack ran on HTTP at any point during the window, assume the cookie was sniffed and act accordingly.

5.4 Verify

The verification target is straightforward: no more unexpected auth.logout events for admin users, and no successful auth.attempt events from unfamiliar IPs.

  • auth.logout baseline. After the salt rotation flushes everyone out once, a healthy WordPress site produces auth.logout events at a rate that matches deliberate user activity — typically a small handful per admin user per day, paired one-to-one with the user's working IP.
  • auth.attempt baseline. Successful auth.attempt events for an admin should originate from one or two known IPs over a 24-hour window. New ASNs are rare.
  • Greps to run for 24–48 hours after remediation:
  # No new logout events from unfamiliar IPs
  grep 'wp-login.php?action=logout' /var/log/nginx/access.log \
    | awk '{print $1}' | sort -u

  # No new successful logins from unknown ASNs
  grep -E 'POST .*wp-login\.php' /var/log/nginx/access.log \
    | grep ' 302 ' | awk '{print $1}' | sort -u

The IP set should be small and known.

  • Session tokens are clean. wp user meta get USERNAME session_tokens --format=json | jq '. | length' should match the number of devices the user actually uses. Running it daily for a week and seeing a stable, expected count is the durable signal.
  • No new wp.state_change on auth keys, admin users, or mu-plugins unless you triggered them yourself. This is the absence-of-signal verification.

If 48 hours pass with auth.logout rate matching expected user behavior, no auth.attempt from new IPs, and no wp.state_change on sensitive paths, the hijacker no longer has access.

6. How to Catch This Early

Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.

This issue surfaces as auth.logout.

The reason hijacks run for hours or days before anyone notices is that WordPress treats auth.logout, auth.attempt, and wp.state_change as routine events. Individually they are. In combination — the same admin user logged out from one IP and logged in from another in the same 30-minute window — they are a signature, and a signature is exactly what a log-driven detection system finds.

The failure mode is not "can I find this in the logs after the fact." You can. The failure mode is that nobody looks until something obvious breaks, and by then the attacker has already planted persistence and moved on. Default WordPress sends no email on a new-IP login. Default uptime checks see only the home page. Default hosting dashboards normalize "logged in successfully" to a count.

This pattern surfaces as auth.logout correlated with auth.attempt from a new IP, frequently followed by wp.state_change on the auth-key options or admin user table — exactly the correlation Logystera detects and alerts on early. The detection is not the result of running a malware scanner; it is the result of treating the three signals as a joined stream and watching for the shape.

You do not need a SIEM to do this, but you do need somewhere those three signal types arrive together, get joined on user and time window, and can fire an alert when the shape matches. That is the prevention layer that does not exist by default and is invisible to your users until the first thing they notice is a logout you didn't perform.

7. Related Silent Failures

  • WordPress credential stuffing — auth.attempt brute force on /wp-login.php — the noisy precursor to the quiet hijack.
  • WordPress REST API user enumeration — auth.attempt against /wp-json/wp/v2/users — how attackers find admin usernames in the first place.
  • WordPress role escalation — wp.state_change on user_capabilities — the persistence step after a session hijack.
  • WordPress plugin silent activation — wp.state_change on active_plugins — the implant step that turns a session into a backdoor.
  • WordPress XML-RPC abuse — auth.attempt floods on /xmlrpc.php — alternate auth surface attackers use when /wp-login.php is rate-limited.

See what's actually happening in your WordPress system

Connect your site. Logystera starts monitoring within minutes.

Logystera Logystera
Monitoring for WordPress and Drupal sites. Install a plugin or module to catch silent failures — cron stalls, failed emails, login attacks, PHP errors — before users report them.
Company
Copyright © 2026 Logystera. All rights reserved.