Concurrent 401s each call /auth/refresh and can log the user out #49

Closed
opened 2026-10-03 06:20:09 +00:00 by araemer · 0 comments
Owner

What happens

During UI verification of the shopping list feature (#47), the headless browser was logged out twice ("Deine Sitzung ist abgelaufen") about an hour after login. Both times POST /auth/refresh itself returned 401, even though the refresh token is valid for 30 days (REFRESH_TOKEN_TTL_MS in recipe-backend/src/handlers/AuthHandler.ts). The access token lives 1 h (recipe-backend/src/utils/encryptionUtils.ts:33), which matches the timing.

This matters most for the shopping list: the main use case is opening the app in a shop after it sat in a pocket for a while, which is exactly when the access token has expired.

Likely mechanism (not fully proven)

  • apiRequest (src/api/apiClient.ts:99) calls refresh() separately for every request that gets a 401. Nothing shares one refresh between concurrent requests.
  • The backend rotates the session on each refresh: it deletes the old session row and creates a new one (AuthHandler.ts:155). A refresh that arrives with an already-rotated cookie finds no row and gets 401 (AuthHandler.ts:134).
  • apiRequest treats a failed refresh as an expired session: clearSession() and SESSION_EXPIRED_EVENT (apiClient.ts:104-105), so one losing refresh logs the user out even if another one succeeded.
  • Pages fire several requests in parallel on mount. For example, the shopping list page loads the overview and the list at the same time (and React StrictMode doubles effects in dev).

Repro of the backend half, with one cookie jar and two refreshes at an offset:

offset 0.02 s: A 200  B 200
offset 0.05 s: A 200  B 200
offset 0.10 s: A 200  B 200
offset 0.30 s: A 200  B 401

Close-together refreshes both succeed (both read the row before either deletes it); a later one with the same cookie fails. In the browser log the failed refreshes were within ~50 ms of each other, which doesn't fully fit this window, so the exact trigger is still open. A successful refresh isn't logged as an error, so the log can't show whether one succeeded first.

Suggested fix

  • recipe-app: make the refresh single-flight in apiClient. Keep one in-flight refresh promise, let every concurrent 401 await that same promise and then retry. Only clear the session when that shared refresh fails.
  • recipe-backend (optional, to discuss): a short grace window in which the just-rotated token is still accepted once, so a refresh racing with another one from the same client doesn't revoke the session.

How to check

Log in, wait for (or force) access-token expiry, then open /shopping-list/<id> (parallel requests on mount). With the fix, the page loads after one refresh and the user stays logged in.

🤖 Generated with Claude Code

## What happens During UI verification of the shopping list feature (#47), the headless browser was logged out twice ("Deine Sitzung ist abgelaufen") about an hour after login. Both times `POST /auth/refresh` itself returned **401**, even though the refresh token is valid for 30 days (`REFRESH_TOKEN_TTL_MS` in `recipe-backend/src/handlers/AuthHandler.ts`). The access token lives 1 h (`recipe-backend/src/utils/encryptionUtils.ts:33`), which matches the timing. This matters most for the shopping list: the main use case is opening the app in a shop after it sat in a pocket for a while, which is exactly when the access token has expired. ## Likely mechanism (not fully proven) - `apiRequest` (`src/api/apiClient.ts:99`) calls `refresh()` separately for **every** request that gets a 401. Nothing shares one refresh between concurrent requests. - The backend rotates the session on each refresh: it deletes the old session row and creates a new one (`AuthHandler.ts:155`). A refresh that arrives with an already-rotated cookie finds no row and gets 401 (`AuthHandler.ts:134`). - `apiRequest` treats a failed refresh as an expired session: `clearSession()` and `SESSION_EXPIRED_EVENT` (`apiClient.ts:104-105`), so one losing refresh logs the user out even if another one succeeded. - Pages fire several requests in parallel on mount. For example, the shopping list page loads the overview and the list at the same time (and React StrictMode doubles effects in dev). Repro of the backend half, with one cookie jar and two refreshes at an offset: ```text offset 0.02 s: A 200 B 200 offset 0.05 s: A 200 B 200 offset 0.10 s: A 200 B 200 offset 0.30 s: A 200 B 401 ``` Close-together refreshes both succeed (both read the row before either deletes it); a later one with the same cookie fails. In the browser log the failed refreshes were within ~50 ms of each other, which doesn't fully fit this window, so the exact trigger is still open. A successful refresh isn't logged as an error, so the log can't show whether one succeeded first. ## Suggested fix - **recipe-app:** make the refresh single-flight in `apiClient`. Keep one in-flight refresh promise, let every concurrent 401 await that same promise and then retry. Only clear the session when that shared refresh fails. - **recipe-backend (optional, to discuss):** a short grace window in which the just-rotated token is still accepted once, so a refresh racing with another one from the same client doesn't revoke the session. ## How to check Log in, wait for (or force) access-token expiry, then open `/shopping-list/<id>` (parallel requests on mount). With the fix, the page loads after one refresh and the user stays logged in. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
araemer/recipe-app#49
No description provided.