Found while reviewing worktree-hifi-pages, which added a per-session absolute deadline to close the
"keep me signed in for 30 days widens the signature window for everyone" hole (spec
docs/superpowers/specs/2026-09-03-hifi-pages-design.md, Deliberate departures, last row). The
session-cookie half landed: stamp_session_expiry writes SESSION_EXPIRES_AT_KEY
(framework/hosting/simple_module_hosting/session.py:165-176) and UsersAuthProvider.resolve_user
refuses a session past it (modules/users/users/provider.py:87-89). The token half did not.
_resolve_bearer is reached before the session check (modules/users/users/provider.py:73-74) and
its only age test is UserAccessToken.created_at > now - _TOKEN_LIFETIME_SECONDS
(modules/users/users/provider.py:201-205). _TOKEN_LIFETIME_SECONDS is a single process-wide 30 days
(modules/users/users/backend.py:31), and UserAccessToken stores no per-row deadline — only
created_at (modules/users/users/models/access_token.py:22-33).
Three consequences:
What would fix it: give the token path the same two bounds the session path has — record a per-row
deadline on UserAccessToken at mint time (honouring bearer_token_lifetime_seconds for
/auth/token and cookie_max_age_seconds / remember_me_max_age_seconds for sm_auth) and filter
on it instead of the global constant, and add the session_version check to _resolve_bearer so a
password change strands tokens the way it strands sessions.
Found while reviewing
worktree-hifi-pages, which added a per-session absolute deadline to close the"keep me signed in for 30 days widens the signature window for everyone" hole (spec
docs/superpowers/specs/2026-09-03-hifi-pages-design.md, Deliberate departures, last row). Thesession-cookie half landed:
stamp_session_expirywritesSESSION_EXPIRES_AT_KEY(
framework/hosting/simple_module_hosting/session.py:165-176) andUsersAuthProvider.resolve_userrefuses a session past it (
modules/users/users/provider.py:87-89). The token half did not._resolve_beareris reached before the session check (modules/users/users/provider.py:73-74) andits only age test is
UserAccessToken.created_at > now - _TOKEN_LIFETIME_SECONDS(
modules/users/users/provider.py:201-205)._TOKEN_LIFETIME_SECONDSis a single process-wide 30 days(
modules/users/users/backend.py:31), andUserAccessTokenstores no per-row deadline — onlycreated_at(modules/users/users/models/access_token.py:22-33).Three consequences:
auth_backend.loginwrites ansm_authcookie with a 14-dayMax-Age(modules/users/users/deps.py:38-42), but the rowbehind it is accepted for 30. The
Max-Ageis browser-enforced only, so a cookie lifted offdisk is replayable for a month — exactly the hole
SESSION_EXPIRES_AT_KEYclosed for thesession cookie.
/api/users/auth/tokenlies aboutexpires_in._create_token_pairreturnsexpires_in=settings.bearer_token_lifetime_seconds, which defaults to 15 minutes(
modules/users/users/auth_local/token_api.py:168,modules/users/users/settings.py:74).Nothing reads that setting anywhere else — a client that honours it re-authenticates every 15
minutes while the token it discarded stays valid for 30 days.
change_my_passwordbumpssession_versionand forgets the cache (modules/users/users/auth_local/self_account.py:89-93)but deletes no
UserAccessTokenrows, and_resolve_bearernever readssession_versionatall (
modules/users/users/provider.py:205-219) — unlike_load_user, which does(
modules/users/users/provider.py:245-248).POST /me/sessions/revoke-allis sound: itdeletes the rows (
modules/users/users/auth_local/self_account.py:127).session_has_expiredaccepts a session with noexpires_atso a deploy does not sign everyone out; the docstring(
framework/hosting/simple_module_hosting/session.py:179-186) says to revisit once deployedsessions have turned over past 30 days. Nothing tracks that date today.
What would fix it: give the token path the same two bounds the session path has — record a per-row
deadline on
UserAccessTokenat mint time (honouringbearer_token_lifetime_secondsfor/auth/tokenandcookie_max_age_seconds/remember_me_max_age_secondsforsm_auth) and filteron it instead of the global constant, and add the
session_versioncheck to_resolve_bearerso apassword change strands tokens the way it strands sessions.