Skip to content

fix(invite): switch to verifyOtp + /auth/confirm — invite mails use hash-flow, not PKCE#129

Merged
gkhngyk merged 2 commits into
mainfrom
fix/invite-verify-otp
May 30, 2026
Merged

fix(invite): switch to verifyOtp + /auth/confirm — invite mails use hash-flow, not PKCE#129
gkhngyk merged 2 commits into
mainfrom
fix/invite-verify-otp

Conversation

@gkhngyk

@gkhngyk gkhngyk commented May 30, 2026

Copy link
Copy Markdown
Contributor

What

#127 routed invite-mail clicks through /auth/callback on the assumption that Supabase appends a PKCE ?code= to the redirect URL. It doesn't — for admin-initiated invites, the recipient's browser never held a PKCE verifier, so Supabase's verify endpoint returns the session as URL hash params (#access_token=...&refresh_token=...). Server components can't see the hash, /auth/callback saw no ?code=, redirected to /sign-in?error=auth_callback_failed, and the user fell into the same old broken /sign-up loop.

Why

This is the canonical "server-side auth with Supabase + Next.js" gotcha. Anthropic / Vercel / Supabase docs all converge on the same answer: don't use {{ .ConfirmationURL }}, use {{ .TokenHash }} + a custom /auth/confirm route that calls verifyOtp server-side.

Scope

  • New /auth/confirm route handler. Reads ?token_hash, ?type, ?redirect_to (or ?next), calls supabase.auth.verifyOtp({ type, token_hash }), and 302s to the destination once the cookie session is set server-side. Handles every Supabase email type — invite, signup, recovery, magiclink, email_change.
  • team.ts reverts the /auth/callback?next=... hop. inviteLink is now plain ${appUrl}/invite/<token>. The auth verification happens inside the email-template URL, not as a separate intermediate redirect.

Required ops step (NOT in code)

The Supabase project's email templates need updating in Auth → Email Templates. Change the link href in the Invite user template from {{ .ConfirmationURL }} to:

{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=invite&redirect_to={{ .RedirectTo }}

Recommended to do the same on the Confirm signup, Reset password, and Magic link templates (type=signup / recovery / magiclink) so every auth email goes through the same SSR-friendly path.

Until the template is updated, the invite link in the email still goes to the old Supabase verify endpoint and this fix has no effect.

Test plan

  1. Update the Invite user template in Supabase Auth → Email Templates per the snippet above
  2. Delete prior auth.users / invitations for the test email
  3. From the team page, invite the test email
  4. Open the mail (delivered via Resend), click 'Accept the invite'
  5. Land on /invite/<token> directly, signed in, with the AcceptInvitationCard ready
  6. Set full name + password, click 'Accept and join'
  7. Land in /dashboard
  8. Sign out → sign back in with the password just set → succeeds

Out of scope

  • /auth/callback stays as-is — it's still the correct route for any future PKCE OAuth flows (Google / GitHub sign-in if added).
  • 'Delete user from team' auth.users cleanup gap — separate fix.

…-flow tokens, not PKCE codes

The /auth/callback hop added in #127 only works for PKCE OAuth flows
where the browser holds the code verifier. Admin-initiated invite
emails (supabase.auth.admin.inviteUserByEmail) have no PKCE verifier
on the recipient's browser — Supabase's verify endpoint returns the
session as URL hash params (#access_token=...&refresh_token=...) which
server components can't read. /auth/callback sees no ?code=, redirects
to /sign-in?error=auth_callback_failed, and the user falls into the
old broken /sign-up dance again.

Canonical fix is the OTP-token-hash flow Supabase documents for SSR:

- New /auth/confirm route handler reads ?token_hash + ?type + ?redirect_to,
  calls supabase.auth.verifyOtp({ type, token_hash }), and 302s to the
  destination once the session cookie is set server-side. Works for
  every Supabase email type (invite, signup, recovery, magiclink,
  email_change) — the type comes from the template, not from us.
- team.ts inviteLink is now plain ${appUrl}/invite/<token> again. The
  auth hop is no longer in the redirect; it's in the email template's
  link itself.

Required ops step (manual, not in code): the Supabase project's Auth →
Email Templates → 'Invite user' template needs the link changed from
{{ .ConfirmationURL }} to:

  {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=invite&redirect_to={{ .RedirectTo }}

Same swap recommended for signup / recovery / magiclink templates so
every email auth flow goes through the same SSR-friendly path. Until
that's done the invite link still hits the old default URL and the
fix doesn't take effect.
…template covers dev + prod

Previous attempt put /auth/confirm under {{ .SiteURL }} in the email
template. Supabase's Site URL is one project-wide value (production
https://app.ansvisor.com here), so the same template can't drive
both prod testing and a localhost dev loop — the link always points
at prod regardless of NEXT_PUBLIC_APP_URL.

Switch the redirectTo we pass to inviteUserByEmail to be the full
/auth/confirm URL with our next path baked in:

  ${appUrl}/auth/confirm?next=/invite/<token>

Template now uses {{ .RedirectTo }} as the prefix and only appends
the OTP params:

  {{ .RedirectTo }}&token_hash={{ .TokenHash }}&type=invite

Because appUrl resolves to whichever NEXT_PUBLIC_APP_URL is in the
current environment, the link goes to localhost in dev and prod in
prod — without changing Supabase project settings between the two.
@gkhngyk gkhngyk merged commit bc81dd5 into main May 30, 2026
3 checks passed
gkhngyk added a commit that referenced this pull request May 30, 2026
…ntl rewrite was 404ing (#130)

* fix(invite): use verifyOtp via /auth/confirm — invite mails ship hash-flow tokens, not PKCE codes

The /auth/callback hop added in #127 only works for PKCE OAuth flows
where the browser holds the code verifier. Admin-initiated invite
emails (supabase.auth.admin.inviteUserByEmail) have no PKCE verifier
on the recipient's browser — Supabase's verify endpoint returns the
session as URL hash params (#access_token=...&refresh_token=...) which
server components can't read. /auth/callback sees no ?code=, redirects
to /sign-in?error=auth_callback_failed, and the user falls into the
old broken /sign-up dance again.

Canonical fix is the OTP-token-hash flow Supabase documents for SSR:

- New /auth/confirm route handler reads ?token_hash + ?type + ?redirect_to,
  calls supabase.auth.verifyOtp({ type, token_hash }), and 302s to the
  destination once the session cookie is set server-side. Works for
  every Supabase email type (invite, signup, recovery, magiclink,
  email_change) — the type comes from the template, not from us.
- team.ts inviteLink is now plain ${appUrl}/invite/<token> again. The
  auth hop is no longer in the redirect; it's in the email template's
  link itself.

Required ops step (manual, not in code): the Supabase project's Auth →
Email Templates → 'Invite user' template needs the link changed from
{{ .ConfirmationURL }} to:

  {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=invite&redirect_to={{ .RedirectTo }}

Same swap recommended for signup / recovery / magiclink templates so
every email auth flow goes through the same SSR-friendly path. Until
that's done the invite link still hits the old default URL and the
fix doesn't take effect.

* fix(invite): use {{ .RedirectTo }}-based template prefix so a single template covers dev + prod

Previous attempt put /auth/confirm under {{ .SiteURL }} in the email
template. Supabase's Site URL is one project-wide value (production
https://app.ansvisor.com here), so the same template can't drive
both prod testing and a localhost dev loop — the link always points
at prod regardless of NEXT_PUBLIC_APP_URL.

Switch the redirectTo we pass to inviteUserByEmail to be the full
/auth/confirm URL with our next path baked in:

  ${appUrl}/auth/confirm?next=/invite/<token>

Template now uses {{ .RedirectTo }} as the prefix and only appends
the OTP params:

  {{ .RedirectTo }}&token_hash={{ .TokenHash }}&type=invite

Because appUrl resolves to whichever NEXT_PUBLIC_APP_URL is in the
current environment, the link goes to localhost in dev and prod in
prod — without changing Supabase project settings between the two.

* fix(invite): mirror /auth/confirm under [locale]/(marketing) so next-intl can resolve it

PR #129 added /auth/confirm at the root app directory only. next-intl
middleware rewrites every /auth/* request to /[locale]/auth/*, and the
deployed bundle had no matching file under [locale]/auth/confirm — so
the request landed on the framework 404 page.

The existing /auth/callback works because there's already a
[locale]/(marketing)/auth/callback/route.ts that the rewrite resolves
to. Add the equivalent mirror for confirm so the same routing rules
have a destination.

Verified via response headers — /auth/callback shows
`x-matched-path: /[locale]/auth/callback`, /auth/confirm before this
change matched /404 with x-vercel-cache HIT. Keeping the root
/auth/confirm/route.ts around to stay consistent with the callback
sibling pattern; it's never hit but matches the existing convention.
gkhngyk added a commit that referenced this pull request May 31, 2026
Bundles the in-product Agent epic (#120, #121), the BYOK rollout on
cloud, the MCP toolkit expansion (#109, #110, #116, #117, #118), the
insights performance fix (#114), and the invite-accept flow rebuild
(#127, #129, #130) into a tagged release.

See CHANGELOG.md for the full notes.
gkhngyk added a commit that referenced this pull request May 31, 2026
Bundles the in-product Agent epic (#120, #121), the BYOK rollout on
cloud, the MCP toolkit expansion (#109, #110, #116, #117, #118), the
insights performance fix (#114), and the invite-accept flow rebuild
(#127, #129, #130) into a tagged release.

See CHANGELOG.md for the full notes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant