- Learn
- Guided Projects
- Build an Authentication System
Intermediate55 min2 prerequisites
Create a complete authentication system with email/password, OAuth providers, password reset, and protected routes.
Build an Authentication System
Build a production-ready authentication system with multiple sign-in methods, email verification, password reset, and protected routes.
Project Overview
What We're Building
Terminal
Authentication Features:
├── Sign Up
│ ├── Email/password registration
│ ├── Email verification
│ └── Form validation
├── Sign In
│ ├── Email/password login
│ ├── OAuth providers (Google, GitHub)
│ └── Remember me option
├── Password Reset
│ ├── Request reset email
│ └── Set new password
├── Session Management
│ ├── JWT tokens
│ ├── Refresh tokens
│ └── Sign out
├── Protected Routes
│ ├── Middleware protection
│ └── Role-based access
└── User Profile
├── Update profile
└── Change password
Tech Stack
- Frontend: Next.js + Tailwind + shadcn/ui
- Backend: Supabase Auth
- Validation: Zod + React Hook Form
- Deployment: Vercel
Phase 1: Project Setup
Create Project
Terminal
npx create-next-app@latest auth-system --typescript --tailwind --eslint
cd auth-system
# Add shadcn/ui
npx shadcn@latest init
# Add components
npx shadcn@latest add button input label card form toast
# Add Supabase and validation
npm install @supabase/supabase-js @supabase/ssr
npm install zod react-hook-form @hookform/resolvers
Environment Variables
Terminal
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
# For OAuth callbacks
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Supabase Client Setup
Terminal
// lib/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!
)
}
Terminal
// lib/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 {}
},
},
}
)
}
Phase 2: Sign Up Flow
AI Prompt for Sign Up
Terminal
"Create a sign up page with:
- Email and password fields
- Password confirmation field
- Password strength indicator
- Form validation with error messages
- Submit button with loading state
- Link to sign in page
Use React Hook Form with Zod validation.
Style with Tailwind and shadcn/ui."
Validation Schema
Terminal
// lib/validations/auth.ts
import { z } from 'zod'
export const signUpSchema = z.object({
email: z.string().email('Invalid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[a-z]/, 'Password must contain a lowercase letter')
.regex(/[0-9]/, 'Password must contain a number'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
})
export const signInSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(1, 'Password is required'),
})
export const resetPasswordSchema = z.object({
email: z.string().email('Invalid email address'),
})
export const newPasswordSchema = z.object({
password: z
.string()
.min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
})
export type SignUpInput = z.infer<typeof signUpSchema>
export type SignInInput = z.infer<typeof signInSchema>
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>
export type NewPasswordInput = z.infer<typeof newPasswordSchema>
Sign Up Page
Terminal
// app/signup/page.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { createClient } from '@/lib/supabase/client'
import { signUpSchema, SignUpInput } from '@/lib/validations/auth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { useToast } from '@/hooks/use-toast'
import { Loader2 } from 'lucide-react'
export default function SignUpPage() {
const [isLoading, setIsLoading] = useState(false)
const router = useRouter()
const { toast } = useToast()
const supabase = createClient()
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SignUpInput>({
resolver: zodResolver(signUpSchema),
})
async function onSubmit(data: SignUpInput) {
setIsLoading(true)
const { error } = await supabase.auth.signUp({
email: data.email,
password: data.password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
},
})
setIsLoading(false)
if (error) {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
})
return
}
toast({
title: 'Check your email',
description: 'We sent you a confirmation link.',
})
router.push('/signup/verify-email')
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Create an account</CardTitle>
<CardDescription>
Enter your email to create your account
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@example.com"
{...register('email')}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
{...register('password')}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
{...register('confirmPassword')}
/>
{errors.confirmPassword && (
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
)}
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign Up
</Button>
<p className="text-sm text-muted-foreground">
Already have an account?{' '}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
)
}
Email Verification Page
Terminal
// app/signup/verify-email/page.tsx
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Mail } from 'lucide-react'
export default function VerifyEmailPage() {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md text-center">
<CardHeader>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Mail className="h-6 w-6 text-primary" />
</div>
<CardTitle>Check your email</CardTitle>
<CardDescription>
We sent a verification link to your email address
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Click the link in the email to verify your account.
If you don't see it, check your spam folder.
</p>
</CardContent>
</Card>
</div>
)
}
Phase 3: Sign In Flow
Sign In Page
Terminal
// app/login/page.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { createClient } from '@/lib/supabase/client'
import { signInSchema, SignInInput } from '@/lib/validations/auth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { useToast } from '@/hooks/use-toast'
import { Loader2 } from 'lucide-react'
import { OAuthButtons } from '@/components/OAuthButtons'
export default function LoginPage() {
const [isLoading, setIsLoading] = useState(false)
const router = useRouter()
const { toast } = useToast()
const supabase = createClient()
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SignInInput>({
resolver: zodResolver(signInSchema),
})
async function onSubmit(data: SignInInput) {
setIsLoading(true)
const { error } = await supabase.auth.signInWithPassword({
email: data.email,
password: data.password,
})
setIsLoading(false)
if (error) {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
})
return
}
router.push('/dashboard')
router.refresh()
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Welcome back</CardTitle>
<CardDescription>
Sign in to your account to continue
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
<OAuthButtons />
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@example.com"
{...register('email')}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
href="/forgot-password"
className="text-sm text-primary hover:underline"
>
Forgot password?
</Link>
</div>
<Input
id="password"
type="password"
{...register('password')}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign In
</Button>
<p className="text-sm text-muted-foreground">
Don't have an account?{' '}
<Link href="/signup" className="text-primary hover:underline">
Sign up
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
)
}
Phase 4: OAuth Providers
Configure OAuth in Supabase
Terminal
Supabase Dashboard → Authentication → Providers
Google:
1. Enable Google provider
2. Add Client ID and Secret from Google Cloud Console
3. Authorized redirect: https://xxx.supabase.co/auth/v1/callback
GitHub:
1. Enable GitHub provider
2. Add Client ID and Secret from GitHub OAuth Apps
3. Authorized redirect: https://xxx.supabase.co/auth/v1/callback
OAuth Buttons Component
Terminal
// components/OAuthButtons.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Github } from 'lucide-react'
export function OAuthButtons() {
const supabase = createClient()
async function signInWithGoogle() {
await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
})
}
async function signInWithGitHub() {
await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
})
}
return (
<div className="grid gap-2">
<Button variant="outline" type="button" onClick={signInWithGoogle}>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Continue with Google
</Button>
<Button variant="outline" type="button" onClick={signInWithGitHub}>
<Github className="mr-2 h-4 w-4" />
Continue with GitHub
</Button>
</div>
)
}
Auth Callback Handler
Terminal
// app/auth/callback/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/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=auth_failed`)
}
Phase 5: Password Reset
Forgot Password Page
Terminal
// app/forgot-password/page.tsx
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { createClient } from '@/lib/supabase/client'
import { resetPasswordSchema, ResetPasswordInput } from '@/lib/validations/auth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { useToast } from '@/hooks/use-toast'
import { Loader2, ArrowLeft } from 'lucide-react'
export default function ForgotPasswordPage() {
const [isLoading, setIsLoading] = useState(false)
const [submitted, setSubmitted] = useState(false)
const { toast } = useToast()
const supabase = createClient()
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ResetPasswordInput>({
resolver: zodResolver(resetPasswordSchema),
})
async function onSubmit(data: ResetPasswordInput) {
setIsLoading(true)
const { error } = await supabase.auth.resetPasswordForEmail(data.email, {
redirectTo: `${window.location.origin}/reset-password`,
})
setIsLoading(false)
if (error) {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
})
return
}
setSubmitted(true)
}
if (submitted) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md text-center">
<CardHeader>
<CardTitle>Check your email</CardTitle>
<CardDescription>
We sent a password reset link to your email
</CardDescription>
</CardHeader>
<CardFooter className="justify-center">
<Link href="/login" className="text-primary hover:underline">
Back to login
</Link>
</CardFooter>
</Card>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Forgot password?</CardTitle>
<CardDescription>
Enter your email and we'll send you a reset link
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@example.com"
{...register('email')}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Send Reset Link
</Button>
<Link
href="/login"
className="text-sm text-muted-foreground hover:text-primary flex items-center"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to login
</Link>
</CardFooter>
</form>
</Card>
</div>
)
}
Reset Password Page
Terminal
// app/reset-password/page.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { createClient } from '@/lib/supabase/client'
import { newPasswordSchema, NewPasswordInput } from '@/lib/validations/auth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { useToast } from '@/hooks/use-toast'
import { Loader2 } from 'lucide-react'
export default function ResetPasswordPage() {
const [isLoading, setIsLoading] = useState(false)
const router = useRouter()
const { toast } = useToast()
const supabase = createClient()
const {
register,
handleSubmit,
formState: { errors },
} = useForm<NewPasswordInput>({
resolver: zodResolver(newPasswordSchema),
})
async function onSubmit(data: NewPasswordInput) {
setIsLoading(true)
const { error } = await supabase.auth.updateUser({
password: data.password,
})
setIsLoading(false)
if (error) {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
})
return
}
toast({
title: 'Password updated',
description: 'Your password has been reset successfully.',
})
router.push('/login')
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Reset password</CardTitle>
<CardDescription>
Enter your new password below
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<Input
id="password"
type="password"
{...register('password')}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
{...register('confirmPassword')}
/>
{errors.confirmPassword && (
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
)}
</div>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Reset Password
</Button>
</CardFooter>
</form>
</Card>
</div>
)
}
Phase 6: Protected Routes
Middleware
Terminal
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import { createServerClient } from '@supabase/ssr'
export async function middleware(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()
// Protected routes
if (request.nextUrl.pathname.startsWith('/dashboard')) {
if (!user) {
const url = request.nextUrl.clone()
url.pathname = '/login'
url.searchParams.set('redirect', request.nextUrl.pathname)
return NextResponse.redirect(url)
}
}
// Redirect authenticated users away from auth pages
if (user && ['/login', '/signup'].includes(request.nextUrl.pathname)) {
const url = request.nextUrl.clone()
url.pathname = '/dashboard'
return NextResponse.redirect(url)
}
return supabaseResponse
}
export const config = {
matcher: ['/dashboard/:path*', '/login', '/signup'],
}
Protected Dashboard
Terminal
// app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { SignOutButton } from '@/components/SignOutButton'
export default async function DashboardPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
return (
<div className="container mx-auto p-6">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold">Dashboard</h1>
<SignOutButton />
</div>
<div className="bg-muted rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">Welcome back!</h2>
<p className="text-muted-foreground">
Signed in as: {user.email}
</p>
</div>
</div>
)
}
Sign Out Button
Terminal
// components/SignOutButton.tsx
'use client'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { LogOut } from 'lucide-react'
export function SignOutButton() {
const router = useRouter()
const supabase = createClient()
async function handleSignOut() {
await supabase.auth.signOut()
router.push('/login')
router.refresh()
}
return (
<Button variant="outline" onClick={handleSignOut}>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</Button>
)
}
Verification Checklist
- Sign up creates account
- Email verification works
- Sign in with email/password works
- Google OAuth works
- GitHub OAuth works
- Password reset flow works
- Protected routes redirect unauthenticated users
- Sign out clears session
- Form validation shows errors
- Loading states display correctly
Common Issues
"Email not confirmed"
Enable email confirmation in Supabase or disable it for testing:
Terminal
Authentication → Email Templates → Confirm signup → Disable
"OAuth redirect mismatch"
Ensure redirect URLs match exactly:
- Supabase:
https://xxx.supabase.co/auth/v1/callback - Your app:
http://localhost:3000/auth/callback
"Session not persisting"
Check that cookies are being set correctly in the Supabase client.
Extensions
Once working, try adding:
- Magic link sign in
- Phone/SMS authentication
- Multi-factor authentication (MFA)
- Session management (view active sessions)
- Account deletion
- Role-based access control
Summary
You built a complete auth system with:
- Email/password sign up and sign in
- OAuth providers (Google, GitHub)
- Password reset flow
- Protected routes with middleware
- Form validation with Zod
- Loading and error states
Next Steps
Learn debugging techniques for AI-generated code.
Mark this lesson as complete to track your progress