Ajouter l'authentification avec Claude Code (Supabase Auth)
Ajoute l'inscription email/mot de passe, l'OAuth Google, les magic links, les routes protégées et la gestion des sessions à une app Next.js 16 avec Claude Code et Supabase Auth.
Vous voulez le framework derrière ces projets ?
Obtenez le système Claude Code que nous utilisons pour planifier, construire, tester et livrer des logiciels en production.
Supabase Auth te donne un système d'utilisateurs hébergé avec sessions, JWT et OAuth déjà construits. Tu n'écris pas de routine de hachage de mot de passe ni de boucle de rafraîchissement de token. Ce que tu écris, c'est la plomberie qui le relie à Next.js : deux clients Supabase, une étape de rafraîchissement de session dans proxy.ts, et les formulaires et Server Actions qui l'appellent. Claude Code peut tout générer correctement si tu lui donnes les bons patterns, et c'est justement ce que ce post détaille avec du code qui marche.
Ce que Supabase Auth te donne
Supabase Auth tourne par-dessus ta base Postgres. Les utilisateurs vivent dans un schéma appelé auth.users que tu ne requêtes jamais directement. Chaque inscription, connexion et flux OAuth se termine de la même façon : Supabase émet un JWT, et @supabase/ssr le stocke dans les cookies pour que ton serveur Next.js puisse le lire à chaque requête.
Tu obtiens l'auth email/mot de passe, les magic links (connexion sans mot de passe via un lien à usage unique envoyé par email) et l'OAuth avec plus de 30 fournisseurs prêts à l'emploi. Ce post prend Google comme exemple d'OAuth, puisque c'est celui vers lequel la plupart des builders se tournent en premier. Le pattern est le même pour n'importe quel autre fournisseur une fois que Google marche.
Installer les packages
Deux packages, tous deux de Supabase. @supabase/supabase-js est le client de base. @supabase/ssr ajoute la gestion de session basée sur les cookies, conçue pour les frameworks de rendu serveur comme Next.js.
npm install @supabase/supabase-js @supabase/ssrIl te faut aussi deux variables d'environnement depuis le dashboard de ton projet Supabase, sous Project Settings > API.
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-keyLa clé anon peut être exposée sans risque dans le navigateur. Le contrôle d'accès vient des policies de row-level security sur tes tables, pas du fait de cacher cette clé.
Deux clients : navigateur et serveur
Supabase Auth dans Next.js a besoin de deux clients distincts parce que les cookies fonctionnent différemment de chaque côté. Le client navigateur lit et écrit les cookies via document.cookie. Le client serveur les lit et les écrit via les objets de requête et de réponse que Next.js te donne.
Demande à Claude de créer d'abord le client navigateur. Il est court.
// 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!
)
}Le client serveur est utilisé dans les Server Components, les Server Actions et les Route Handlers. Dans Next.js 16, cookies() de next/headers renvoie une Promise, il faut donc 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.
}
},
},
}
)
}Ce try/catch n'est pas décoratif. Les Server Components ne peuvent pas du tout écrire de cookies, seulement les lire. L'étape de rafraîchissement de session dans proxy.ts (section suivante) est ce qui maintient vraiment la session en vie, donc un Server Component qui n'arrive pas à écrire un cookie ici est attendu, pas un bug.
Inscription et connexion email/mot de passe
Les Server Actions gèrent la soumission des formulaires. L'inscription et la connexion suivent la même forme : récupérer les champs du FormData, appeler la méthode Supabase, rediriger en cas de succès.
// 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')
}Par défaut, Supabase exige une confirmation par email avant qu'un nouveau compte puisse se connecter, c'est pourquoi signup redirige vers une page /check-email au lieu d'aller directement au dashboard. Tu peux couper la confirmation dans le dashboard Supabase sous Authentication > Providers pour tester en local, mais laisse-la active en prod.
Le formulaire lui-même est un Client Component pour pouvoir afficher l'état de validation, mais l'action de soumission tourne sur le serveur.
// 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>
)
}Demande à Claude d'ajouter l'affichage de searchParams.error et tu obtiens la gestion des messages d'erreur gratuitement. Comme searchParams est une Promise dans Next.js 16, le lire dans un Server Component a toujours besoin d'await si tu utilises la valeur directement dans le corps du composant.
Rafraîchir la session dans proxy.ts
Next.js 16 a renommé middleware.ts en proxy.ts, et la fonction exportée s'appelle désormais proxy au lieu de middleware. Claude écrit encore middleware.ts par défaut sans instruction de projet lui disant le contraire, alors ajoute une ligne à CLAUDE.md : « La logique de middleware va dans proxy.ts, exportée comme proxy, pas dans middleware.ts. »
La logique de rafraîchissement vit d'abord dans un fichier helper, puisqu'elle est plus facile à tester et à réutiliser.
// 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
}Appeler getUser() ici fait deux choses à la fois : ça vérifie le token contre le serveur de Supabase, et ça rafraîchit le cookie de session si le token approche de son expiration. C'est pour ça que ça tourne à chaque requête et pas seulement sur les pages protégées, une session expirée doit se rafraîchir avant d'expirer, pas après.
Le fichier proxy.ts racine se contente d'appeler le helper et de définir les chemins sur lesquels il tourne.
// 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)$).*)',
],
}Le matcher exclut les assets statiques et les images pour que le proxy ne lance pas de vérification de session sur chaque requête de favicon. Il tourne quand même sur chaque page et route API, ce qui garde les sessions fraîches dans toute l'app.
Protéger les routes (défense en profondeur)
La redirection du proxy ci-dessus couvre la plupart des cas, mais ne la traite pas comme la seule vérification. Un pattern de matcher est facile à mal régler subtilement, et s'il exclut en silence une route que tu voulais protéger, cette route est maintenant ouverte. Vérifie à nouveau dans la 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}</>
}Ça coûte un aller-retour réseau supplémentaire vers Supabase par chargement de page protégée. C'est un juste échange contre le fait de ne pas dépendre d'un matcher regex comme unique frontière d'autorisation. Le vrai filet de sécurité sous ces deux vérifications, c'est la row-level security sur tes tables, couverte plus bas, puisque c'est la couche qu'un attaquant ne peut pas contourner.
OAuth Google
Active le fournisseur Google dans le dashboard Supabase sous Authentication > Providers avant d'écrire du code. Il te faut un Client ID et un Client Secret depuis la Google Cloud Console, et l'URI de redirection que Google doit accepter est celui que Supabase t'affiche sur cette page, de la forme https://your-project-ref.supabase.co/auth/v1/callback.
L'action de connexion demande une URL à Supabase et y redirige le navigateur.
// 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 doit être réglée sur ton URL réellement déployée (ou http://localhost:3000 en développement) pour que la redirection retombe sur ton app, pas sur celle par défaut de Supabase.
<form>
<button formAction={signInWithGoogle} className="w-full border rounded py-2">
Continue with Google
</button>
</form>Magic links
Un magic link est un lien de connexion à usage unique envoyé par email. Pas de mot de passe à définir ni à retenir. Supabase appelle ça un flux OTP sans mot de passe en coulisses, et il utilise la même route de callback que l'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')
}Branche-le à son propre petit formulaire puisqu'il n'a besoin que d'un champ email, pas de mot de passe.
<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>La route de callback partagée par les deux flux
L'OAuth Google et les magic links se terminent tous deux par Supabase qui redirige le navigateur vers ton app avec un paramètre de requête code. Un seul Route Handler échange ce code contre une 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`)
}C'est le seul endroit où exchangeCodeForSession est appelé. Chaque fournisseur OAuth et chaque magic link pointe ici, donc ajouter un second fournisseur plus tard ne demande pas de nouvelle route de callback, juste une nouvelle action de connexion qui redirige vers le même /auth/callback.
Row-level security calée sur auth.uid()
Rien dans le flux d'auth ci-dessus ne restreint ce qu'un utilisateur connecté peut voir dans ta base. C'est le boulot de Postgres, imposé via la row-level security (RLS). Un pattern courant est une table profiles qui reçoit automatiquement une ligne dès que quelqu'un s'inscrit, grâce à un trigger Postgres sur 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();Une fois la table en place, active la RLS et écris des policies qui vérifient le propriétaire de la ligne contre l'ID de l'utilisateur connecté.
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() lit l'ID utilisateur dans le JWT vérifié que @supabase/ssr attache à chaque requête. Il n'y a pas de vérification d'autorisation séparée à écrire dans ton code applicatif pour cette table, Postgres refuse la requête d'emblée si la ligne n'appartient pas à l'appelant. C'est la même garantie qui soutient la redirection de proxy.ts et la vérification de layout ci-dessus, sauf qu'elle ne peut pas être contournée par une vérification de route manquante. Pour le tableau complet de la RLS (policies INSERT/DELETE, vues, et le piège de security_invoker), voir Claude Code avec Supabase.
Demande à Claude de faire tourner tout ce flux de bout en bout une fois qu'il est branché : inscris-toi avec un nouvel email, confirme-le, connecte-toi, connecte-toi avec Google, demande un magic link, atteins une page protégée en étant déconnecté et confirme la redirection. C'est le vrai test qui compte, pas juste une vérification de types propre.
Chaque pièce ici (les deux clients, le rafraîchissement dans proxy.ts, la route de callback, la forme de la policy RLS) est quelque chose que Claude Code écrit correctement une fois, mais re-dérive de zéro sur chaque nouveau projet à moins que tu aies sauvegardé le pattern quelque part. Le Code Kit à $29 livre ça déjà branché : pages protégées, OAuth Google et RLS sur chaque table dès la première migration, pour qu'un nouveau projet démarre au-delà de cette plomberie au lieu de la reconstruire. C'est un moteur ponctuel posé sur Claude Code, pas un abonnement, et Claude Code lui-même a toujours besoin de son propre plan Anthropic payant en dessous.
Posté par @speedy_devv
Vous voulez le framework derrière ces projets ?
Obtenez le système Claude Code que nous utilisons pour planifier, construire, tester et livrer des logiciels en production.
Construire un MVP SaaS avec Claude Code
Un journal de build sur un week-end : monter une app Next.js 16, ajouter l'auth Supabase et Postgres, brancher la facturation Stripe, et déployer sur Vercel, le tout avec Claude Code.
Ajouter les emails transactionnels avec Claude Code (Resend + React Email)
Comment construire des emails de bienvenue, de reçu et de réinitialisation de mot de passe dans une app Next.js 16 avec Resend et React Email, en laissant Claude Code écrire les templates et la logique d'envoi.

