Ship/Now

What is wired · 3 min

Authentication

Magic links and OAuth on Supabase Auth, and the two failures that cost a day each if you meet them fresh.

Sessions live in cookies that the server can read, which is what makes requireUser() work in a Server Component without a loading state.

import { requireUser, currentUser } from "@/lib/auth";

// In a page that makes no sense signed out. Redirects if there is no session.
const user = await requireUser();

// Where signed out is a normal state, the nav for example.
const maybe = await currentUser();

currentUser is wrapped in React's cache, so a layout and the page inside it share one call to the auth server rather than making two.

Supabase's default email templates use {{ .ConfirmationURL }}, which returns the session in a hash fragment. A fragment is never sent to the server. Your callback route sees an empty query string, creates no session, and bounces the user back to sign-in with no error to show them. It looks like the link is broken. It is not: the session went to the browser and nothing read it.

Rewrite all three templates, confirm signup, magic link and email change, to send a token hash to your own callback:

{{ .SiteURL }}/auth/callback?token_hash={{ .TokenHash }}&type=magiclink

In a route handler, mutating the cookie store from next/headers does not carry onto a NextResponse.redirect. You verify the token, you set the session, you redirect, and the session is gone.

Bind the Supabase client to the response object instead:

let response = NextResponse.redirect(`${origin}${target}`);

const supabase = createServerClient(url, key, {
  cookies: {
    getAll: () => request.cookies.getAll(),
    setAll: (list) => {
      list.forEach(({ name, value, options }) =>
        response.cookies.set(name, value, options));
    },
  },
});

await supabase.auth.verifyOtp({ type, token_hash: tokenHash });
return response;

That is what src/app/auth/callback/route.ts does, and the comment above it says so, because this is exactly the code somebody simplifies six months later.

Profiles

A user row in auth.users is not yours to extend. The schema creates a matching public.profiles row from a trigger:

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute function public.handle_new_user();

A trigger, not an insert in the sign-up handler, because a user can arrive through OAuth, through an invite, or through the dashboard, and only the database sees all three. Do it in application code and you will eventually have a user with no profile and a page that crashes on profile.full_name.

Adding a provider

Enable it in the Supabase dashboard, add the redirect URL, and add a button. The client call is one line:

await supabaseBrowser().auth.signInWithOAuth({
  provider: "github",
  options: { redirectTo: `${location.origin}/auth/callback?next=/app` },
});

No new route. The same callback handles it, since it already reads ?code= as well as ?token_hash=.

Something wrong or missing on this page? Tell us.