Start here

Arrows to move, enter to open, escape to close.

Ship/Now

What is wired · 4 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.

The one-click providers

The email link always works and needs nothing configured. GitHub and Google are optional, and off by default:

auth: {
  providers: [] as readonly ("github" | "google")[],
},

npm run setup fills this in for you. It asks Supabase which providers are actually switched on in your project and only offers you those, so the array cannot end up naming one that does not work.

To add one later: enable it in Authentication → Providers in the Supabase dashboard, paste the client id and secret from GitHub or Google, add https://<project>.supabase.co/auth/v1/callback as the redirect URL on their side, then add the name to the array. Re-running npm run setup does the last step for you.

Adding a third provider is a line in the config, a label and a mark in OAuthButtons.tsx, and the same dashboard step. No new route: the existing /auth/callback already reads ?code= as well as ?token_hash=.

Something wrong or missing on this page? Tell us.