I built a self-service password change form, tested it as a normal user would, and it logged me out of the exact session I was sitting in while I typed the new password. Not some other device, not a stale session somewhere. Me, right now, on the tab I was actively using to change my own password.
what the feature is supposed to do
Signed-in users can change their own password from a tabbed /profile page, under Security. Type your current password to prove it's you, type a new one, done. The obvious security requirement sitting behind that form is: if someone else's session or token is still floating around out there, changing your password should kill it. That's not optional, it's the entire point of letting a password change happen in the first place, if it didn't invalidate old sessions, a stolen token would just survive right through the "fix."
the design that seemed reasonable
My first instinct was "except-current." Revoke every refresh token for this account except the one belonging to the session making this exact request, since that's presumably the legitimate one, the person is proving they know the current password after all. To do that, the handler needs to know which refresh token belongs to this session, so it can skip it. The obvious place to find that is the refresh_token cookie riding along on the request:
// first attempt: read the current session's own refresh cookie so we
// know which one to spare
currentToken := r.CookieValue("refresh_token")
if err := qtx.RevokeAllUserRefreshTokensExcept(ctx, userID, currentToken); err != nil {
writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to revoke sessions")
return
}Except the cookie isn't scoped the way I'd half-assumed. Refresh cookies here are set with Path=/auth:
func (s *Server) newRefreshCookie(rawToken string, maxAge time.Duration) *http.Cookie {
return &http.Cookie{
Name: "refresh_token",
Value: rawToken,
Path: "/auth",
HttpOnly: true,
Secure: s.CookieSecure,
SameSite: http.SameSiteStrictMode,
MaxAge: int(maxAge.Seconds()),
}
}Path=/auth means the browser only attaches that cookie to requests whose path actually starts with /auth. A form post from /profile/security isn't one of those. So the moment my except-current logic tried to read refresh_token off the incoming request to figure out which session to spare, it got nothing, every single time, for every legitimate user, because the browser correctly never sent it there in the first place. My fail-safe for "couldn't identify the current session" was to fall back to revoking everything, which is a reasonable fail-safe in isolation. It just meant the fallback fired on literally every real password change, since the happy path it was supposed to be an exception to never actually happened.
I found this by testing the feature end to end instead of just unit-testing the handler in isolation, which would have handed it a cookie that a real browser never would have sent.
the actual fix
Drop the idea of sparing one session by reading a cookie that structurally can't be there. Revoke everything, no exceptions, then immediately mint a brand new refresh token for the session making the request and hand it back in the same response via Set-Cookie. The user stays logged in, just on a freshly issued token instead of their old one, and every other session, the ones that might belong to someone who stole a token, gets nuked along with it:
if err := qtx.RevokeAllUserRefreshTokens(ctx, userID); err != nil {
writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to revoke sessions")
return
}
rawRefreshToken, _, err := s.issueRefreshTokenWith(ctx, qtx, userID)
if err != nil {
writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to issue refresh token")
return
}
if err := tx.Commit(ctx); err != nil {
writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to commit transaction")
return
}
http.SetCookie(w, s.newRefreshCookie(rawRefreshToken, s.RefreshTTL))
w.WriteHeader(http.StatusNoContent)Revoke all, issue one fresh token, set it on the way out, all inside one transaction so there's no window where every session is dead and no new one exists yet. Simpler than the except-current version and it doesn't depend on a cookie the browser was never going to send to that route anyway. Sometimes the fix for "my exception logic doesn't work" is deleting the exception, not debugging it.
proving it with a second session, not just reading the code
I didn't trust myself to just read the new version and call it fixed, given how confidently wrong the first version had felt while I was writing it. The actual test opens two sessions against the real running stack, registers a user, logs a "current" session and grabs its refresh cookie, then logs a second, separate session for the same account and grabs that one's cookie too. Change the password through the current session, then assert two things directly against the real endpoints: the current session's newly rotated cookie still successfully exchanges for a fresh access token on /auth/refresh, and the second session's old cookie gets rejected outright. Both assertions have to hold at once for the fix to actually be correct, a version that accidentally revoked everything including the new token would fail the first, and a version that forgot to revoke other sessions at all would fail the second silently, looking fine from the current session's point of view while leaving every other one wide open.
the smaller fix riding along with it
The same review pass that caught this also caught a related, quieter bug in the login and password-change rate limiter, which had been keying on the frontend container's own address rather than the real visitor's, in the exact same shape of mistake as a rate limiter one hop over in the blog service (worth its own post, since the fix ended up living in the reverse proxy for both). Neither of those was the headline bug going in. Both came out of the same discipline: tracing what a request actually carries at each hop, cookie path included, instead of trusting that code which compiles and passes a narrow unit test is doing what it looks like it's doing once real browsers and real proxies are involved.
the part that made it click
The frontend side of this stayed almost boring by comparison, the silent session-refresh logic in hooks.server.ts already reads whichever refresh_token cookie currently exists and exchanges it for a fresh access token when the short-lived one expires:
const refreshToken = event.cookies.get('refresh_token');
if (refreshToken) {
const response = await event.fetch('/auth/refresh', { method: 'POST' });
...
}Because the new refresh cookie gets set in the exact same response as the password change itself, by the time this code path runs again the browser is already holding the new one. No separate re-login step, no "you've been logged out, please sign back in" message, the session just quietly continues on a new token underneath the user without them noticing anything changed. Verified it properly too, not just by reading the code: changed the password through the real form behind Caddy, confirmed the current session's rotated cookie still worked on the next request, and confirmed a second, separate session's old refresh token got rejected. Plain-words version of the lesson: a cookie's Path isn't a formality, it's an actual access boundary, and a security fallback that silently fires on every request instead of the rare one it's meant for isn't a fallback, it's the real behavior wearing a disguise.