Skip to content

Tech Story: Switch auth tokens to httpOnly cookies #93

@GitAddRemote

Description

@GitAddRemote

Tech Story

As a platform engineer, I want access and refresh tokens stored in httpOnly cookies so that they cannot be read or exfiltrated by JavaScript, eliminating the XSS attack surface introduced by localStorage.

Context

Currently both tokens are stored in localStorage and injected manually into Authorization: Bearer headers. Any XSS vector (compromised dependency, injected script) can trivially steal both tokens. Storing tokens in httpOnly; Secure; SameSite=Strict cookies removes this surface entirely — the browser sends them automatically and JS cannot read them.

Acceptance Criteria

  • POST /auth/login sets access_token and refresh_token as httpOnly; Secure; SameSite=Strict cookies; response body returns user info only (no tokens)
  • POST /auth/refresh reads the refresh token from its cookie, issues new token pair as cookies
  • POST /auth/logout reads the refresh token from its cookie, revokes it, clears both cookies
  • JwtStrategy extracts the access token from the access_token cookie (not Authorization header)
  • RefreshTokenStrategy and RefreshTokenAuthGuard removed — refresh/logout read cookies directly
  • Frontend apiClient sends withCredentials: true; all manual token reads/writes from localStorage removed
  • ProtectedRoute uses AuthContext (backed by GET /auth/me) instead of localStorage check
  • GET /auth/me endpoint added — returns { id, username } for authenticated users, 401 otherwise
  • Login and Register pages no longer touch localStorage
  • Dead api.service.ts (hardcoded wrong port, unused) deleted
  • Existing sessions invalidated (acceptable — users must re-login after deploy)

Technical Elaboration

  • Use cookie-parser middleware in main.ts; install cookie-parser and @types/cookie-parser
  • Cookie config: httpOnly: true, secure: true (env-gated for local dev), sameSite: 'strict'
  • Access token cookie expiry matches JWT expiry (15m); refresh token cookie expiry = 7 days
  • JwtStrategy: change jwtFromRequest to ExtractJwt.fromExtractors([(req) => req?.cookies?.access_token])
  • AuthContext (React): calls GET /auth/me on mount; exposes { user, loading, logout }; ProtectedRoute reads from context
  • apiClient 401 interceptor: call POST /auth/refresh with withCredentials: true; on success retry original request; on failure redirect to /login
  • CORS must have credentials: true and explicit origin (wildcard * is incompatible with credentialed cookies)

Notes

  • Cookie secure flag should be NODE_ENV === 'production' so local dev over HTTP still works
  • This issue is a prerequisite for or should be done in conjunction with the CORS/Helmet hardening issue

Metadata

Metadata

Assignees

No one assigned

    Labels

    apiPublic/internal API endpointsbackendBackend services and logicfrontendFrontend app and dashboardsecuritySecurity, auth, and permissionstech-storyTechnical implementation story

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions