AUTHTEMPLATE

Supabase Email Auth

Do not build your own auth. Not because you couldn't: because the failure modes are invisible until they're catastrophic, and password storage is a problem the industry already solved and keeps re-solving every time someone's homemade version leaks. Supabase gives you signup, login, email confirmation, and session management on top of Postgres, and your job shrinks to wiring it in without undoing its guarantees.

That wiring is where beginners get bitten, in two specific places. First, the confirmation flow: a user signs up, Supabase sends a confirmation email, and the link in it has to land somewhere in your app that completes the handshake. Skip that route and every new user hits a dead end with a valid account they can't use. Second, where you check the session: a login check that lives only in client-side JavaScript is a curtain, not a lock. Anyone can call your API directly, no browser required. The session check has to happen on the server, on every protected route, every time.

This spec hands your agent the full circuit: signup, confirm, login, logout, protected routes checked server-side, and a profiles table with row-level security so the database enforces who sees what even if a bug slips through the app layer. Defense at the data layer is not paranoia. It's the layer that holds when the other one doesn't.

Prerequisites

  • A Supabase project (free tier is fine): grab the project URL and anon key from Settings, API.
  • Your framework's Supabase client library installed (e.g., `@supabase/ssr` for Next.js).
markdown
# Spec: email/password auth with Supabase

Build signup, email confirmation, login, logout, and protected routes for
[app name] using Supabase Auth. Sessions are checked server-side; the
database enforces access with row-level security.

## Stack

- Framework: [e.g., Next.js 14 App Router with @supabase/ssr]
- Supabase project already exists; URL and keys below.

## Environment variables

```bash
NEXT_PUBLIC_SUPABASE_URL=https://[project].supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...   # safe for the client; RLS is the guard
SUPABASE_SERVICE_ROLE_KEY=eyJ...       # server ONLY, bypasses RLS; never ships
                                       # to the browser, never NEXT_PUBLIC_
```

## Supabase dashboard settings (do these first, in Authentication)

- Email confirmation: ON. Unconfirmed users cannot sign in.
- Site URL: [http://localhost:3000] for dev; the production URL at deploy.
- Redirect URLs: add `[APP_URL]/auth/callback`.

## Pages and routes

- `/signup`: email + password form. On submit call `signUp` with
  `emailRedirectTo: [APP_URL]/auth/callback`. On success show "check your
  email", do NOT log the user in or redirect to the app.
- `/auth/callback`: route handler that exchanges the code in the
  confirmation link for a session, then redirects to `/[home or dashboard]`.
  Without this route, confirmation links dead-end.
- `/login`: email + password, `signInWithPassword`. On the "email not
  confirmed" error, show "confirm your email first" with a resend button.
- Logout: a POST action calling `signOut`, then redirect to `/login`.
  Logout via GET gets triggered by prefetchers; use POST.
- `/[dashboard]` and everything under it: protected (below).

## Protecting routes (server-side, non-negotiable)

- Middleware refreshes the session on every request (per @supabase/ssr docs).
- Every protected page and API route calls `supabase.auth.getUser()`
  server-side and redirects to `/login` (or returns 401) when null.
- `getUser()` validates against the Auth server. Do not substitute
  `getSession()` for authorization decisions; it reads the cookie without
  verifying it.
- Client-side redirects are UX polish on top of the server check, never a
  replacement for it.

## Profiles table with RLS

`auth.users` belongs to Supabase; app data lives in a `profiles` row created
automatically for each new user:

```sql
create table profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  email text not null,
  display_name text,
  created_at timestamptz not null default now()
);

alter table profiles enable row level security;

create policy "read own profile" on profiles
  for select using (auth.uid() = id);
create policy "update own profile" on profiles
  for update using (auth.uid() = id);
-- No insert policy: the trigger below creates rows, users never insert directly.

create function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = '' as $$
begin
  insert into public.profiles (id, email) values (new.id, new.email);
  return new;
end $$;

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

## Verification (before calling this done)

1. Sign up with a real inbox. Confirm you CANNOT log in before clicking the
   email link, and CAN after.
2. Confirm the `profiles` row exists after signup, created by the trigger.
3. While logged out, hit a protected page by URL and a protected API route
   with curl: redirect and 401 respectively.
4. In the SQL editor, as user A, select from profiles: exactly one row, yours.

Adaptation notes:

  • Password rules, resend cooldowns, and email templates all live in the Supabase dashboard. Change them there, not in app code, or dashboard and app will disagree about what a valid password is.
  • Adding OAuth later (Google, GitHub) reuses this exact skeleton: signInWithOAuth lands on the same /auth/callback route, and the trigger creates the profile row the same way.
  • In local dev, confirmation emails land in Supabase's built-in Inbucket (or your project's email logs), not a real inbox. Look there before concluding email is broken.
  • For production email deliverability, wire a custom SMTP provider in the dashboard; the default sender is fine for testing and gets rate-limited fast beyond it.
  • The mistake: checking auth in a client component and calling it protected. Open the network tab, copy an API call, replay it in curl with no cookies. If it answers, your protection is decorative. That test takes thirty seconds; run it.