Summary
In multiuser mode, changing your own password signs you out — but only if a sliding-window token
refresh happened to be accepted in the previous 60 seconds, which makes it look intermittent.
The server goes to some trouble to prevent exactly this: a password change bumps the user's
revocation epoch, which kills the token that authenticated the request, so the route hands back a
replacement token in X-Refreshed-Token (_issue_replacement_token, invokeai/app/api/routers/auth.py:41-63,
called at :773 for PATCH /auth/me and :642 for an admin resetting their own password). The
client discards that replacement whenever its refresh throttle is active.
Steps to reproduce
- Run with multiuser mode enabled and sign in.
- Use the app normally for a few seconds — any mutating request will do, and simply navigating
the UI persists client state, so this is the usual state of affairs.
- Within 60 seconds of that, open the profile page and change your password. The request
succeeds (HTTP 200, the password really is changed).
- On the next request the session ends and you are returned to the login screen.
Wait more than 60 seconds after the last accepted refresh before changing the password, and the
session survives — which is what makes this look flaky rather than deterministic.
Root cause
PATCH /api/v1/auth/me bumps token_epoch and returns the replacement token in
X-Refreshed-Token (routers/auth.py:698-776).
- The response goes through
dynamicBaseQuery, which hands the header to acceptRefreshedToken
(invokeai/frontend/web/src/services/api/index.ts:151-157).
acceptRefreshedToken returns early when isTokenRefreshThrottled() is true — a flat 60s
window since the last accepted refresh (features/auth/store/authTokenRefresh.ts:16-24, 60-62).
The replacement is dropped silently; localStorage.auth_token keeps the superseded token.
- That token now fails the epoch check in
resolve_authorized_user
(invokeai/app/api/auth_dependencies.py:68-69), so the next request 401s.
shouldEndSessionForUnauthorized is true (the stale token is still the live one), so the client
dispatches sessionExpiredLogout() (services/api/index.ts:147-149).
There is no recovery path: the sliding-window middleware correctly refuses to refresh a token whose
epoch is already stale, so nothing re-mints it.
Why the throttle exists
It is right for what it was written for: the middleware mints a replacement on every mutating
request, and each acceptance costs a serialized media-cookie round trip under a cross-tab lock, so
re-committing once a minute is plenty for a token whose lifetime is measured in days. The bug is
that it treats a revocation-advancing replacement as if it were one of those routine slides.
Suggested fix
Throttle routine slides only. A replacement whose token_epoch differs from the token it replaces
is not a slide — it is the only credential that will work from that moment on, and dropping it
strands the session. Gating the early return on the epoch being unchanged is enough; the identity
(user_id) still has to match, which shouldAcceptRefreshedToken already enforces.
PR #9540 adds a claims decoder and a user_id:token_epoch session key to authSlice, which would
make that check a one-liner, but the fix does not depend on it.
Impact
Not data-destructive — the password change itself succeeds, and signing back in with the new
password works. But the user is bounced to the login screen immediately after a successful
password change, which reads as a failure, and any in-flight work in that tab is interrupted.
Summary
In multiuser mode, changing your own password signs you out — but only if a sliding-window token
refresh happened to be accepted in the previous 60 seconds, which makes it look intermittent.
The server goes to some trouble to prevent exactly this: a password change bumps the user's
revocation epoch, which kills the token that authenticated the request, so the route hands back a
replacement token in
X-Refreshed-Token(_issue_replacement_token,invokeai/app/api/routers/auth.py:41-63,called at
:773forPATCH /auth/meand:642for an admin resetting their own password). Theclient discards that replacement whenever its refresh throttle is active.
Steps to reproduce
the UI persists client state, so this is the usual state of affairs.
succeeds (HTTP 200, the password really is changed).
Wait more than 60 seconds after the last accepted refresh before changing the password, and the
session survives — which is what makes this look flaky rather than deterministic.
Root cause
PATCH /api/v1/auth/mebumpstoken_epochand returns the replacement token inX-Refreshed-Token(routers/auth.py:698-776).dynamicBaseQuery, which hands the header toacceptRefreshedToken(
invokeai/frontend/web/src/services/api/index.ts:151-157).acceptRefreshedTokenreturns early whenisTokenRefreshThrottled()is true — a flat 60swindow since the last accepted refresh (
features/auth/store/authTokenRefresh.ts:16-24, 60-62).The replacement is dropped silently;
localStorage.auth_tokenkeeps the superseded token.resolve_authorized_user(
invokeai/app/api/auth_dependencies.py:68-69), so the next request 401s.shouldEndSessionForUnauthorizedis true (the stale token is still the live one), so the clientdispatches
sessionExpiredLogout()(services/api/index.ts:147-149).There is no recovery path: the sliding-window middleware correctly refuses to refresh a token whose
epoch is already stale, so nothing re-mints it.
Why the throttle exists
It is right for what it was written for: the middleware mints a replacement on every mutating
request, and each acceptance costs a serialized media-cookie round trip under a cross-tab lock, so
re-committing once a minute is plenty for a token whose lifetime is measured in days. The bug is
that it treats a revocation-advancing replacement as if it were one of those routine slides.
Suggested fix
Throttle routine slides only. A replacement whose
token_epochdiffers from the token it replacesis not a slide — it is the only credential that will work from that moment on, and dropping it
strands the session. Gating the early return on the epoch being unchanged is enough; the identity
(
user_id) still has to match, whichshouldAcceptRefreshedTokenalready enforces.PR #9540 adds a claims decoder and a
user_id:token_epochsession key toauthSlice, which wouldmake that check a one-liner, but the fix does not depend on it.
Impact
Not data-destructive — the password change itself succeeds, and signing back in with the new
password works. But the user is bounced to the login screen immediately after a successful
password change, which reads as a failure, and any in-flight work in that tab is interrupted.