DEV Community

Cover image for WordPress has never stored when your users last logged in, and the column you add records something slightly different
Lucas Fenwick
Lucas Fenwick

Posted on

WordPress has never stored when your users last logged in, and the column you add records something slightly different

Show the Last Login column for users in the WordPress admin by enabling Custom Admin Columns in WP Adminify: go to WP Adminify > Settings > Productivity, scroll to Custom Admin Columns, tick Show "Last Login" Column for Users, and save, and a Last Login column appears on the Users screen, but it starts blank for every existing user and fills in only as each one signs in again, because WordPress core stores no last login date anywhere, there is no last_login field in wp_users and no user meta key core writes for it, so no plugin on any site can show a login that happened before it was installed, and what the column records is the wp_login authentication event rather than activity, which means an editor who ticked Remember Me can work in the dashboard every day for two weeks while that column still reads 14 days ago.

That is the whole answer. The rest of this post is why the column is empty, what it is actually measuring, the login timestamp core keeps and then destroys, and a sorting behaviour that can hide the accounts you are auditing for.

There is no field to reveal

Most admin column tricks are recoveries. The Posts list already knows the post ID and drops it. The Comments list already knows comment_parent and prints an author name instead. You add a column, the number comes back.

This one is not that. Here is wp_users, in full:

ID
user_login
user_pass
user_nicename
user_email
user_url
user_registered
user_activation_key
user_status
display_name
Enter fullscreen mode Exit fullscreen mode

Registration date, yes. Last login, no. And core writes no user meta key for it either. Every implementation of this feature, WP Last Login with its 10,000+ installs, the WPBeginner snippet, rudrastyh's sortable version, all of them do the same two things: hook wp_login, write a timestamp to user meta.

So the column is not uncovering a field. It is starting a recording, and a recording has no past. Every existing user reads Never on day one and fills in one at a time as people sign back in. WPBeginner and rudrastyh both say this plainly, so it is baseline knowledge rather than a discovery. The next three parts are not.

What actually gets recorded

Here is the end of wp_signon() in wp-includes/user.php:

wp_set_auth_cookie( $user->ID, $credentials['remember'] );

/**
 * Fires after the user has successfully logged in.
 */
do_action( 'wp_login', $user->user_login, $user );
Enter fullscreen mode Exit fullscreen mode

wp_login fires on authentication. That is the moment credentials are checked and a cookie is issued.

It does not fire when a browser comes back holding a cookie that is still valid. And wp_set_auth_cookie() issues generous cookies:

if ( $remember ) {
    $expiration = time() + apply_filters( 'auth_cookie_expiration', 14 * DAY_IN_SECONDS, $user_id, $remember );
} else {
    $expiration = time() + apply_filters( 'auth_cookie_expiration', 2 * DAY_IN_SECONDS,  $user_id, $remember );
}
Enter fullscreen mode Exit fullscreen mode

Fourteen days with Remember Me ticked. Two days without. Both adjustable through auth_cookie_expiration, and almost nobody adjusts them.

Work the arithmetic on a real team. An editor who ticks Remember Me and opens the dashboard every weekday re-authenticates roughly twice a month. Their Last Login has a hard staleness ceiling of 14 days, and on any given morning it can read 13 days old while they are actively editing.

Last Login column:  July 11, 2026
Reality:            in the dashboard every day since July 11
Difference:         one auth event vs 14 days of work
Enter fullscreen mode Exit fullscreen mode

Which means the column answers "when did this person last type their password", not "when was this person last here". Those are different questions, and dormant-account audits ask the second one.

Core does record a login time, then throws it away

This is the part I have not found written down anywhere.

WP_Session_Tokens::create() builds a session record and stamps it:

$session = $this->get_session_information( $expiration );

// Timestamp.
$session['login'] = time();
Enter fullscreen mode Exit fullscreen mode

The record lands in the session_tokens user meta, keyed by a hash of the session token:

get_user_meta( $user_id, 'session_tokens', true );
/*
array(
  '9f2c...' => array(
      'expiration' => 1754648160,
      'ip'         => '203.0.113.7',
      'ua'         => 'Mozilla/5.0 ...',
      'login'      => 1753436160,   // <- the login timestamp
  ),
)
*/
Enter fullscreen mode Exit fullscreen mode

So core knows exactly when the login happened. It stores the number inside a live session record, which is destroyed on logout and garbage collected when the session expires.

Core keeps a login timestamp only for users who are logged in right now. The users you are auditing are, by definition, the ones whose record core already deleted. It is a genuinely funny piece of design: the data exists precisely when it is least useful.

Worth knowing for a second reason. If you want to know who is logged in right now, that meta is the answer and the Last Login column is not. Two different questions, two different sources.

The sorting behaviour that inverts the audit

Here is the one that costs something.

Sorting a Users list by a custom column normally means registering the column as sortable and then setting meta_key plus an orderby on the user query. That join is on the meta key, so a user with no row for that key is not sorted to the bottom. That user is excluded from the result set.

Play it out. You enable Last Login specifically to find dormant accounts. You click the column header to sort oldest first. Every row reading Never can drop out of the list, and Never is the strongest dormancy signal on the screen. The audit deletes its own findings.

The fix, if you are building this yourself, is to keep the never-logged-in users in the query with a two-branch meta_query rather than a bare meta_key, via pre_get_users:

add_action( 'pre_get_users', function ( $query ) {
    if ( 'wpa_last_login' !== $query->get( 'orderby' ) ) {
        return;
    }
    $query->set( 'meta_query', array(
        'relation' => 'OR',
        'logged'   => array( 'key' => 'wpa_last_login', 'compare' => 'EXISTS' ),
        'never'    => array( 'key' => 'wpa_last_login', 'compare' => 'NOT EXISTS' ),
    ) );
    $query->set( 'orderby', 'logged' );
} );
Enter fullscreen mode Exit fullscreen mode

Test that on your own install before trusting it, meta_query ordering is fiddly and version-sensitive.

Honestly though, for an actual dormancy audit, skip the column and run the query:

SELECT u.ID, u.user_login, u.user_registered
FROM wp_users u
LEFT JOIN wp_usermeta m
  ON m.user_id = u.ID AND m.meta_key = 'wpa_last_login'
WHERE m.umeta_id IS NULL
ORDER BY u.user_registered ASC;
Enter fullscreen mode Exit fullscreen mode

Every row is someone who has not authenticated since you started recording. Swap wp_ for your real table prefix and swap the meta key for whatever your plugin writes.

The code route, and its fine print

Recording first, because there is nothing to display until you do:

// mu-plugin, not functions.php
add_action( 'wp_login', 'wpa_record_last_login', 10, 2 );
function wpa_record_last_login( $user_login, $user ) {
    update_user_meta( $user->ID, 'wpa_last_login', time() );
}
Enter fullscreen mode Exit fullscreen mode

Then the column, using manage_users_custom_column for the value:

add_filter( 'manage_users_columns', 'wpa_last_login_column' );
function wpa_last_login_column( $columns ) {
    $columns['wpa_last_login'] = 'Last Login';
    return $columns;
}

add_filter( 'manage_users_custom_column', 'wpa_last_login_value', 10, 3 );
function wpa_last_login_value( $output, $column, $user_id ) {
    if ( 'wpa_last_login' !== $column ) {
        return $output;
    }
    $ts = get_user_meta( $user_id, 'wpa_last_login', true );
    if ( ! $ts ) {
        return 'Never';
    }
    return wp_date(
        get_option( 'date_format' ) . ' ' . get_option( 'time_format' ),
        (int) $ts
    );
}
Enter fullscreen mode Exit fullscreen mode

The fine print:

  • Use update_user_meta() and store a raw Unix timestamp, not a formatted string. Formatting at write time locks you out of sorting and out of timezone changes
  • Use wp_date() rather than date() so the output respects the site timezone
  • User meta is global on multisite. wp_usermeta is not per-site, so a Last Login recorded on one site in the network shows on every site's Users screen. That is either useful or misleading depending on what you are auditing
  • The value callback runs per row, so this is one get_user_meta() per user on screen. Fine at 20 rows, worth thinking about at 500
  • Application password requests, REST authentication and programmatic wp_set_current_user() do not go through wp_signon(), so headless and API traffic will not update this
  • In a mu-plugin it survives theme switches. In functions.php it does not

The checkbox route

I did this run with WP Adminify's Productivity module. Settings > Productivity tab > scroll to Custom Admin Columns > tick Show "Last Login" Column for Users > save.

The caveats I will not skip. A Last Login column is a date on a screen. It does not log activity, record sessions, capture IP addresses or flag anything, so treat "track user activity" as marketing rather than a feature. If you actually need activity records, that is a separate job and monitoring user activity in WordPress plus the track users docs is the honest pointer. The column also costs horizontal space on a Users screen already carrying Username, Name, Email, Role and Posts, and Screen Options is where you get it back. The docs page does not document the column as sortable, so treat sorting as unconfirmed, and if it is sortable, check the never-logged-in behaviour above before you rely on it.

One more, for the offboarding case

A last login date is personal data. If you are recording it to satisfy a security policy, it also lands inside whatever data export and erasure obligations you already have, and it should be in your privacy policy alongside the rest of the user meta you collect. Not a reason to skip the column. A reason to write it down.

Short demo

Guide: How to show the Last Login column in the WordPress Users list

So: column on permanently, snippet in a mu-plugin, or a scheduled query? And has anyone here actually cross-checked a dormant list against session_tokens before revoking access?

Top comments (0)