Files
2025-11-29 18:46:08 +08:00

16 KiB

Authentication Protection Patterns for Next.js

This reference document provides common authentication and authorization patterns for Next.js App Router applications.

Pattern 1: Server Component Auth Check

Basic authentication check in Server Components.

// app/dashboard/page.tsx
import { createServerClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const supabase = createServerClient()
  const { data: { user }, error } = await supabase.auth.getUser()

  if (error || !user) {
    redirect('/login')
  }

  return (
    <div>
      <h1>Welcome, {user.email}</h1>
      {/* Protected content */}
    </div>
  )
}

Pattern 2: API Route Auth Check

Protecting API routes with authentication.

// app/api/data/route.ts
import { createServerClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  const supabase = createServerClient()
  const { data: { user }, error } = await supabase.auth.getUser()

  if (error || !user) {
    return NextResponse.json(
      { error: 'Unauthorized' },
      { status: 401 }
    )
  }

  // Fetch user-specific data
  const { data } = await supabase
    .from('items')
    .select('*')
    .eq('user_id', user.id)

  return NextResponse.json({ data })
}

export async function POST(request: Request) {
  const supabase = createServerClient()
  const { data: { user }, error } = await supabase.auth.getUser()

  if (error || !user) {
    return NextResponse.json(
      { error: 'Unauthorized' },
      { status: 401 }
    )
  }

  const body = await request.json()

  // Insert with user_id
  const { data, error: insertError } = await supabase
    .from('items')
    .insert([{ ...body, user_id: user.id }])
    .select()

  if (insertError) {
    return NextResponse.json(
      { error: insertError.message },
      { status: 400 }
    )
  }

  return NextResponse.json({ data }, { status: 201 })
}

Pattern 3: Server Action Protection

Securing server actions with auth and validation.

// lib/actions/posts.ts
'use server'

import { createServerClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'

const postSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
})

export async function createPost(formData: FormData) {
  // 1. Auth check
  const supabase = createServerClient()
  const { data: { user }, error } = await supabase.auth.getUser()

  if (error || !user) {
    return { error: 'Unauthorized' }
  }

  // 2. Input validation
  const rawData = {
    title: formData.get('title'),
    content: formData.get('content'),
  }

  const validatedData = postSchema.safeParse(rawData)

  if (!validatedData.success) {
    return { error: 'Invalid input', details: validatedData.error }
  }

  // 3. Perform action
  const { data, error: insertError } = await supabase
    .from('posts')
    .insert([{
      ...validatedData.data,
      user_id: user.id,
    }])
    .select()
    .single()

  if (insertError) {
    return { error: insertError.message }
  }

  // 4. Revalidate
  revalidatePath('/posts')

  return { data }
}

export async function updatePost(postId: string, formData: FormData) {
  const supabase = createServerClient()
  const { data: { user }, error } = await supabase.auth.getUser()

  if (error || !user) {
    return { error: 'Unauthorized' }
  }

  // Verify ownership
  const { data: post } = await supabase
    .from('posts')
    .select('user_id')
    .eq('id', postId)
    .single()

  if (!post || post.user_id !== user.id) {
    return { error: 'Forbidden' }
  }

  // Validate and update
  const rawData = {
    title: formData.get('title'),
    content: formData.get('content'),
  }

  const validatedData = postSchema.safeParse(rawData)

  if (!validatedData.success) {
    return { error: 'Invalid input' }
  }

  const { data, error: updateError } = await supabase
    .from('posts')
    .update(validatedData.data)
    .eq('id', postId)
    .select()
    .single()

  if (updateError) {
    return { error: updateError.message }
  }

  revalidatePath('/posts')
  revalidatePath(`/posts/${postId}`)

  return { data }
}

Pattern 4: Role-Based Access Control (RBAC)

Check user roles for access control.

// lib/auth/roles.ts
export type Role = 'user' | 'moderator' | 'admin'

export async function getUserRole(userId: string): Promise<Role | null> {
  const supabase = createServerClient()

  const { data } = await supabase
    .from('profiles')
    .select('role')
    .eq('id', userId)
    .single()

  return data?.role || null
}

export function canAccessRoute(userRole: Role, requiredRoles: Role[]): boolean {
  return requiredRoles.includes(userRole)
}

// Usage in server component
export default async function AdminPage() {
  const supabase = createServerClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) redirect('/login')

  const role = await getUserRole(user.id)

  if (!canAccessRoute(role, ['admin'])) {
    redirect('/unauthorized')
  }

  return <div>Admin Content</div>
}

Pattern 5: Permission-Based Access Control

Fine-grained permission checking.

// lib/auth/permissions.ts
export type Permission =
  | 'posts:read'
  | 'posts:create'
  | 'posts:update'
  | 'posts:delete'
  | 'users:read'
  | 'users:update'
  | 'users:delete'

export async function hasPermission(
  userId: string,
  permission: Permission
): Promise<boolean> {
  const supabase = createServerClient()

  // Check user's role-based permissions
  const { data: role } = await supabase
    .from('profiles')
    .select('role')
    .eq('id', userId)
    .single()

  // Admin has all permissions
  if (role?.role === 'admin') return true

  // Check specific permission
  const { data } = await supabase
    .from('user_permissions')
    .select('permission')
    .eq('user_id', userId)
    .eq('permission', permission)
    .single()

  return !!data
}

// Usage in server action
export async function deleteUser(targetUserId: string) {
  'use server'

  const supabase = createServerClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    return { error: 'Unauthorized' }
  }

  const canDelete = await hasPermission(user.id, 'users:delete')

  if (!canDelete) {
    return { error: 'Insufficient permissions' }
  }

  // Perform deletion
  const { error } = await supabase
    .from('users')
    .delete()
    .eq('id', targetUserId)

  if (error) return { error: error.message }

  revalidatePath('/admin/users')
  return { success: true }
}

Pattern 6: Middleware Route Protection

Protect multiple routes with middleware.

// middleware.ts
import { createServerClient } from '@/lib/supabase/middleware'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const publicRoutes = ['/', '/about', '/login', '/signup']
const protectedRoutes = ['/dashboard', '/profile', '/settings']
const adminRoutes = ['/admin']

export async function middleware(request: NextRequest) {
  const response = NextResponse.next()
  const supabase = createServerClient(request, response)

  const { data: { user } } = await supabase.auth.getUser()
  const pathname = request.nextUrl.pathname

  // Allow public routes
  if (publicRoutes.includes(pathname)) {
    return response
  }

  // Check if route requires auth
  const requiresAuth = protectedRoutes.some(route =>
    pathname.startsWith(route)
  )

  if (requiresAuth && !user) {
    const redirectUrl = new URL('/login', request.url)
    redirectUrl.searchParams.set('redirect', pathname)
    return NextResponse.redirect(redirectUrl)
  }

  // Check admin routes
  const requiresAdmin = adminRoutes.some(route =>
    pathname.startsWith(route)
  )

  if (requiresAdmin) {
    if (!user) {
      return NextResponse.redirect(new URL('/login', request.url))
    }

    const { data: profile } = await supabase
      .from('profiles')
      .select('role')
      .eq('id', user.id)
      .single()

    if (profile?.role !== 'admin') {
      return NextResponse.redirect(new URL('/unauthorized', request.url))
    }
  }

  return response
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

Pattern 7: Layout-Based Protection

Protect all routes within a layout.

// app/(protected)/layout.tsx
import { createServerClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'

export default async function ProtectedLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const supabase = createServerClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    redirect('/login')
  }

  return <>{children}</>
}

// All pages under app/(protected)/ are now protected

Pattern 8: Ownership Verification

Verify user owns a resource before allowing access.

// app/posts/[id]/edit/page.tsx
import { createServerClient } from '@/lib/supabase/server'
import { redirect, notFound } from 'next/navigation'

export default async function EditPostPage({
  params,
}: {
  params: { id: string }
}) {
  const supabase = createServerClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) redirect('/login')

  // Fetch post and verify ownership
  const { data: post, error } = await supabase
    .from('posts')
    .select('*')
    .eq('id', params.id)
    .single()

  if (error || !post) notFound()

  if (post.user_id !== user.id) {
    redirect('/unauthorized')
  }

  return <EditPostForm post={post} />
}

Pattern 9: Multi-Tenant Isolation

Ensure users can only access their tenant's data.

// lib/auth/tenant.ts
export async function requireTenantAccess(tenantId: string) {
  const supabase = createServerClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    redirect('/login')
  }

  // Check tenant membership
  const { data: membership } = await supabase
    .from('tenant_members')
    .select('role')
    .eq('tenant_id', tenantId)
    .eq('user_id', user.id)
    .single()

  if (!membership) {
    redirect('/unauthorized')
  }

  return { user, role: membership.role }
}

// Usage
export default async function TenantPage({
  params,
}: {
  params: { tenantId: string }
}) {
  const { user, role } = await requireTenantAccess(params.tenantId)

  // User has access to this tenant
  return <div>Tenant Content</div>
}

Pattern 10: Helper Functions

Reusable auth utilities.

// lib/auth/helpers.ts
import { createServerClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { cache } from 'react'

// Cache the current user for the request
export const getCurrentUser = cache(async () => {
  const supabase = createServerClient()
  const { data: { user } } = await supabase.auth.getUser()
  return user
})

// Require authentication or redirect
export async function requireAuth() {
  const user = await getCurrentUser()

  if (!user) {
    redirect('/login')
  }

  return user
}

// Require specific role
export async function requireRole(allowedRoles: string[]) {
  const user = await requireAuth()
  const supabase = createServerClient()

  const { data: profile } = await supabase
    .from('profiles')
    .select('role')
    .eq('id', user.id)
    .single()

  if (!profile || !allowedRoles.includes(profile.role)) {
    redirect('/unauthorized')
  }

  return { user, role: profile.role }
}

// Get user with profile
export async function getUserWithProfile() {
  const user = await requireAuth()
  const supabase = createServerClient()

  const { data: profile } = await supabase
    .from('profiles')
    .select('*')
    .eq('id', user.id)
    .single()

  return { user, profile }
}

// Check if user can perform action
export async function canPerformAction(
  action: string,
  resourceId?: string
): Promise<boolean> {
  const user = await getCurrentUser()

  if (!user) return false

  // Check permissions logic
  const supabase = createServerClient()

  const { data } = await supabase
    .rpc('check_user_permission', {
      p_user_id: user.id,
      p_action: action,
      p_resource_id: resourceId,
    })

  return !!data
}

Pattern 11: Error Handling

Proper error handling for auth failures.

// app/api/protected/route.ts
import { createServerClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  try {
    const supabase = createServerClient()
    const { data: { user }, error } = await supabase.auth.getUser()

    if (error) {
      console.error('Auth error:', error)
      return NextResponse.json(
        { error: 'Authentication failed' },
        { status: 401 }
      )
    }

    if (!user) {
      return NextResponse.json(
        { error: 'Unauthorized - Please log in' },
        { status: 401 }
      )
    }

    // Fetch data
    const { data, error: fetchError } = await supabase
      .from('items')
      .select('*')

    if (fetchError) {
      console.error('Database error:', fetchError)
      return NextResponse.json(
        { error: 'Failed to fetch data' },
        { status: 500 }
      )
    }

    return NextResponse.json({ data })
  } catch (error) {
    console.error('Unexpected error:', error)
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

Pattern 12: Rate Limiting with Auth

Combine auth with rate limiting.

// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'),
})

export async function checkRateLimit(identifier: string) {
  const { success, limit, reset, remaining } = await ratelimit.limit(
    identifier
  )

  return {
    success,
    limit,
    reset,
    remaining,
  }
}

// Usage in API route
export async function POST(request: Request) {
  const supabase = createServerClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  // Rate limit by user ID
  const rateLimitResult = await checkRateLimit(user.id)

  if (!rateLimitResult.success) {
    return NextResponse.json(
      { error: 'Rate limit exceeded' },
      {
        status: 429,
        headers: {
          'X-RateLimit-Limit': rateLimitResult.limit.toString(),
          'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
          'X-RateLimit-Reset': rateLimitResult.reset.toString(),
        },
      }
    )
  }

  // Process request
  return NextResponse.json({ success: true })
}

Testing Auth Protection

Always test auth protection:

// tests/auth.test.ts
import { describe, it, expect } from 'vitest'
import { GET } from '@/app/api/protected/route'

describe('Auth Protection', () => {
  it('rejects unauthenticated requests', async () => {
    const request = new Request('http://localhost/api/protected')
    const response = await GET(request)

    expect(response.status).toBe(401)
    const json = await response.json()
    expect(json.error).toBe('Unauthorized')
  })

  it('allows authenticated requests', async () => {
    // Mock authenticated request
    const request = new Request('http://localhost/api/protected', {
      headers: {
        Authorization: 'Bearer valid-token',
      },
    })

    const response = await GET(request)
    expect(response.status).toBe(200)
  })
})

Security Best Practices

  1. Always validate on server: Never trust client-side auth
  2. Check ownership: Verify user owns resource before modification
  3. Use RLS: Combine with Row-Level Security in database
  4. Rate limit: Prevent abuse of authenticated endpoints
  5. Audit log: Log authentication failures and privileged actions
  6. Expire sessions: Implement session timeouts
  7. HTTPS only: Never send auth tokens over HTTP
  8. CSRF protection: Use CSRF tokens for state-changing operations