Build This Now
Build This Now
What Is Claude Code?Claude Code InstallationClaude Code Native InstallerYour First Claude Code Project
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.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →
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.


Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →

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.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →

Posted by @speedy_devv

Continue in Workflow

  • Agentic Commerce: How to Build an App AI Agents Can Pay For
    A plain-English guide to agentic commerce in 2026: what x402, ACP, and the Machine Payments Protocol do, plus a weekend walkthrough for shipping a paid API that AI agents can buy from.
  • Claude Code Best Practices
    Five habits separate engineers who ship with Claude Code: PRDs, modular CLAUDE.md rules, custom slash commands, /clear resets, and a system-evolution mindset.
  • Claude Code Auto Mode
    A second Sonnet model reviews every Claude Code tool call before it fires. What auto mode blocks, what it allows, and the allow rules it drops in your settings.
  • Channels, Routines, Teleport, Dispatch
    The four Claude Code features Anthropic shipped in March and April 2026 that turn the CLI into an event-driven coordination layer across phone, web, and 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

  • Agent Fundamentals
    Five ways to build specialist agents in Claude Code: Task sub-agents, .claude/agents YAML, custom slash commands, CLAUDE.md personas, and perspective prompts.
  • Agent Harness Engineering
    The harness is every layer around your AI agent except the model itself. Learn the five control levers, the constraint paradox, and why harness design determines agent performance more than the model does.
  • Agent Patterns
    Orchestrator, fan-out, validation chain, specialist routing, progressive refinement, and watchdog. Six orchestration shapes to wire Claude Code sub-agents with.
  • Agent Teams Best Practices
    Battle-tested patterns for Claude Code Agent Teams. Context-rich spawn prompts, right-sized tasks, file ownership, delegate mode, and v2.1.33-v2.1.45 fixes.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →

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

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →