Adicionar Autenticação Com o Claude Code (Supabase Auth)
Adiciona registo por email/password, Google OAuth, magic links, rotas protegidas e gestão de sessões a uma app Next.js 16 usando o Claude Code e o Supabase Auth.
Quer o framework por trás destes projetos?
Obtenha o sistema Claude Code que usamos para planejar, construir, testar e lançar software em produção.
O Supabase Auth dá-te um sistema de utilizadores alojado com sessões, JWTs e OAuth já construídos. Não escreves uma rotina de hashing de passwords nem um ciclo de refresh de tokens. O que escreves é a canalização que o liga ao Next.js: dois clientes Supabase, um passo de refresh da sessão no proxy.ts, e os formulários e as Server Actions que o chamam. O Claude Code consegue gerar tudo isto corretamente se lhe deres os padrões certos, que é o que este post percorre com código a funcionar.
O Que o Supabase Auth Te Dá
O Supabase Auth corre por cima da tua base de dados Postgres. Os utilizadores vivem num esquema chamado auth.users que nunca consultas diretamente. Cada registo, login e fluxo OAuth acaba da mesma forma: o Supabase emite um JWT, e o @supabase/ssr guarda-o em cookies para que o teu servidor Next.js o possa ler em cada pedido.
Recebes autenticação por email/password, magic links (início de sessão sem password através de um link único enviado por email), e OAuth com mais de 30 fornecedores logo de início. Este post cobre o Google como exemplo de OAuth, já que é o que a maioria de quem constrói escolhe primeiro. O padrão é o mesmo para qualquer outro fornecedor assim que o Google funciona.
Instalar os Pacotes
Dois pacotes, ambos do Supabase. O @supabase/supabase-js é o cliente central. O @supabase/ssr acrescenta gestão de sessões baseada em cookies feita para frameworks de renderização no servidor como o Next.js.
npm install @supabase/supabase-js @supabase/ssrPrecisas também de duas variáveis de ambiente do dashboard do teu projeto Supabase, em Project Settings > API.
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-keyA anon key pode ser exposta no browser sem risco. O controlo de acesso vem das políticas de row-level security nas tuas tabelas, não de esconder esta chave.
Dois Clientes: Browser e Servidor
O Supabase Auth no Next.js precisa de dois clientes separados porque os cookies funcionam de forma diferente de cada lado. O cliente do browser lê e escreve cookies através do document.cookie. O cliente do servidor lê e escreve através dos objetos de pedido e resposta que o Next.js te dá.
Pede ao Claude para criar primeiro o cliente do browser. É curto.
// 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!
)
}O cliente do servidor é usado dentro de Server Components, Server Actions e Route Handlers. No Next.js 16, o cookies() de next/headers devolve uma Promise, por isso precisa de 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.
}
},
},
}
)
}Aquele try/catch não é decoração. Os Server Components não conseguem definir cookies de todo, só lê-los. O passo de refresh da sessão no proxy.ts (secção a seguir) é o que mantém a sessão viva, por isso um Server Component a falhar a definição de um cookie aqui é esperado, não um bug.
Registo e Login Por Email/Password
As Server Actions tratam das submissões dos formulários. Tanto o registo como o login seguem o mesmo formato: puxar os campos do FormData, chamar o método do Supabase, redirecionar em caso de sucesso.
// 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')
}Por omissão, o Supabase exige confirmação por email antes de uma conta nova poder iniciar sessão, e é por isso que o signup redireciona para uma página /check-email em vez de ir direto ao dashboard. Podes desligar a confirmação no dashboard do Supabase em Authentication > Providers para testes locais, mas deixa-a ligada em produção.
O formulário em si é um Client Component para poder mostrar o estado de validação, mas a ação de submit corre no servidor.
// 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>
)
}Pede ao Claude para adicionar a apresentação de searchParams.error e ganhas mensagens de erro de graça. Como searchParams é uma Promise no Next.js 16, lê-lo dentro de um Server Component ainda precisa de await se usares o valor diretamente no corpo do componente.
Refresh da Sessão no proxy.ts
O Next.js 16 renomeou o middleware.ts para proxy.ts, e a função exportada chama-se agora proxy em vez de middleware. O Claude continua a escrever middleware.ts por omissão sem uma instrução de projeto que diga o contrário, por isso acrescenta uma linha ao CLAUDE.md: "Middleware logic goes in proxy.ts, exported as proxy, not middleware.ts."
A lógica de refresh vive primeiro num ficheiro auxiliar, já que é mais fácil de testar e reutilizar.
// 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
}Chamar getUser() aqui faz duas coisas ao mesmo tempo: verifica o token contra o servidor do Supabase, e faz refresh do cookie da sessão se o token estiver perto de expirar. É por isso que isto corre em cada pedido em vez de só nas páginas protegidas, uma sessão expirada precisa de fazer refresh antes de expirar, não depois.
O ficheiro raiz proxy.ts limita-se a chamar o auxiliar e a definir em que caminhos corre.
// 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)$).*)',
],
}O matcher exclui os ativos estáticos e as imagens para que o proxy não corra verificações de sessão em cada pedido de favicon. Continua a correr em cada página e rota de API, que é o que mantém as sessões atualizadas em toda a app.
Proteger Rotas (Defesa em Profundidade)
O redirect do proxy acima cobre a maioria dos casos, mas não o trates como a única verificação. Um padrão de matcher é fácil de acertar mal de forma subtil, e se excluir em silêncio uma rota que querias proteger, essa rota fica agora aberta. Verifica outra vez dentro da página.
// 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}</>
}Isto custa uma ida e volta de rede extra ao Supabase por cada carregamento de página protegida. É uma troca justa por não depender de um matcher de regex como a tua única fronteira de autorização. O verdadeiro reforço por baixo destas duas verificações é o row-level security nas tuas tabelas, coberto abaixo, já que essa é a camada que um atacante não consegue contornar.
Google OAuth
Liga o fornecedor Google no dashboard do Supabase em Authentication > Providers antes de escreveres qualquer código. Vais precisar de um Client ID e um Client Secret da Google Cloud Console, e o redirect URI que o Google precisa de aceitar é o que o Supabase te mostra nessa página, no formato https://your-project-ref.supabase.co/auth/v1/callback.
A ação de início de sessão pede um URL ao Supabase e redireciona o browser para lá.
// 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)
}O NEXT_PUBLIC_SITE_URL precisa de estar definido para o teu URL efetivamente publicado (ou http://localhost:3000 em desenvolvimento) para que o redirect aterre de volta na tua app, e não na predefinição do Supabase.
<form>
<button formAction={signInWithGoogle} className="w-full border rounded py-2">
Continue with Google
</button>
</form>Magic Links
Um magic link é um link de início de sessão único enviado por email. Nenhuma password para definir ou lembrar. O Supabase chama a isto um fluxo OTP sem password nos bastidores, e usa a mesma rota de callback do 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')
}Liga-o ao seu próprio pequeno formulário, já que só precisa de um campo de email, sem 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>A Rota de Callback Que Ambos os Fluxos Partilham
O Google OAuth e os magic links acabam ambos com o Supabase a redirecionar o browser de volta para a tua app com um parâmetro de query code. Um único Route Handler troca esse código por uma sessão.
// 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`)
}Este é o único sítio onde o exchangeCodeForSession é chamado. Todos os fornecedores OAuth e todos os magic links apontam para aqui, por isso adicionar um segundo fornecedor mais tarde não precisa de uma nova rota de callback, apenas de uma nova ação de início de sessão que redirecione para o mesmo /auth/callback.
Row-Level Security Ligado ao auth.uid()
Nada do fluxo de autenticação acima restringe o que um utilizador com sessão iniciada consegue ver na tua base de dados. Esse é o trabalho do Postgres, imposto através do row-level security (RLS). Um padrão comum é uma tabela profiles que recebe uma linha automaticamente sempre que alguém se regista, usando um trigger do Postgres em 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();Com a tabela no sítio, liga o RLS e escreve políticas que verificam o dono da linha contra o ID do utilizador com sessão iniciada.
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 );O auth.uid() lê o ID do utilizador do JWT verificado que o @supabase/ssr anexa a cada pedido. Não há uma verificação de autorização separada para escrever no código da tua aplicação para esta tabela, o Postgres recusa a query pura e simplesmente se a linha não pertencer a quem chama. Esta é a mesma garantia que suporta o redirect do proxy.ts e a verificação do layout acima, exceto que não pode ser saltada por uma verificação de rota em falta. Para o quadro completo do RLS (políticas de INSERT/DELETE, views, e a armadilha do security_invoker), vê Claude Code Com o Supabase.
Pede ao Claude para correr este fluxo todo de ponta a ponta assim que estiver ligado: regista-te com um email novo, confirma-o, inicia sessão, entra com o Google, pede um magic link, atinge uma página protegida sem sessão iniciada e confirma o redirect. Esse é o teste que interessa de verdade, não apenas uma verificação de tipos limpa.
Cada peça aqui (os dois clientes, o refresh do proxy.ts, a rota de callback, o formato da política de RLS) é algo que o Claude Code escreve corretamente uma vez, mas volta a derivar do zero em cada projeto novo a menos que tenhas guardado o padrão nalgum sítio. O Code Kit de $29 entrega isto já ligado: páginas protegidas, Google OAuth, e RLS em todas as tabelas desde a primeira migração, para que um projeto novo comece para lá desta canalização em vez de a reconstruir. É uma estrutura de pagamento único por cima do Claude Code, não uma subscrição, e o próprio Claude Code continua a precisar do seu plano Anthropic pago por baixo.
Posted by @speedy_devv
Quer o framework por trás destes projetos?
Obtenha o sistema Claude Code que usamos para planejar, construir, testar e lançar software em produção.
Como Construir um MVP de SaaS Com o Claude Code
Um diário de construção de fim de semana: montar uma app Next.js 16, adicionar autenticação e Postgres com Supabase, ligar a faturação Stripe e fazer deploy na Vercel, tudo com o Claude Code.
Adicionar Email Transacional Com o Claude Code (Resend + React Email)
Como construir emails de boas-vindas, recibos e reposição de password numa app Next.js 16 usando o Resend e o React Email, com o Claude Code a escrever os templates e a lógica de envio.

