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-out → 403
- 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
- Sign in to the app
- Navigate to /profile
- Click sign out
- Observe "Could not sign out. Please try again." toast and 403 in server logs
Investigation Checklist
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
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
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.
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-out→ 403/api/auth/[...path]function invocationnxtPpath=sign-outappears as a query param, indicating Next.js is routing through the catch-all correctlyServer Log (redacted)
Likely Cause
The auth handler is issuing a 403 before proxying to Neon — most likely a CSRF token validation failure. The static
/api/auth/webhooksegment 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
Investigation Checklist
[...path]handler requires for sign-out (CSRF token, session cookie, origin header)devor only on branches with the webhook routeImplementation Plan
Overview
Real-auth sign-out fails because
components/layouts/user-menu.tsxcalls the browser-sideauthClient.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#256branch; 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 vianext/headers, clears it from the upstreamset-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: callsauthServer.signOut(), returns{ error }on upstream failure, redirects to/loginon success.components/layouts/user-menu.tsx— replace the real-auth branch'sauthClient.signOut()call with the newsignOutUser()action; drop the now-unusedauthClientimport; keep the bypass branch (logoutBypassUser()) unchanged.lib/auth/client.ts— leave as-is (still used byNeonAuthUIProvider); only theUserMenuusage moves server-side.Implementation
prisma/actions/auth.tswith'use server'exportingsignOutUser(): Promise<ErrorType | void>— callgetCurrentUser()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 vianext/headers, thenrevalidatePath('/', 'layout')andredirect('/login'). No input → no zod schema needed.user-menu.tsxhandleLogout, replace the real-auth try/catch aroundauthClient.signOut()withconst result = await signOutUser();— on a returned error showtoast.error(result.error); on success the action redirects (no client navigation needed). Remove theauthClientimport and the manualrouter.push('/login')/router.refresh()in the real-auth branch (redirect + layout revalidate replace them).if (isBypass) { await logoutBypassUser(); return; }unchanged.tsc/eslint/prettier; confirm no remainingauthClient.signOutreferences.Data & contracts
No Prisma schema changes. New server action
signOutUser()inprisma/actions/auth.ts: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.messagecarry the upstream failure). Onerror, 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).set-cookie(cleared session) back throughnext/headers, so the session cookie is removed server-side; thenredirect('/login'). Use theResponseType/ErrorTypehelpers fromlib/utilsfor the return type, matching existing actions.UX states
Log outmenu item staysdisabled={pending}during theuseTransition(unchanged)./login; no client toast needed on success (page navigates away). Bypass branch keeps its existing redirect to/login/bypass.{ error }→toast.error('Could not sign out. Please try again.'); the menu re-enables so the user can retry.Testing
/login, session cleared (revisiting a gated route redirects to login).NEON_AUTH_BASE_URL) and confirm the action returns the error toast and the menu re-enables (no crash, no redirect)./login/bypass(unchanged path).Risks / notes