Findings (root cause)
- Supabase invite emails default to ConfirmationURL (…/auth/v1/verify?token=…&type=invite&redirect_to=…). After verification, Supabase redirects to your site with tokens in the URL hash (#access_token=…). That requires client-side handling; otherwise no session/cookie is set and users land unauthenticated on your homepage.
- Your app has a server route at app/auth/confirm/route.ts that expects token_hash and type query params and calls supabase.auth.verifyOtp({ type, token_hash }) to set cookies server-side—but the current invite link doesn’t hit that route and uses token (not token_hash).
- Net: there’s a mismatch between the link Supabase sends and how your app expects to finalize auth; you’re not processing the hash fragment, so the invite appears to “do nothing”.
What “should” happen
- Either: handle the hash fragment client-side (getSessionFromUrl) and then sync to server cookies; or: bypass the hash entirely by customizing the email invite link to point directly at your server route with token_hash so verifyOtp can run server-side and set cookies. Supabase docs show using TokenHash in custom email links for this purpose. References:
- Email templates and TokenHash: https://supabase.com/docs/guides/auth/auth-email-templates
- Admin invite API: https://supabase.com/docs/reference/javascript/auth-admin-inviteuserbyemail
- PKCE/implicit flows context: https://supabase.com/docs/guides/auth/sessions/pkce-flow
Proposal (recommended, minimal change)
- Update the “Invitation” email template in Supabase (Auth → Email Templates) to link directly to your server confirm route using TokenHash:
{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=invite&next=/auth/update-password- Ensure https://resumeforge-alpha.vercel.app/auth/confirm and /auth/update-password are in your Redirect URLs allow-list.
- Your existing app/auth/confirm/route.ts will run verifyOtp, set Supabase cookies server-side, and redirect to /auth/update-password where the user can set their password (components/auth/update-password-form.tsx uses supabase.auth.updateUser({ password })).
- Also update the “Recovery” (and optionally “Signup”) templates similarly:
{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery&next=/auth/update-passwordAlternative (if you prefer to keep default ConfirmationURL)
- Add a client page (e.g., /auth/callback) that runs on mount:
// Client component
const supabase = createClient();
await supabase.auth.getSessionFromUrl({ storeSession: true });
// then navigate to onboarding/update-password, and optionally hit an API route to sync cookies if needed- Change redirect_to in the invite to /auth/callback. This works, but you must ensure cookie sync for SSR (e.g., call a tiny route that reads Authorization bearer and sets Supabase cookies), whereas your current verifyOtp server route already handles cookies cleanly.
Checks
- Auth settings: keep Email sign-ups disabled; verify Redirect URLs allow-list includes the routes above.
- Test: send an invite → click email → ensure app/auth/confirm/route.ts is hit (no hash in URL), cookies set, and you land on /auth/update-password to complete onboarding.
Why this fixes it
- You align the email link to your server-side confirm flow (token_hash + type), avoiding the hash fragment and ensuring the session is created and cookies are set in one step. This matches Supabase’s documented pattern for custom email links with verifyOtp.