Skip to content

Sign-out returns 403 — Could not sign out. Please try again. #285

Description

@b-at-neu

Bug Report

Users hitting "Could not sign out. Please try again." on the profile page. The sign-out POST returns 403 from the Neon Auth catch-all handler with no outgoing requests made to Neon — meaning the auth handler is rejecting the request before forwarding it.

Observed Behavior

  • POST /api/auth/sign-out403
  • UI shows: "Could not sign out. Please try again."
  • Middleware passes (200); the 403 originates inside the /api/auth/[...path] function invocation
  • No outgoing external API requests were made by the function — Neon's sign-out endpoint was never called
  • nxtPpath=sign-out appears as a query param, indicating Next.js is routing through the catch-all correctly

Server Log (redacted)

POST /api/auth/sign-out → 403
Route: /api/auth/[...path]
Execution: 149ms
External APIs: none
Branch: 256-set-up-neon-auth-email-webhooks-with-resend

Likely Cause

The auth handler is issuing a 403 before proxying to Neon — most likely a CSRF token validation failure. The static /api/auth/webhook segment added in #261 sits alongside [...path]; investigate whether its presence affects how Neon Auth initialises the handler or whether the sign-out request is missing a required CSRF header/cookie that the client isn't sending.

Steps to Reproduce

  1. Sign in to the app
  2. Navigate to /profile
  3. Click sign out
  4. Observe "Could not sign out. Please try again." toast and 403 in server logs

Investigation Checklist

  • Check what Neon Auth's [...path] handler requires for sign-out (CSRF token, session cookie, origin header)
  • Confirm whether the issue reproduces on dev or only on branches with the webhook route
  • Check the Neon Auth SDK version and its sign-out CSRF requirements
  • Verify the sign-out client call passes all required headers

Implementation Plan

Overview

Real-auth sign-out fails because components/layouts/user-menu.tsx calls the browser-side authClient.signOut() (better-auth/react), which POSTs /api/auth/sign-out; the SDK proxy forwards the browser request to Neon's upstream better-auth, which rejects the CSRF-protected POST with 403. The webhook route is a red herring — it's POST-only, self-contained, and lives only on the #256 branch; the real bug is the client-driven sign-out path. Fix: move sign-out to a server action that calls the SDK's server client (authServer.signOut()), which forwards the session cookie via next/headers, clears it from the upstream set-cookie, and returns a structured error instead of throwing in the browser. This also aligns sign-out with the repo's "mutations are server actions, no client fetching" rule.

Changes

  • prisma/actions/auth.ts (new) — signOutUser() server action: calls authServer.signOut(), returns { error } on upstream failure, redirects to /login on success.
  • components/layouts/user-menu.tsx — replace the real-auth branch's authClient.signOut() call with the new signOutUser() action; drop the now-unused authClient import; keep the bypass branch (logoutBypassUser()) unchanged.
  • lib/auth/client.ts — leave as-is (still used by NeonAuthUIProvider); only the UserMenu usage moves server-side.

Implementation

  • Add prisma/actions/auth.ts with 'use server' exporting signOutUser(): Promise<ErrorType | void> — call getCurrentUser() to confirm a real session (bypass never reaches this), await authServer.signOut(), and on { error } return { error: 'Could not sign out. Please try again.' }; on success the SDK clears the Neon cookies via next/headers, then revalidatePath('/', 'layout') and redirect('/login'). No input → no zod schema needed.
  • In user-menu.tsx handleLogout, replace the real-auth try/catch around authClient.signOut() with const result = await signOutUser(); — on a returned error show toast.error(result.error); on success the action redirects (no client navigation needed). Remove the authClient import and the manual router.push('/login')/router.refresh() in the real-auth branch (redirect + layout revalidate replace them).
  • Keep the bypass branch: if (isBypass) { await logoutBypassUser(); return; } unchanged.
  • Verify tsc/eslint/prettier; confirm no remaining authClient.signOut references.

Data & contracts

No Prisma schema changes. New server action signOutUser() in prisma/actions/auth.ts:

  • Auth scoping: getCurrentUser() first; redirects to login if no session (defense-in-depth — the control only renders for authenticated users).
  • authServer.signOut() returns { data, error } (it does not throw on a 403; error.status/error.message carry the upstream failure). On error, return { error: 'Could not sign out. Please try again.' } — user-facing and actionable (retry), per §4 decision test. The raw upstream message is not surfaced (avoid leaking internals).
  • Success: the SDK writes the upstream set-cookie (cleared session) back through next/headers, so the session cookie is removed server-side; then redirect('/login'). Use the ResponseType/ErrorType helpers from lib/utils for the return type, matching existing actions.

UX states

  • Pending: Log out menu item stays disabled={pending} during the useTransition (unchanged).
  • Success: server redirect to /login; no client toast needed on success (page navigates away). Bypass branch keeps its existing redirect to /login/bypass.
  • Error: returned { error }toast.error('Could not sign out. Please try again.'); the menu re-enables so the user can retry.

Testing

  • Sign in via real OTP auth in production-style env; open the user menu → Log out → lands on /login, session cleared (revisiting a gated route redirects to login).
  • Confirm the 403 toast no longer appears for a normal real-auth sign-out.
  • Force an upstream failure (e.g. temporarily invalid NEON_AUTH_BASE_URL) and confirm the action returns the error toast and the menu re-enables (no crash, no redirect).
  • In dev with a bypass session: Log out still clears the bypass cookie and returns to /login/bypass (unchanged path).
  • Keyboard: open menu, tab to Log out, Enter triggers sign-out; focus handled by the dropdown primitive.

Risks / notes

  • If the production 403 is ultimately a Neon trusted-origins misconfiguration (the deployment origin not registered in the Neon Auth project), the server action surfaces it as the same user-facing error rather than masking it — the server-to-server call still forwards the app origin. The server-side path removes the browser SameSite/origin fragility that is the common cause of this exact symptom, but if the toast persists in production after this change, the next step is verifying the production origin is listed as a trusted origin in the Neon Auth dashboard (a config step outside this repo). Flagged so the human can check it if the code fix alone doesn't fully resolve.

Metadata

Metadata

Assignees

Labels

claudeWill be worked on by Claudepr openedPull request has been opened

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions