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
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 );
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 );
}
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
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();
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
),
)
*/
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' );
} );
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;
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() );
}
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
);
}
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 thandate()so the output respects the site timezone - User meta is global on multisite.
wp_usermetais 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 throughwp_signon(), so headless and API traffic will not update this - In a mu-plugin it survives theme switches. In
functions.phpit 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 Last Login column appends to the Users screen after Posts, no code, survives theme switches. Their own guide states the feature is in the free version on wordpress.org, worth verifying against your install before you promise it to a client
- The full walkthrough with the code alternative is on how to show the Last Login column in the WordPress Users list
- The same panel carries the rest of the list-screen columns people usually snippet in one at a time, Show Post/Page ID Column, Comment ID with Parent ID, Taxonomy ID, and a URL Path column with per-post-type selection, all on the Custom Admin Columns docs page
- If you want a date column and also arbitrary meta columns, the standalone Admin Columns Editor is the bigger hammer
- This rundown of necessary admin columns for WordPress is a decent map of which columns earn their width
- On the users side of the same product, hiding the admin bar based on user roles and changing a WordPress username are the two adjustments most sites make next
- It all sits inside the wider Productivity toolset
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.
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)