No server-side session invalidation on password change/logout; JWTs stay valid and can be renewed indefinitely
medium Possibly Valid medium confidence
Status
Possibly Valid
The lack of server-side session invalidation on password change/logout and the indefinite renew are code-proven (CWE-613), and the developers clearly had the invalidation signal available but left it advisory. Rated possibly_valid because it overlaps an explicitly-accepted stateless-JWT assumption and exploitation presupposes a held/compromised token; the renew path meaningfully contradicts that assumption's 'token lifetime is the maximum revocation window' premise, which is why it is worth surfacing.
Repository / Component
Plain-English Description
Changing your password or logging out does not actually cut off sessions that are already signed in. An old login token keeps working until it expires, and it can be repeatedly refreshed to stay alive, so someone who got hold of a token is not locked out by a password change.
Description of the Underlying Issue
On every authenticated request the server computes an invalidation signal — updated := tk.IssuedAt < LastUpdate(tk.User.ID) (http/auth.go:97) — which flips true after a password change, because Users.Update bumps the per-user updated timestamp (users/storage.go:76-91). However, withUser only uses this signal advisorily: when expiresSoon || updated it merely adds an X-Renew-Token response header (http/auth.go:96-101) and still proceeds; it never rejects the token. Logout is client-side only (frontend/src/utils/auth.ts), and a repo grep shows no server-side logout/revoke/jti/token-version endpoint. renewHandler (http/auth.go:208-213) mints a fresh full-lifetime token from any presented live token with no re-authentication and no absolute lifetime cap, so a token can be renewed indefinitely. Net effect (CWE-613): the server has the information needed to invalidate a session but does not act on it, and provides no revocation mechanism.
Potential Attack
An adversary who holds a valid token (e.g., token theft, a shared or compromised device, or a leaked token) continues to use it after the legitimate user changes their password or 'logs out'. To retain access beyond the original expiry, the adversary calls /api/renew before the token expires, repeatedly, obtaining fresh full-lifetime tokens indefinitely. The password change that the victim believes revoked access does not.
Outcomes of Potential Attack
Persistent unauthorized access to the victim's account that survives password change and logout and can be extended without bound via renew. Password rotation — the standard user response to suspected compromise — fails to evict the attacker.
Affected Scope
All authenticated sessions: password change and logout do not invalidate existing JWTs; the stale-token signal is advisory only and /api/renew extends any live token indefinitely.
Suggested Fix (plain english)
Make password change and logout actually end existing sessions, and stop letting tokens be refreshed forever.
Suggested Fix (detailed)
Promote the existing advisory signal to enforcement: in withUser (http/auth.go), reject the request (401) when tk.IssuedAt < LastUpdate(user) instead of only setting X-Renew-Token; ensure the LastUpdate timestamp is persisted so it survives restarts. Add a server-side logout/revocation mechanism (e.g., a token-version or jti value stored per user that invalidates outstanding tokens). In renewHandler (http/auth.go:208-213), enforce an absolute maximum session lifetime measured from the original issuance so renewals cannot extend a session indefinitely. Verify by changing a password and confirming the previously issued token is rejected on the next request and cannot be renewed. Reject tokens when tk.IssuedAt < LastUpdate(user) (promote the existing advisory signal to enforcement), persist that timestamp, add server-side logout/revocation, and cap absolute token lifetime across renewals.
Validation
The lack of server-side session invalidation on password change/logout and the indefinite renew are code-proven (CWE-613), and the developers clearly had the invalidation signal available but left it advisory. Rated possibly_valid because it overlaps an explicitly-accepted stateless-JWT assumption and exploitation presupposes a held/compromised token; the renew path meaningfully contradicts that assumption's 'token lifetime is the maximum revocation window' premise, which is why it is worth surfacing.
Full Evidence
http/auth.go:96-101 stale-token signal (expiresSoon/updated) only sets the advisory X-Renew-Token header; the token is NOT rejected
http/auth.go:97 updated := tk.IssuedAt < LastUpdate(tk.User.ID) — the invalidation check is computed and available server-side
users/storage.go:76-91 Update bumps s.updated[id]; so the signal flips after a password change (which calls Users.Update)
http/auth.go:208-213 renewHandler mints a fresh full-lifetime token from the presented one with no absolute cap and no re-auth
frontend/src/utils/auth.ts logout is client-only; repo grep shows no server-side logout/revoke/jti/token-version endpoint
Proven fact: The server computes an invalidation signal on password change but only uses it advisorily; withUser does not reject on it
Proven fact: renewHandler extends any live token indefinitely with no absolute lifetime cap
Proven fact: There is no server-side session revocation/blacklist — complete in source
Unvalidated fact: This overlaps the project's explicitly-accepted 'jwt-no-session-revocation' security assumption; classifying it as a must-fix vulnerability vs the accepted stateless-JWT tradeoff is the unresolved point
Unvalidated fact: Practical impact requires the adversary to already hold a valid token (theft/shared device); the renew-indefinitely angle requires the adversary to actively renew before expiry
http/auth.go:96-101 — expiresSoon/updated only set the advisory X-Renew-Token header; the request still proceeds and the token is NOT rejected (verified in source)
http/auth.go:97 — updated := tk.IssuedAt != nil && tk.IssuedAt.Unix() < d.store.Users.LastUpdate(tk.User.ID); the invalidation check is computed server-side but unused for enforcement
users/storage.go:76-91 — Update bumps the per-user updated timestamp, so the signal flips after a password change (which calls Users.Update)
http/auth.go:208-213 — renewHandler mints a fresh full-lifetime token from the presented token with no absolute cap and no re-auth
frontend/src/utils/auth.ts — logout is client-only; no server-side logout/revoke/jti/token-version endpoint exists in the repo
Proven fact: The server computes an invalidation signal on password change but uses it only advisorily; withUser does not reject on it (http/auth.go:96-101)
Proven fact: renewHandler extends any live token with a fresh full lifetime and no absolute cap (http/auth.go:208-213)
Proven fact: There is no server-side session revocation/blacklist/logout endpoint — complete in source
Unvalidated fact: This behavior overlaps the project's explicitly-accepted 'jwt-no-session-revocation' stateless-JWT assumption; whether to classify it as a must-fix vulnerability or an accepted design tradeoff is a policy decision, not a code fact
Unvalidated fact: Practical exploitation presupposes the adversary already holds a valid token (theft/shared device); the renew-indefinitely angle additionally requires the adversary to actively renew before each expiry
http/auth.go:96-101 — stale-token signal computed (expiresSoon/updated) but only sets advisory X-Renew-Token header; token is not rejected.
http/auth.go:97 — updated := tk.IssuedAt < d.store.Users.LastUpdate(tk.User.ID) is the exact invalidation check, already available server-side.
users/storage.go:88 — LastUpdate (s.updated[id]) is bumped by Storage.Update; http/users.go:263 shows password changes call Users.Update, so the signal reliably flips after a password change.
http/auth.go:208-213 + http/auth.go:231-236 — renewHandler/printToken mint a fresh full-lifetime token from time.Now() with no absolute cap and no re-auth, enabling indefinite renewal of any live token.
frontend/src/utils/auth.ts:119 — logout is client-only (cookie deletion); repository grep shows no server-side logout/revoke/blacklist/jti/token-version endpoint.
security-assumptions.jsonl (active, jwt-no-session-revocation) — documents this behavior as accepted and claims token lifetime is the 'maximum revocation window'; the renewal path contradicts that premise, motivating elevation to a reviewable finding.