[GH-ISSUE #4387] Last Login Date/Time for User gets not updated #8583

Open
opened 2026-08-05 01:18:43 -04:00 by saavagebueno · 4 comments
Owner

Originally created by @MichaelUray on GitHub (Aug 21, 2025).
Original GitHub issue: https://github.com/netbirdio/netbird/issues/4387

Describe the problem
Even I just logged out and logged in in with my user to the dashboard, it does not update the last login date/time.
Image

NetBird version
selfhosted docker latest (0.55.1)

Nothing obvious to see in the logs.
I did migrate from SQLite to Postgres about two weeks ago if that matters.

Originally created by @MichaelUray on GitHub (Aug 21, 2025). Original GitHub issue: https://github.com/netbirdio/netbird/issues/4387 **Describe the problem** Even I just logged out and logged in in with my user to the dashboard, it does not update the last login date/time. <img width="1465" height="176" alt="Image" src="https://github.com/user-attachments/assets/672bd25c-deb9-4c4b-b358-b62847508324" /> **NetBird version** selfhosted docker latest ([0.55.1](https://hub.docker.com/layers/netbirdio/netbird/0.55.1/images/sha256-493efcf6cabe6b0e54487feeb26655f031e28996eb2d31868031f2fa81e436d5)) Nothing obvious to see in the logs. I did migrate from SQLite to Postgres about two weeks ago if that matters.
saavagebueno added the triage-needed label 2026-08-05 01:18:43 -04:00
Author
Owner

@saule1508 commented on GitHub (Aug 24, 2025):

This login date is not the date you logged in onto the dashboard, for what I know it is the login date of a peer of your user

<!-- gh-comment-id:3218734886 --> @saule1508 commented on GitHub (Aug 24, 2025): This login date is not the date you logged in onto the dashboard, for what I know it is the login date of a peer of your user
Author
Owner

@MichaelUray commented on GitHub (Aug 25, 2025):

for what I know it is the login date of a peer of your user

There are peers which got just connected for this user as well, the peers are shown online - last seen "just now".
From my understanding means last login when the user did connect to the dashboard or to sign in via the web browser to connect.

<!-- gh-comment-id:3221514044 --> @MichaelUray commented on GitHub (Aug 25, 2025): > for what I know it is the login date of a peer of your user There are peers which got just connected for this user as well, the peers are shown online - last seen "just now". From my understanding means `last login` when the user did connect to the dashboard or to sign in via the web browser to connect.
Author
Owner

@MichaelUray commented on GitHub (May 9, 2026):

After re-tracing this in the current codebase I want to share what I found, partly to settle the "what does this field actually mean" thread and partly so any future fixer doesn't have to repeat the archaeology.

What User.LastLogin actually contains

The field is updated by three different code paths with different semantic meanings, all writing to the same column:

1. JWT IdP claim — dashboard or device auth

management/server/user.go:240 inside GetUserFromUserAuth:

err = am.Store.SaveUserLastLogin(ctx, userAuth.AccountId, userAuth.UserId, userAuth.LastLogin)

userAuth.LastLogin is parsed from the JWT's last_login claim by shared/auth/jwt/extractor.go:116. This runs on every authentication that goes through the JWT middleware — both dashboard logins and gRPC device logins. The struct comment in types/user.go says "LastLogin is the last time the user logged in to IdP", but the value comes from a custom last_login claim that most IdPs do not set automatically:

  • Keycloak's standard claim for authentication time is auth_time, not last_login. Without explicit mapper configuration, last_login is missing or stale.
  • Auth0 needs an Action / Rule to populate this claim.
  • Dex passes through whatever the upstream IdP issued.

When the claim is missing or unparseable the extractor falls back to zero-time, and SaveUserLastLogin then short-circuits without saving (see below).

There is an explicit TODO comment in the same function acknowledging the next problem:

"this code should be outside of the am.GetAccountIDFromToken(claims) because this method is called also by the gRPC server when user authenticates a device. And we need to separate the Dashboard login event from the Device login event."

2. New peer added by user

management/server/peer.go:811:

case addedByUser:
    err := transaction.SaveUserLastLogin(ctx, accountID, userID, newPeer.GetLastLogin())

Adding a new peer (netbird up on a fresh device) writes the peer's registration timestamp into User.LastLogin.

3. Expired peer re-authenticated

management/server/peer.go:1203 inside handleExpiredPeer:

err = transaction.SaveUserLastLogin(ctx, user.AccountID, user.Id, peer.GetLastLogin())

Whenever a peer's session expires and the user re-authenticates that peer, the peer's new login timestamp lands in User.LastLogin.

Save logic always overwrites

management/server/store/sql_store.go:2731:

func (s *SqlStore) SaveUserLastLogin(...) error {
    // ...
    if !lastLogin.IsZero() {
        user.LastLogin = &lastLogin
        return s.db.Save(&user).Error
    }
    return nil
}

No MAX(existing, new) comparison. The field is always overwritten, even if the new value is older than the existing one. This is the smoking gun for the user-visible symptom in this issue: a JWT carrying a stale last_login claim can silently roll the displayed timestamp backwards, hiding the more recent dashboard or peer event.

So what does the dashboard actually display?

The latest of:

  • whatever the IdP put into the JWT's last_login claim (if anything)
  • the registration timestamp of the most recently added peer
  • the re-auth timestamp of the most recently expired-and-renewed peer

…minus any older value that happened to overwrite it. The label "Last Login" suggests a clear semantic, but in practice the field is multi-source with no ordering guarantee.

Why dashboard re-login appears not to update it

For the symptom in the issue ("logged out and logged in, no update") the most likely chains, ranked by how often I've seen them in self-hosted setups:

  1. IdP doesn't populate the last_login claim on token issuance. No claim → zero-time → SaveUserLastLogin no-ops. The field stays at whatever the most recent peer event wrote.
  2. IdP populates the claim with session-start time, not token-issuance time. The session was started before the most recent peer event → the JWT carries an older timestamp → silent rollback.
  3. Active peer keep-alive overwrites with peer's last-seen. This shouldn't happen on the standard paths shown above (the three call sites are all explicit user/peer auth events, not periodic keep-alives), but it's worth verifying for setups where peer expiration is configured aggressively.

Suggested fix direction (open for discussion before I send a PR)

A clean fix would split the field by source rather than try to reconcile sources:

  • New columns users.last_dashboard_login and users.last_device_auth. Dashboard UI shows the right one in the right context (and either field can be exposed in the API alongside the existing last_login for backwards compat).
  • Each SaveUserLastLogin* callsite writes to the field that matches its semantic.
  • The LastLogin column stays as-is for one release as a synthetic MAX(last_dashboard_login, last_device_auth) so existing API consumers don't break.
  • The JWT-extractor falls back to the token's iat claim if last_login is empty or unparseable — iat is a standard JWT claim and a reasonable proxy for "when this session started".

This is a meaningful change (DB migration on users, API schema, and a coordinated dashboard-repo update), so I'd appreciate maintainer signal on whether this is worth a PR before opening one. Happy to scope it to a smaller increment if a partial fix would land more easily — e.g. just the MAX() comparison in SaveUserLastLogin plus the iat fallback would already make the symptom much rarer without a schema change.

<!-- gh-comment-id:4414025666 --> @MichaelUray commented on GitHub (May 9, 2026): After re-tracing this in the current codebase I want to share what I found, partly to settle the "what does this field actually mean" thread and partly so any future fixer doesn't have to repeat the archaeology. ## What `User.LastLogin` actually contains The field is updated by **three** different code paths with **different semantic meanings**, all writing to the same column: ### 1. JWT IdP claim — dashboard *or* device auth [`management/server/user.go:240`](https://github.com/netbirdio/netbird/blob/main/management/server/user.go#L240) inside `GetUserFromUserAuth`: ```go err = am.Store.SaveUserLastLogin(ctx, userAuth.AccountId, userAuth.UserId, userAuth.LastLogin) ``` `userAuth.LastLogin` is parsed from the JWT's `last_login` claim by [`shared/auth/jwt/extractor.go:116`](https://github.com/netbirdio/netbird/blob/main/shared/auth/jwt/extractor.go#L116). This runs on **every** authentication that goes through the JWT middleware — both dashboard logins and gRPC device logins. The struct comment in [`types/user.go`](https://github.com/netbirdio/netbird/blob/main/management/server/types/user.go) says *"LastLogin is the last time the user logged in to IdP"*, but the value comes from a custom `last_login` claim that most IdPs do **not** set automatically: - Keycloak's standard claim for authentication time is `auth_time`, not `last_login`. Without explicit mapper configuration, `last_login` is missing or stale. - Auth0 needs an Action / Rule to populate this claim. - Dex passes through whatever the upstream IdP issued. When the claim is missing or unparseable the extractor falls back to zero-time, and `SaveUserLastLogin` then short-circuits without saving (see below). There is an explicit TODO comment in the same function acknowledging the next problem: > *"this code should be outside of the am.GetAccountIDFromToken(claims) because this method is called also by the gRPC server when user authenticates a device. And we need to separate the Dashboard login event from the Device login event."* ### 2. New peer added by user [`management/server/peer.go:811`](https://github.com/netbirdio/netbird/blob/main/management/server/peer.go#L811): ```go case addedByUser: err := transaction.SaveUserLastLogin(ctx, accountID, userID, newPeer.GetLastLogin()) ``` Adding a new peer (`netbird up` on a fresh device) writes the **peer's** registration timestamp into `User.LastLogin`. ### 3. Expired peer re-authenticated [`management/server/peer.go:1203`](https://github.com/netbirdio/netbird/blob/main/management/server/peer.go#L1203) inside `handleExpiredPeer`: ```go err = transaction.SaveUserLastLogin(ctx, user.AccountID, user.Id, peer.GetLastLogin()) ``` Whenever a peer's session expires and the user re-authenticates that peer, the **peer's** new login timestamp lands in `User.LastLogin`. ## Save logic always overwrites [`management/server/store/sql_store.go:2731`](https://github.com/netbirdio/netbird/blob/main/management/server/store/sql_store.go#L2731): ```go func (s *SqlStore) SaveUserLastLogin(...) error { // ... if !lastLogin.IsZero() { user.LastLogin = &lastLogin return s.db.Save(&user).Error } return nil } ``` No `MAX(existing, new)` comparison. The field is **always overwritten**, even if the new value is older than the existing one. This is the smoking gun for the user-visible symptom in this issue: a JWT carrying a stale `last_login` claim can silently roll the displayed timestamp **backwards**, hiding the more recent dashboard or peer event. ## So what does the dashboard actually display? The latest of: - whatever the IdP put into the JWT's `last_login` claim (if anything) - the registration timestamp of the most recently added peer - the re-auth timestamp of the most recently expired-and-renewed peer …minus any older value that happened to overwrite it. The label "Last Login" suggests a clear semantic, but in practice the field is **multi-source with no ordering guarantee**. ## Why dashboard re-login appears not to update it For the symptom in the issue ("logged out and logged in, no update") the most likely chains, ranked by how often I've seen them in self-hosted setups: 1. **IdP doesn't populate the `last_login` claim on token issuance.** No claim → zero-time → `SaveUserLastLogin` no-ops. The field stays at whatever the most recent peer event wrote. 2. **IdP populates the claim with session-start time, not token-issuance time.** The session was started before the most recent peer event → the JWT carries an older timestamp → silent rollback. 3. **Active peer keep-alive overwrites with peer's last-seen.** This shouldn't happen on the standard paths shown above (the three call sites are all explicit user/peer auth events, not periodic keep-alives), but it's worth verifying for setups where peer expiration is configured aggressively. ## Suggested fix direction (open for discussion before I send a PR) A clean fix would split the field by source rather than try to reconcile sources: - New columns `users.last_dashboard_login` and `users.last_device_auth`. Dashboard UI shows the right one in the right context (and either field can be exposed in the API alongside the existing `last_login` for backwards compat). - Each `SaveUserLastLogin*` callsite writes to the field that matches its semantic. - The `LastLogin` column stays as-is for one release as a synthetic `MAX(last_dashboard_login, last_device_auth)` so existing API consumers don't break. - The JWT-extractor falls back to the token's `iat` claim if `last_login` is empty or unparseable — `iat` is a standard JWT claim and a reasonable proxy for "when this session started". This is a meaningful change (DB migration on `users`, API schema, and a coordinated dashboard-repo update), so I'd appreciate maintainer signal on whether this is worth a PR before opening one. Happy to scope it to a smaller increment if a partial fix would land more easily — e.g. just the `MAX()` comparison in `SaveUserLastLogin` plus the `iat` fallback would already make the symptom much rarer without a schema change.
Author
Owner

@JasmeowTheCat commented on GitHub (Jul 27, 2026):

Still present on v0.75.0 (combined netbird-server image, dashboard v2.90.7), so this isn't migration-related — I'm on SQLite, never migrated to PostgreSQL, using the embedded Dex IdP with an upstream Authentik OIDC connector and localAuthDisabled: true.

Useful detail for triage: the login is definitely being processed. In the same login that left last_login untouched, JWT group sync ran and added a new group to users.auto_groups:

sqlite> select id, name, issued from "groups" where name like '%Game%';
d9jv1t32tu3s34dklf40|GameServer: Client|jwt

sqlite> select datetime(last_login) from users where id='abc';
2026-07-12 18:49:12

So the authentication path works and writes group changes, but the last_login update is either never issued or gated on a code path the embedded-Dex setup doesn't reach. Peer last_login values update correctly — this looks specific to the user record.

<!-- gh-comment-id:5098339344 --> @JasmeowTheCat commented on GitHub (Jul 27, 2026): Still present on v0.75.0 (combined netbird-server image, dashboard v2.90.7), so this isn't migration-related — I'm on SQLite, never migrated to PostgreSQL, using the embedded Dex IdP with an upstream Authentik OIDC connector and localAuthDisabled: true. Useful detail for triage: the login is definitely being processed. In the same login that left last_login untouched, JWT group sync ran and added a new group to users.auto_groups: sqlite> select id, name, issued from "groups" where name like '%Game%'; d9jv1t32tu3s34dklf40|GameServer: Client|jwt sqlite> select datetime(last_login) from users where id='abc'; 2026-07-12 18:49:12 So the authentication path works and writes group changes, but the last_login update is either never issued or gated on a code path the embedded-Dex setup doesn't reach. Peer last_login values update correctly — this looks specific to the user record.
Sign in to join this conversation.
No Label triage-needed
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: DYNR/netbird#8583