Build This Now
Build This Now
O que é o Código Claude?Instalar o Claude CodeInstalador Nativo do Claude CodeO Teu Primeiro Projeto com Claude Code
Claude Code v2.1.122 Release NotesClaude Code Dynamic Workflows: Como Orquestrar 1.000 Subagentes Num Codebase RealMelhores Práticas do Claude CodeBoas Práticas para o Claude Opus 4.7Claude Code num VPSIntegração GitRevisão de Código com ClaudeWorktrees no Claude CodeControle Remoto do Claude CodeChannels do Claude CodeChannels, Routines, Teleport, DispatchTarefas Agendadas no Claude CodePermissões do Claude CodeModo Auto do Claude CodeAdicionar Pagamentos Stripe Com o Claude CodeFeedback LoopsFluxos de Trabalho com TodosTarefas no Claude CodeTemplates de ProjetoPreços e Consumo de Tokens no Claude CodePreços do Claude Code: O Que Vais Mesmo PagarClaude Code Ultra ReviewConstruir Uma App Next.js Com o 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)Comércio Agêntico: Como Construir uma App Que Agentes de IA Podem PagarClaude 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 OnQuanto Custa Construir um SaaS com Claude Code em 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.

Pare de configurar. Comece a construir.

Templates SaaS com orquestração de IA.

Veja o que construímos para empresas →
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.


Pare de configurar. Comece a construir.

Templates SaaS com orquestração de IA.

Veja o que construímos para empresas →

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.

Pare de configurar. Comece a construir.

Templates SaaS com orquestração de IA.

Veja o que construímos para empresas →

Posted by @speedy_devv

Continue in Workflow

  • Comércio Agêntico: Como Construir uma App Que Agentes de IA Podem Pagar
    Um guia em português simples sobre comércio agêntico em 2026: o que fazem o x402, o ACP e o Machine Payments Protocol, mais um passo a passo de fim de semana para lançar uma API paga que agentes de IA podem comprar.
  • Melhores Práticas do Claude Code
    Cinco hábitos separam os engenheiros que entregam com Claude Code: PRDs, regras modulares em CLAUDE.md, slash commands personalizados, resets com /clear e uma mentalidade de evolução do sistema.
  • Modo Auto do Claude Code
    Um segundo modelo Sonnet revê cada chamada de ferramenta do Claude Code antes de ser executada. O que o modo auto bloqueia, o que permite e as regras de permissão que cria nas tuas definições.
  • Channels, Routines, Teleport, Dispatch
    As quatro funcionalidades de Claude Code que a Anthropic lançou em março e abril de 2026 e que transformam a CLI numa camada de coordenação orientada a eventos entre telemóvel, web e 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

  • Fundamentos do agente
    Cinco maneiras de criar agentes especializados no Código Claude: Sub-agentes de tarefas, .claude/agents YAML, comandos de barra personalizados, personas CLAUDE.md e prompts de perspetiva.
  • Engenharia de Harness para Agentes
    O harness é cada camada ao redor do seu agente de IA, exceto o modelo em si. Aprenda os cinco pontos de controle, o paradoxo das restrições, e por que o design do harness determina o desempenho do agente mais do que o modelo.
  • Padrões de Agentes
    Orchestrator, fan-out, cadeia de validação, routing especializado, refinamento progressivo e watchdog. Seis formas de orquestração para ligar sub-agentes no Claude Code.
  • Boas Práticas para Equipas de Agentes
    Padrões testados em produção para Equipas de Agentes Claude Code. Prompts de criação ricos em contexto, tarefas bem dimensionadas, posse de ficheiros, modo delegado, e correções das versões v2.1.33-v2.1.45.

Pare de configurar. Comece a construir.

Templates SaaS com orquestração de IA.

Veja o que construímos para empresas →

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()

Pare de configurar. Comece a construir.

Templates SaaS com orquestração de IA.

Veja o que construímos para empresas →