Skip to content

fix(auth): move refresh token out of localStorage into an httpOnly cookie - #51

Merged
nazarli-shabnam merged 2 commits into
mainfrom
fix/httponly-refresh-token-cookie
Jul 10, 2026
Merged

fix(auth): move refresh token out of localStorage into an httpOnly cookie#51
nazarli-shabnam merged 2 commits into
mainfrom
fix/httponly-refresh-token-cookie

Conversation

@nazarli-shabnam

@nazarli-shabnam nazarli-shabnam commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

The refresh token was persisted to localStorage via zustand's persist middleware alongside the access token, readable by any script running on the page. Since the refresh token rotates on use rather than expiring quickly, it effectively grants indefinite re-authentication -- a far more valuable XSS target than the short-lived access token.

Changes

Backend (api/app/app/modules/auth/)

  • schemas.py: new TokenResponse (access_token only) is what actually goes in JSON bodies now. Token (internal, still has refresh_token) is used only for service-layer return values.
  • router.py: login/refresh/OAuth callbacks now set the refresh token via Set-Cookie (httponly, secure in production only, samesite=lax, scoped to /api/auth) instead of returning it in the response body. /refresh and /logout read the cookie via request.cookies instead of a request body field -- RefreshRequest/LogoutRequest are gone, no body needed anymore. A failed refresh clears the cookie.

Frontend

  • authStore.ts: dropped the refreshToken field/setters entirely -- there's nothing for the client to store; the cookie is invisible to JS by design and the browser attaches it automatically.
  • lib/api.ts: apiFetch/refreshAccessToken/logoutAndClear all pass credentials: "include" instead of reading/sending a refresh token from state; refresh no longer needs a body.
  • LoginPage.tsx / AuthCallbackPage.tsx: store only the access token; the OAuth callback URL no longer carries a refresh_token query param (the backend sets the cookie directly on the redirect response, before the browser ever lands on that page).
  • DashboardLayout.tsx: was using a raw fetch() for /api/auth/me instead of apiFetch, bypassing the auto-refresh path entirely and logging the user out on any 401 instead of trying to refresh first. This is issue Fix redundant React effect in the dashboard and standardize auth fetching in the layout to use the shared API client. #6's "direct fetch in layout" item, not a new bug -- I fixed it here because it directly blocked verifying this PR and defeats the whole point of a working silent-refresh flow.

Test plan

  • ruff check ., mypy ., pytest -- all clean (53/53 backend tests)
  • npm run lint, npm run test (28/28), npm run build -- all clean
  • End-to-end with a real browser (Playwright) against a live docker-compose stack:
    • After login, localStorage's persisted auth state contains only {token: ...} -- no refresh token anywhere in it
    • document.cookie is empty (httpOnly working), but the browser's actual cookie jar (which can see httpOnly cookies) shows refresh_token with httpOnly: true, path: /api/auth, sameSite: Lax
    • Corrupted the in-memory access token and reloaded: network trace shows 401 on /api/auth/mePOST /api/auth/refresh (200) → retried /api/auth/me succeeds (200) -- stayed on /dashboard instead of bouncing to /login, and localStorage held a new access token afterward, confirming the silent-refresh flow works entirely without the refresh token ever touching JS
    • Logged out and confirmed the refresh_token cookie is gone from the browser's cookie jar afterward

Fixes #34

Summary by CodeRabbit

  • Security

    • Refresh tokens are now stored securely in HTTP-only cookies and are no longer exposed to the browser or callback URLs.
    • Client-side authentication storage retains only the access token.
  • Authentication

    • Login, OAuth, refresh, and logout flows now work with cookie-based session renewal.
    • Requests automatically include authentication cookies and retry after expired access tokens.
    • Logout clears authentication state even if the server request fails.
  • Bug Fixes

    • Improved handling of missing, invalid, or failed token refreshes by automatically signing users out.

…okie

The refresh token was persisted to localStorage via zustand's persist
middleware alongside the access token, readable by any script running
on the page. Since the refresh token rotates on use rather than
expiring quickly, it effectively grants indefinite re-authentication --
a far more valuable XSS target than the short-lived access token.

Backend (api/app/app/modules/auth/):
- schemas.py: new TokenResponse (access_token only) is what actually
  goes in JSON bodies now. Token (internal, has refresh_token) is used
  only for service-layer return values.
- router.py: login/refresh/OAuth callbacks now set the refresh token
  via Set-Cookie (httpOnly, Secure in production only, SameSite=Lax,
  scoped to /api/auth) instead of returning it in the response body.
  /refresh and /logout read the cookie via request.cookies instead of
  a request body field -- RefreshRequest/LogoutRequest are gone, no
  body needed. A failed refresh clears the cookie.

Frontend:
- authStore.ts: dropped the refreshToken field/setters entirely --
  there's nothing for the client to store; the cookie is invisible to
  JS by design and the browser attaches it automatically.
- lib/api.ts: apiFetch/refreshAccessToken/logoutAndClear all pass
  credentials: "include" instead of reading/sending a refresh token
  from state; refresh no longer needs a body.
- LoginPage.tsx / AuthCallbackPage.tsx: store only the access token;
  the callback URL no longer carries a refresh_token query param (the
  backend sets the cookie directly on the OAuth redirect response,
  before the browser ever lands on that page).
- DashboardLayout.tsx: was using a raw fetch() for /api/auth/me instead
  of apiFetch, bypassing the auto-refresh path entirely and logging the
  user out on any 401 instead of trying to refresh first (this is
  issue #6's "direct fetch in layout" item, not a new bug -- fixing it
  here because it directly blocked verifying this change and undoing
  the whole point of a working refresh flow).

Verified end-to-end with a real browser (Playwright) against a live
docker-compose stack:
- After login, localStorage's persisted auth state contains only
  {token: ...} -- no refresh_token anywhere in it.
- document.cookie is empty (httpOnly working), but the browser's actual
  cookie jar (page.context().cookies(), which sees httpOnly cookies)
  shows refresh_token with httpOnly: true, path: /api/auth,
  sameSite: Lax.
- Corrupted the in-memory access token, reloaded: network trace shows
  401 on /api/auth/me -> POST /api/auth/refresh (200) -> retried
  /api/auth/me succeeds (200) -- the user stays on /dashboard instead
  of being bounced to /login, and localStorage now holds a new access
  token, confirming the whole silent-refresh flow works without the
  refresh token ever touching JS.
- Logged out and confirmed the refresh_token cookie is gone from the
  browser's cookie jar afterward.

Backend: ruff/mypy/pytest clean (53/53). Frontend: lint/test (28/28)/
build clean.

Fixes #34
@nazarli-shabnam nazarli-shabnam self-assigned this Jul 10, 2026
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Authentication now stores refresh tokens in HTTP-only /api/auth cookies. Backend refresh and logout endpoints read cookies, while frontend state persists only access tokens and includes credentials on authentication requests.

Changes

Authentication cookie flow

Layer / File(s) Summary
Backend cookie contract
api/app/app/modules/auth/router.py, api/app/app/modules/auth/schemas.py
Auth responses expose only access-token fields; login, refresh, logout, and OAuth callbacks set, rotate, read, or clear the HTTP-only refresh cookie.
Frontend auth state and requests
apps/frontend/src/stores/authStore.ts, apps/frontend/src/lib/api.ts, apps/frontend/src/components/layout/DashboardLayout.tsx
The store removes refresh-token state and persistence, while API requests use cookie credentials for refresh, logout, retries, and user loading.
Login, OAuth, and request validation
apps/frontend/src/pages/auth/LoginPage.tsx, apps/frontend/src/pages/auth/AuthCallbackPage.tsx, apps/frontend/src/lib/api.test.ts
Login and OAuth callbacks store only access tokens, and API tests cover credential inclusion and access-token-only refresh responses.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant LoginPage
  participant AuthAPI
  participant AuthStore
  Browser->>LoginPage: submit credentials
  LoginPage->>AuthAPI: POST login with credentials included
  AuthAPI-->>Browser: access token and HTTP-only refresh cookie
  LoginPage->>AuthStore: setToken(access token)
  Browser->>AuthAPI: request with access token and cookie
  AuthAPI-->>Browser: 401 response
  Browser->>AuthAPI: POST refresh with cookie credentials
  AuthAPI-->>Browser: new access token and rotated cookie
  Browser->>AuthStore: setToken(new access token)
  Browser->>AuthAPI: retry original request
Loading

Poem

I’m a rabbit with a cookie tucked away,
No refresh string for scripts to display.
Access tokens hop through the store,
Rotating cookies guard the door.
“Credentials included!” I cheer,
Safer auth is blooming here.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: moving refresh tokens from localStorage to an httpOnly cookie.
Linked Issues check ✅ Passed The PR satisfies #34 by removing client-side refresh-token storage, using an httpOnly cookie, and updating refresh/logout flows and frontend requests.
Out of Scope Changes check ✅ Passed The changed frontend, backend, and tests all support the cookie-based auth flow and do not introduce obvious unrelated scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/httponly-refresh-token-cookie

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@nazarli-shabnam nazarli-shabnam added bug Something isn't working UI UX API labels Jul 10, 2026
@nazarli-shabnam nazarli-shabnam added this to the Enhancement Deadline milestone Jul 10, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/frontend/src/stores/authStore.ts (1)

35-37: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Existing sessions keep the old refreshToken in localStorage.

partialize only affects future writes. On upgrade, a returning user's persisted loomy-auth blob still contains the previously stored refreshToken, and zustand persist does not re-serialize on rehydrate — the stale token remains readable to JS until the next state change overwrites it. This leaves a window that undercuts the issue's goal of removing the refresh token from client-readable storage.

Add a version bump + migrate to strip it on load:

🛡️ Force cleanup of the legacy field
     {
       name: "loomy-auth",
+      version: 1,
+      migrate: (persisted) => {
+        if (persisted && typeof persisted === "object") {
+          delete (persisted as Record<string, unknown>).refreshToken;
+        }
+        return persisted as AuthState;
+      },
       partialize: (s) => ({ token: s.token }),
     },

Please confirm zustand persist writes back the migrated state on rehydrate for the installed 5.0.11.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/frontend/src/stores/authStore.ts` around lines 35 - 37, Update the
zustand persist configuration in authStore’s “loomy-auth” storage to bump the
persistence version and add a migrate function that removes the legacy
refreshToken while preserving the supported token state. Confirm against the
installed zustand 5.0.11 behavior that migrated state is written back during
rehydration, and ensure future persistence remains limited to token via
partialize.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@api/app/app/modules/auth/router.py`:
- Around line 57-66: Update _set_refresh_cookie to use SameSite=None with
secure=True when the frontend and API are configured on different registrable
domains, while retaining SameSite=Lax for same-site deployments; ensure the
cross-site configuration always enables Secure so refresh_token is sent to the
refresh and logout endpoints.

---

Nitpick comments:
In `@apps/frontend/src/stores/authStore.ts`:
- Around line 35-37: Update the zustand persist configuration in authStore’s
“loomy-auth” storage to bump the persistence version and add a migrate function
that removes the legacy refreshToken while preserving the supported token state.
Confirm against the installed zustand 5.0.11 behavior that migrated state is
written back during rehydration, and ensure future persistence remains limited
to token via partialize.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: aacd894d-44f0-449f-b547-d396066cbf09

📥 Commits

Reviewing files that changed from the base of the PR and between d744c4a and d7a036b.

📒 Files selected for processing (8)
  • api/app/app/modules/auth/router.py
  • api/app/app/modules/auth/schemas.py
  • apps/frontend/src/components/layout/DashboardLayout.tsx
  • apps/frontend/src/lib/api.test.ts
  • apps/frontend/src/lib/api.ts
  • apps/frontend/src/pages/auth/AuthCallbackPage.tsx
  • apps/frontend/src/pages/auth/LoginPage.tsx
  • apps/frontend/src/stores/authStore.ts

Comment thread api/app/app/modules/auth/router.py
@nazarli-shabnam
nazarli-shabnam merged commit 3be7d32 into main Jul 10, 2026
7 checks passed
@devlaner-coolify-app

Copy link
Copy Markdown

The preview deployment for Loomy UI failed. 🔴

Open Build Logs | Open Application Logs

Last updated at: 2026-07-10 10:02:05 CET

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

API bug Something isn't working UI UX

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Access + refresh tokens persisted to localStorage — XSS-readable, long-lived refresh token is a high-value target

1 participant