Build This Now
Build This Now
Qu'est-ce que le code Claude ?Installer Claude CodeL'installateur natif de Claude CodeTon premier projet Claude Code
Claude Code v2.1.122 Release NotesClaude Code Dynamic Workflows : comment orchestrer 1 000 sous-agents sur une vraie codebaseBonnes pratiques Claude CodeMeilleures pratiques pour Claude Opus 4.7Claude Code sur un VPSIntégration GitRevue de code avec Claude CodeLes Worktrees avec Claude CodeClaude Code à distanceClaude Code ChannelsChannels, Routines, Teleport, DispatchTâches planifiées avec Claude CodePermissions Claude CodeLe mode auto de Claude CodeAjouter les paiements Stripe avec Claude CodeFeedback LoopsWorkflows TodoGestion des tâches dans Claude CodeTemplates de projetTarification et utilisation des tokens Claude CodeTarifs de Claude Code : ce que tu vas vraiment payerClaude Code Ultra ReviewConstruire une app Next.js avec Claude CodeClaude Code With Supabase: Database, Auth, RLSVercel deepsec with Claude CodeTest-Driven Development with Claude CodeHow to Build a SaaS MVP With Claude CodeAdding Authentication With Claude Code (Supabase Auth)Adding Transactional Email With Claude Code (Resend + React Email)Building a Type-Safe API With Claude Code (oRPC + Zod)Commerce agentique : comment construire une app que les agents IA peuvent payerClaude Code 1M Context in Practice: When Bigger Isn't BetterClaude Code GitHub Actions Setup Guide (@claude + Cron)Claude Code Headless Mode: The Definitive Guide to claude -pClaude Code Max Plan vs API Cost: Break-Even GuideClaude Code Prompt Caching: The Token Discount Most People Never Turn OnCombien coûte la création d'un SaaS avec Claude Code en 2026Run a Team of AI Agents in Parallel with Git WorktreesPrompt Injection in Coding Agents: How to Not Get Pwned
speedy_devvkoen_salo
Blog/Handbook/Workflow/Adding Authentication With Claude Code (Supabase Auth)

Adding Authentication With Claude Code (Supabase Auth)

Add email/password signup, Google OAuth, magic links, protected routes, and session handling to a Next.js 16 app using Claude Code and Supabase Auth.

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →
speedy_devvWritten by speedy_devvPublished Jul 14, 20269 min readHandbook hubWorkflow index

Supabase Auth gives you a hosted user system with sessions, JWTs, and OAuth already built. You don't write a password hashing routine or a token refresh loop. What you do write is the plumbing that connects it to Next.js: two Supabase clients, a session refresh step in proxy.ts, and the forms and Server Actions that call it. Claude Code can generate all of it correctly if you give it the right patterns, which is what this post walks through with working code.


Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →

What Supabase Auth Gives You

Supabase Auth runs on top of your Postgres database. Users live in a schema called auth.users that you never query directly. Every sign-up, login, and OAuth flow ends the same way: Supabase issues a JWT, and @supabase/ssr stores it in cookies so your Next.js server can read it on every request.

You get email/password auth, magic links (passwordless sign-in via a one-time emailed link), and OAuth with 30-plus providers out of the box. This post covers Google as the OAuth example, since it's the one most builders reach for first. The pattern is the same for any other provider once Google works.

Install the Packages

Two packages, both from Supabase. @supabase/supabase-js is the core client. @supabase/ssr adds cookie-based session handling built for server-rendering frameworks like Next.js.

npm install @supabase/supabase-js @supabase/ssr

You also need two environment variables from your Supabase project dashboard, under Project Settings > API.

# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key

The anon key is safe to expose in the browser. Access control comes from row-level security policies on your tables, not from hiding this key.

Two Clients: Browser and Server

Supabase Auth in Next.js needs two separate clients because cookies work differently on each side. The browser client reads and writes cookies through document.cookie. The server client reads and writes them through the request and response objects Next.js gives you.

Ask Claude to create the browser client first. It's short.

// utils/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

The server client is used inside Server Components, Server Actions, and Route Handlers. In Next.js 16, cookies() from next/headers returns a Promise, so it needs await.

// utils/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // Called from a Server Component that can't set cookies.
            // proxy.ts refreshes the session instead, so this is safe to ignore.
          }
        },
      },
    }
  )
}

That try/catch isn't decoration. Server Components can't set cookies at all, only read them. The session refresh step in proxy.ts (next section) is what actually keeps the session alive, so a Server Component failing to set a cookie here is expected, not a bug.

Email/Password Signup and Login

Server Actions handle the form submissions. Both signup and login follow the same shape: pull fields off the FormData, call the Supabase method, redirect on success.

// app/login/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'

export async function signup(formData: FormData) {
  const supabase = await createClient()

  const email = formData.get('email') as string
  const password = formData.get('password') as string

  const { error } = await supabase.auth.signUp({ email, password })

  if (error) {
    redirect(`/login?error=${encodeURIComponent(error.message)}`)
  }

  revalidatePath('/', 'layout')
  redirect('/check-email')
}

export async function login(formData: FormData) {
  const supabase = await createClient()

  const email = formData.get('email') as string
  const password = formData.get('password') as string

  const { error } = await supabase.auth.signInWithPassword({ email, password })

  if (error) {
    redirect(`/login?error=${encodeURIComponent(error.message)}`)
  }

  revalidatePath('/', 'layout')
  redirect('/dashboard')
}

export async function signOut() {
  const supabase = await createClient()
  await supabase.auth.signOut()
  revalidatePath('/', 'layout')
  redirect('/login')
}

By default, Supabase requires email confirmation before a new account can log in, which is why signup redirects to a /check-email page instead of straight to the dashboard. You can turn confirmation off in the Supabase dashboard under Authentication > Providers for local testing, but leave it on in production.

The form itself is a Client Component so it can show validation state, but the submit action runs on the server.

// app/login/page.tsx
import { login, signup } from './actions'

export default function LoginPage({
  searchParams,
}: {
  searchParams: Promise<{ error?: string }>
}) {
  return (
    <form className="max-w-sm mx-auto py-12 space-y-4">
      <h1 className="text-2xl font-bold">Log in</h1>
      <input
        id="email"
        name="email"
        type="email"
        required
        placeholder="you@example.com"
        className="w-full border rounded px-3 py-2"
      />
      <input
        id="password"
        name="password"
        type="password"
        required
        placeholder="Password"
        className="w-full border rounded px-3 py-2"
      />
      <div className="flex gap-2">
        <button formAction={login} className="flex-1 bg-black text-white rounded py-2">
          Log in
        </button>
        <button formAction={signup} className="flex-1 border rounded py-2">
          Sign up
        </button>
      </div>
    </form>
  )
}

Ask Claude to add the searchParams.error display and you get error messaging for free. Since searchParams is a Promise in Next.js 16, reading it inside a Server Component still needs await if you use the value directly in the component body.

Session Refresh in proxy.ts

Next.js 16 renamed middleware.ts to proxy.ts, and the exported function is now named proxy instead of middleware. Claude still writes middleware.ts by default without a project instruction telling it otherwise, so add a line to CLAUDE.md: "Middleware logic goes in proxy.ts, exported as proxy, not middleware.ts."

The refresh logic lives in a helper file first, since it's easier to test and reuse.

// utils/supabase/proxy.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({ request })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
          supabaseResponse = NextResponse.next({ request })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  const {
    data: { user },
  } = await supabase.auth.getUser()

  const protectedPaths = ['/dashboard', '/account']
  const isProtected = protectedPaths.some((path) => request.nextUrl.pathname.startsWith(path))

  if (!user && isProtected) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }

  return supabaseResponse
}

Calling getUser() here does two things at once: it verifies the token against Supabase's server, and it refreshes the session cookie if the token is close to expiring. That's why this runs on every request instead of only on protected pages, an expired session needs to refresh before it expires, not after.

The root proxy.ts file just calls the helper and defines which paths it runs on.

// proxy.ts
import { type NextRequest } from 'next/server'
import { updateSession } from '@/utils/supabase/proxy'

export async function proxy(request: NextRequest) {
  return updateSession(request)
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

The matcher excludes static assets and images so the proxy doesn't run session checks on every favicon request. It still runs on every page and API route, which is what keeps sessions fresh across the whole app.

Protecting Routes (Defense in Depth)

The proxy redirect above covers most cases, but don't treat it as the only check. A matcher pattern is easy to get subtly wrong, and if it silently excludes a route you meant to protect, that route is now open. Verify again inside the page.

// app/dashboard/layout.tsx
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const supabase = await createClient()
  const {
    data: { user },
  } = await supabase.auth.getUser()

  if (!user) {
    redirect('/login')
  }

  return <>{children}</>
}

This costs one extra network round trip to Supabase per protected page load. That's a fair trade for not depending on a regex matcher as your only authorization boundary. The real backstop underneath both of these checks is row-level security on your tables, covered below, since that's the layer an attacker can't route around.

Google OAuth

Turn on the Google provider in the Supabase dashboard under Authentication > Providers before writing any code. You'll need a Client ID and Client Secret from the Google Cloud Console, and the redirect URI Google needs to accept is the one Supabase shows you on that page, in the shape https://your-project-ref.supabase.co/auth/v1/callback.

The sign-in action requests a URL from Supabase and redirects the browser to it.

// app/login/actions.ts (add to the same file)

export async function signInWithGoogle() {
  const supabase = await createClient()

  const { data, error } = await supabase.auth.signInWithOAuth({
    provider: 'google',
    options: {
      redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
    },
  })

  if (error || !data.url) {
    redirect('/login?error=Could not sign in with Google')
  }

  redirect(data.url)
}

NEXT_PUBLIC_SITE_URL needs to be set to your actual deployed URL (or http://localhost:3000 in development) so the redirect lands back on your app, not Supabase's default.

<form>
  <button formAction={signInWithGoogle} className="w-full border rounded py-2">
    Continue with Google
  </button>
</form>

Magic Links

A magic link is a one-time sign-in link sent by email. No password to set or remember. Supabase calls this a passwordless OTP flow under the hood, and it uses the same callback route as OAuth.

// app/login/actions.ts (add to the same file)

export async function sendMagicLink(formData: FormData) {
  const supabase = await createClient()
  const email = formData.get('email') as string

  const { error } = await supabase.auth.signInWithOtp({
    email,
    options: {
      emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
    },
  })

  if (error) {
    redirect(`/login?error=${encodeURIComponent(error.message)}`)
  }

  redirect('/check-email')
}

Wire it to its own small form since it only needs an email field, no password.

<form className="space-y-2">
  <input name="email" type="email" required placeholder="you@example.com" className="w-full border rounded px-3 py-2" />
  <button formAction={sendMagicLink} className="w-full border rounded py-2">
    Send magic link
  </button>
</form>

The Callback Route Both Flows Share

Google OAuth and magic links both end with Supabase redirecting the browser back to your app with a code query parameter. A single Route Handler exchanges that code for a session.

// app/auth/callback/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/utils/supabase/server'

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url)
  const code = searchParams.get('code')
  const next = searchParams.get('next') ?? '/dashboard'

  if (code) {
    const supabase = await createClient()
    const { error } = await supabase.auth.exchangeCodeForSession(code)

    if (!error) {
      return NextResponse.redirect(`${origin}${next}`)
    }
  }

  return NextResponse.redirect(`${origin}/login?error=Could not authenticate`)
}

This is the only place exchangeCodeForSession gets called. Every OAuth provider and every magic link points here, so adding a second provider later doesn't need a new callback route, just a new sign-in action that redirects to the same /auth/callback.

Row-Level Security Keyed to auth.uid()

None of the auth flow above restricts what a logged-in user can see in your database. That's Postgres's job, enforced through row-level security (RLS). A common pattern is a profiles table that gets a row automatically whenever someone signs up, using a Postgres trigger on auth.users.

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

create or replace 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 procedure public.handle_new_user();

With the table in place, turn on RLS and write policies that check the row's owner against the logged-in user's ID.

alter table public.profiles enable row level security;

create policy "Users can view their own profile"
on public.profiles for select
to authenticated
using ( (select auth.uid()) = id );

create policy "Users can update their own profile"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = id )
with check ( (select auth.uid()) = id );

auth.uid() reads the user ID out of the verified JWT that @supabase/ssr attaches to every request. There's no separate authorization check to write in your application code for this table, Postgres refuses the query outright if the row doesn't belong to the caller. This is the same guarantee that backs the proxy.ts redirect and the layout check above, except it can't be skipped by a missing route check. For the full RLS picture (INSERT/DELETE policies, views, and the security_invoker gotcha), see Claude Code With Supabase.

Ask Claude to run this whole flow end to end once it's wired: sign up with a new email, confirm it, log in, sign in with Google, request a magic link, hit a protected page while logged out and confirm the redirect. That's the actual test that matters, not just a clean type check.

Every piece here (the two clients, the proxy.ts refresh, the callback route, the RLS policy shape) is something Claude Code writes correctly once, but re-derives from scratch on every new project unless you've saved the pattern somewhere. The $29 Code Kit ships this already wired: protected pages, Google OAuth, and RLS on every table from the first migration, so a new project starts past this plumbing instead of rebuilding it. It's a one-time harness on top of Claude Code, not a subscription, and Claude Code itself still needs its own paid Anthropic plan underneath it.

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →

Posted by @speedy_devv

Continue in Workflow

  • Commerce agentique : comment construire une app que les agents IA peuvent payer
    Un guide en français simple du commerce agentique en 2026 : ce que font x402, ACP et le Machine Payments Protocol, plus un pas-à-pas d'un week-end pour livrer une API payante que les agents IA peuvent acheter.
  • Bonnes pratiques Claude Code
    Cinq habitudes séparent les ingénieurs qui livrent avec Claude Code : les PRDs, les règles CLAUDE.md modulaires, les slash commands personnalisés, les resets /clear, et un état d'esprit d'évolution du système.
  • Le mode auto de Claude Code
    Un second modèle Sonnet examine chaque appel d'outil Claude Code avant qu'il s'exécute. Ce que le mode auto bloque, ce qu'il autorise, et les règles d'autorisation qu'il place dans tes paramètres.
  • Channels, Routines, Teleport, Dispatch
    Les quatre fonctionnalités Claude Code livrées par Anthropic en mars et avril 2026 qui transforment le CLI en une couche de coordination orientée événements, entre téléphone, web et desktop.
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build a SaaS MVP With Claude Code
    A weekend build log: scaffold a Next.js 16 app, add Supabase auth and Postgres, wire up Stripe billing, and deploy to Vercel, all with Claude Code.

More from Handbook

  • Principes de base de l'agent
    Cinq façons de construire des agents spécialisés dans le code Claude : Sous-agents de tâches, .claude/agents YAML, commandes slash personnalisées, personas CLAUDE.md, et invites de perspective.
  • L'ingénierie du harness agent
    Le harness, c'est toutes les couches autour de ton agent IA sauf le modèle lui-même. Découvre les cinq leviers de contrôle, le paradoxe des contraintes, et pourquoi le design du harness détermine les performances de l'agent bien plus que le modèle.
  • Patterns d'agents
    Orchestrateur, fan-out, chaîne de validation, routage par spécialiste, raffinement progressif, et watchdog. Six formes d'orchestration pour câbler des sub-agents Claude Code.
  • Meilleures pratiques des équipes d'agents
    Patterns éprouvés pour les équipes d'agents Claude Code. Prompts de création riches en contexte, tâches bien calibrées, propriété des fichiers, mode délégué, et correctifs v2.1.33-v2.1.45.

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →

How to Build a SaaS MVP With Claude Code

A weekend build log: scaffold a Next.js 16 app, add Supabase auth and Postgres, wire up Stripe billing, and deploy to Vercel, all with Claude Code.

Adding Transactional Email With Claude Code (Resend + React Email)

How to build welcome, receipt, and password reset emails in a Next.js 16 app using Resend and React Email, with Claude Code writing the templates and the send logic.

On this page

What Supabase Auth Gives You
Install the Packages
Two Clients: Browser and Server
Email/Password Signup and Login
Session Refresh in proxy.ts
Protecting Routes (Defense in Depth)
Google OAuth
Magic Links
The Callback Route Both Flows Share
Row-Level Security Keyed to auth.uid()

Arrête de tout configurer. Place à la construction.

Des templates SaaS avec orchestration IA.

Découvrez ce que nous construisons pour les entreprises →