Skip to content
Published on

Next.js App Router 인증 실전 가이드 — 미들웨어, Server Action, HttpOnly 쿠키, SSR 인증 완전 정복

Authors
  • Name
    Twitter

SSO 쿠키/JWT 인증 시리즈 > React 편 ← 현재: Next.js 편 → 통합 실전편

개요 — Next.js App Router 인증의 특수성

Next.js App Router는 전통적인 React SPA 인증과 근본적으로 다른 패러다임을 요구한다. React 편에서 다룬 클라이언트 중심 인증은 브라우저의 document.cookiefetch 요청에 의존하지만, Next.js에서는 서버와 클라이언트 양쪽에서 인증 상태를 관리해야 한다.

Server Components vs Client Components 인증 차이

Server Components는 서버에서 렌더링되므로 cookies() API를 통해 요청 쿠키에 직접 접근할 수 있다. 반면 Client Components는 브라우저에서 실행되므로 HttpOnly 쿠키에 직접 접근이 불가능하고, API 호출을 통해 인증 상태를 확인해야 한다.

// Server Component — cookies() API로 직접 접근 가능
import { cookies } from 'next/headers'

export default async function DashboardPage() {
  const cookieStore = await cookies()
  const token = cookieStore.get('access_token')?.value

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

  const user = await verifyAndDecodeToken(token)
  return <Dashboard user={user} />
}
// Client Component — API 호출 필요
'use client'

import { useEffect, useState } from 'react'

export function UserProfile() {
  const [user, setUser] = useState(null)

  useEffect(() => {
    fetch('/api/auth/me', { credentials: 'include' })
      .then((res) => res.json())
      .then(setUser)
  }, [])

  if (!user) return <LoginButton />
  return <Profile user={user} />
}

Edge Runtime vs Node.js Runtime

미들웨어는 Edge Runtime에서 실행되므로 Node.js 전용 모듈(jsonwebtoken 등)을 사용할 수 없다. 대신 Web Crypto API 기반의 jose 라이브러리를 사용해야 한다. Route Handler와 Server Action은 기본적으로 Node.js Runtime에서 실행되지만, export const runtime = 'edge'로 Edge에서도 동작시킬 수 있다.

React SPA와 다른 점

항목React SPANext.js App Router
쿠키 접근document.cookie (HttpOnly 불가)cookies() API (HttpOnly 포함)
인증 체크 시점클라이언트 렌더링 후서버 렌더링 시점 (미들웨어/RSC)
리다이렉트클라이언트 라우터서버 사이드 redirect()
토큰 검증백엔드 API 위임미들웨어에서 직접 검증 가능
초기 로딩인증 상태 불확실 (깜빡임)SSR로 확정된 상태 전달

Next.js 인증 아키텍처 전체 흐름

Next.js App Router에서 인증 요청이 처리되는 전체 흐름은 다음과 같다.

┌─────────────────────────────────────────────────────────────────┐
Browser│  ┌──────────────┐    ┌──────────────┐    ┌──────────────────┐   │
│  │ Client Comp  │    │ Form Submit  │    │ fetch(/api/...)  │   │
 (useAuth) (Server Act) │    │ credentials:     │   │
│  │              │    │              │    │  'include'       │   │
│  └──────┬───────┘    └──────┬───────┘    └────────┬─────────┘   │
│         │                   │                      │             │
└─────────┼───────────────────┼──────────────────────┼─────────────┘
          │                   │                      │
          ▼                   ▼                      ▼
┌─────────────────────────────────────────────────────────────────┐
│                    middleware.ts (Edge Runtime)│  ┌──────────────────────────────────────────────────────────┐   │
│  │  1. NextRequest에서 쿠키 읽기                              │   │
│  │  2. jose로 JWT 검증 (Edge 호환)                            │   │
│  │  3. 미인증 → /login 리다이렉트                              │   │
│  │  4. 인증 → 요청 헤더에 사용자 정보 주입 (선택)                │   │
│  └──────────────────────────────────────────────────────────┘   │
│                             │                                    │
└─────────────────────────────┼────────────────────────────────────┘
┌──────────────────┐  ┌──────────────────┐  ┌──────────────────┐
Server Component │  │  Server Action   │  │  Route Handlercookies() 읽기    │  │ cookies() 읽기   │  │ cookies() 설정   │
JWT 파싱          │  │ 쿠키 설정/삭제    │  │ 로그인/로그아웃   │
│ 조건부 렌더링     │  │ revalidate       │  │ 토큰 리프레시     │
└──────────────────┘  └──────────────────┘  └──────────────────┘
                   ┌──────────────────┐
Backend API                     (인증 서버)JWT 발급/검증     │
                   └──────────────────┘

핵심 원칙은 다음과 같다.

  1. 미들웨어가 모든 요청의 첫 번째 관문 역할을 하며 인증 상태를 확인한다.
  2. Route Handler가 쿠키 설정/삭제를 담당한다 (로그인, 로그아웃, 리프레시).
  3. Server Component는 쿠키에서 토큰을 읽어 사용자 정보를 렌더링한다.
  4. Server Action은 폼 기반 인증과 서버 사이드 로직을 처리한다.
  5. Client Component는 서버에서 전달된 인증 상태를 받거나, API로 갱신한다.

미들웨어 기반 인증 (middleware.ts)

미들웨어는 모든 요청이 서버에 도달하기 전에 실행되는 Edge Function이다. 인증이 필요한 경로에 대해 토큰을 검증하고, 미인증 요청을 리다이렉트하는 역할을 한다.

// middleware.ts (프로젝트 루트)
import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify, type JWTPayload } from 'jose'

interface AuthPayload extends JWTPayload {
  sub: string
  roles: string[]
  email: string
}

const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)

// 인증이 필요한 경로 패턴
const protectedPaths = ['/dashboard', '/settings', '/api/protected']

// 인증 없이 접근 가능한 경로
const publicPaths = ['/login', '/register', '/api/auth']

function isProtectedPath(pathname: string): boolean {
  return protectedPaths.some((path) => pathname.startsWith(path))
}

function isPublicPath(pathname: string): boolean {
  return publicPaths.some((path) => pathname.startsWith(path))
}

async function verifyToken(token: string): Promise<AuthPayload | null> {
  try {
    const { payload } = await jwtVerify(token, JWT_SECRET, {
      algorithms: ['HS256'],
      clockTolerance: 15, // 15초 시계 허용 오차
    })
    return payload as AuthPayload
  } catch (error) {
    return null
  }
}

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // 공개 경로는 통과
  if (isPublicPath(pathname)) {
    return NextResponse.next()
  }

  // 보호된 경로가 아니면 통과
  if (!isProtectedPath(pathname)) {
    return NextResponse.next()
  }

  const token = request.cookies.get('access_token')?.value

  if (!token) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('callbackUrl', pathname)
    return NextResponse.redirect(loginUrl)
  }

  const payload = await verifyToken(token)

  if (!payload) {
    // 토큰이 유효하지 않으면 리프레시 시도
    const refreshToken = request.cookies.get('refresh_token')?.value
    if (refreshToken) {
      // 리프레시는 Route Handler에서 처리하도록 리다이렉트
      const refreshUrl = new URL('/api/auth/refresh', request.url)
      refreshUrl.searchParams.set('callbackUrl', pathname)
      return NextResponse.redirect(refreshUrl)
    }

    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('callbackUrl', pathname)
    return NextResponse.redirect(loginUrl)
  }

  // 인증된 요청: 사용자 정보를 요청 헤더에 주입 (선택적)
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-user-id', payload.sub)
  requestHeaders.set('x-user-roles', JSON.stringify(payload.roles))

  return NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  })
}

export const config = {
  matcher: [
    /*
     * 정적 파일과 이미지를 제외한 모든 요청 경로에 매칭
     * _next/static, _next/image, favicon.ico 제외
     */
    '/((?!_next/static|_next/image|favicon.ico|public).*)',
  ],
}

주의사항: 미들웨어에서는 데이터베이스 호출이나 무거운 연산을 수행해서는 안 된다. Edge Runtime은 경량 실행 환경으로, 빠른 응답이 필수적이다. 토큰 서명 검증(jose의 jwtVerify)은 충분히 빠르지만, 토큰 블랙리스트 DB 조회 같은 작업은 Route Handler로 위임하는 것이 좋다.

Route Handler에서 인증

Route Handler는 쿠키를 설정하고 삭제하는 핵심 계층이다. 로그인, 로그아웃, 토큰 리프레시, 현재 사용자 정보 조회를 구현한다.

로그인 (app/api/auth/login/route.ts)

// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { SignJWT } from 'jose'

const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
const IS_PRODUCTION = process.env.NODE_ENV === 'production'

interface LoginRequest {
  email: string
  password: string
}

interface BackendAuthResponse {
  user: {
    id: string
    email: string
    name: string
    roles: string[]
  }
  accessToken: string
  refreshToken: string
}

export async function POST(request: NextRequest) {
  try {
    const body: LoginRequest = await request.json()

    // 백엔드 인증 서버에 로그인 요청
    const backendResponse = await fetch(`${process.env.BACKEND_URL}/auth/login`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    })

    if (!backendResponse.ok) {
      const error = await backendResponse.json()
      return NextResponse.json(
        { error: error.message || '로그인에 실패했습니다.' },
        { status: 401 }
      )
    }

    const data: BackendAuthResponse = await backendResponse.json()

    // Next.js에서 자체 JWT를 발급하거나, 백엔드 토큰을 그대로 사용
    const response = NextResponse.json({
      user: {
        id: data.user.id,
        email: data.user.email,
        name: data.user.name,
        roles: data.user.roles,
      },
    })

    // Access Token 쿠키 설정
    response.cookies.set('access_token', data.accessToken, {
      httpOnly: true,
      secure: IS_PRODUCTION,
      sameSite: 'lax',
      path: '/',
      maxAge: 60 * 15, // 15분
      ...(IS_PRODUCTION && { domain: '.example.com' }),
    })

    // Refresh Token 쿠키 설정
    response.cookies.set('refresh_token', data.refreshToken, {
      httpOnly: true,
      secure: IS_PRODUCTION,
      sameSite: 'lax',
      path: '/api/auth/refresh', // 리프레시 경로에서만 전송
      maxAge: 60 * 60 * 24 * 7, // 7일
      ...(IS_PRODUCTION && { domain: '.example.com' }),
    })

    return response
  } catch (error) {
    console.error('Login error:', error)
    return NextResponse.json({ error: '서버 내부 오류가 발생했습니다.' }, { status: 500 })
  }
}

로그아웃 (app/api/auth/logout/route.ts)

// app/api/auth/logout/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'

export async function POST(request: NextRequest) {
  try {
    const cookieStore = await cookies()
    const accessToken = cookieStore.get('access_token')?.value

    // 백엔드에 토큰 무효화 요청 (선택적: 서버 사이드 블랙리스트)
    if (accessToken) {
      await fetch(`${process.env.BACKEND_URL}/auth/logout`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${accessToken}`,
        },
      }).catch(() => {
        // 백엔드 호출 실패해도 쿠키는 삭제
      })
    }

    const response = NextResponse.json({ success: true })

    // 쿠키 삭제 — maxAge: 0 으로 즉시 만료
    response.cookies.set('access_token', '', {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      path: '/',
      maxAge: 0,
    })

    response.cookies.set('refresh_token', '', {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      path: '/api/auth/refresh',
      maxAge: 0,
    })

    return response
  } catch (error) {
    return NextResponse.json({ error: '로그아웃 처리 중 오류가 발생했습니다.' }, { status: 500 })
  }
}

토큰 리프레시 (app/api/auth/refresh/route.ts)

// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  const refreshToken = request.cookies.get('refresh_token')?.value

  if (!refreshToken) {
    return NextResponse.json({ error: 'Refresh token이 없습니다.' }, { status: 401 })
  }

  try {
    const backendResponse = await fetch(`${process.env.BACKEND_URL}/auth/refresh`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken }),
    })

    if (!backendResponse.ok) {
      // 리프레시 실패 → 모든 토큰 쿠키 삭제
      const response = NextResponse.json(
        { error: '세션이 만료되었습니다. 다시 로그인해주세요.' },
        { status: 401 }
      )
      response.cookies.set('access_token', '', { path: '/', maxAge: 0 })
      response.cookies.set('refresh_token', '', { path: '/api/auth/refresh', maxAge: 0 })
      return response
    }

    const data = await backendResponse.json()
    const IS_PRODUCTION = process.env.NODE_ENV === 'production'

    const response = NextResponse.json({ success: true })

    response.cookies.set('access_token', data.accessToken, {
      httpOnly: true,
      secure: IS_PRODUCTION,
      sameSite: 'lax',
      path: '/',
      maxAge: 60 * 15,
      ...(IS_PRODUCTION && { domain: '.example.com' }),
    })

    // Refresh Token Rotation 적용 시
    if (data.refreshToken) {
      response.cookies.set('refresh_token', data.refreshToken, {
        httpOnly: true,
        secure: IS_PRODUCTION,
        sameSite: 'lax',
        path: '/api/auth/refresh',
        maxAge: 60 * 60 * 24 * 7,
        ...(IS_PRODUCTION && { domain: '.example.com' }),
      })
    }

    return response
  } catch (error) {
    return NextResponse.json({ error: '토큰 갱신 중 오류가 발생했습니다.' }, { status: 500 })
  }
}

현재 사용자 정보 (app/api/auth/me/route.ts)

// app/api/auth/me/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify } from 'jose'

const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)

export async function GET(request: NextRequest) {
  const token = request.cookies.get('access_token')?.value

  if (!token) {
    return NextResponse.json({ user: null }, { status: 401 })
  }

  try {
    const { payload } = await jwtVerify(token, JWT_SECRET)

    return NextResponse.json({
      user: {
        id: payload.sub,
        email: payload.email,
        name: payload.name,
        roles: payload.roles,
      },
    })
  } catch (error) {
    return NextResponse.json({ user: null }, { status: 401 })
  }
}

Server Action에서 인증

Server Action은 'use server' 지시어가 붙은 비동기 함수로, 폼 제출과 서버 사이드 데이터 변경을 처리한다. cookies() API에 직접 접근할 수 있어 인증 로직을 서버에서 안전하게 처리할 수 있다.

// app/actions/auth.ts
'use server'

import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

interface LoginFormState {
  error?: string
  success?: boolean
}

export async function loginAction(
  prevState: LoginFormState,
  formData: FormData
): Promise<LoginFormState> {
  const email = formData.get('email') as string
  const password = formData.get('password') as string

  if (!email || !password) {
    return { error: '이메일과 비밀번호를 입력해주세요.' }
  }

  try {
    const response = await fetch(`${process.env.BACKEND_URL}/auth/login`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    })

    if (!response.ok) {
      const data = await response.json()
      return { error: data.message || '이메일 또는 비밀번호가 올바르지 않습니다.' }
    }

    const data = await response.json()
    const cookieStore = await cookies()
    const IS_PRODUCTION = process.env.NODE_ENV === 'production'

    cookieStore.set('access_token', data.accessToken, {
      httpOnly: true,
      secure: IS_PRODUCTION,
      sameSite: 'lax',
      path: '/',
      maxAge: 60 * 15,
    })

    cookieStore.set('refresh_token', data.refreshToken, {
      httpOnly: true,
      secure: IS_PRODUCTION,
      sameSite: 'lax',
      path: '/api/auth/refresh',
      maxAge: 60 * 60 * 24 * 7,
    })
  } catch (error) {
    return { error: '서버에 연결할 수 없습니다.' }
  }

  revalidatePath('/')
  redirect('/dashboard')
}

export async function logoutAction(): Promise<void> {
  const cookieStore = await cookies()
  const accessToken = cookieStore.get('access_token')?.value

  // 백엔드에 토큰 무효화 요청
  if (accessToken) {
    await fetch(`${process.env.BACKEND_URL}/auth/logout`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${accessToken}` },
    }).catch(() => {})
  }

  cookieStore.delete('access_token')
  cookieStore.delete('refresh_token')

  revalidatePath('/')
  redirect('/login')
}
// app/login/page.tsx — Server Action을 사용하는 로그인 폼
'use client'

import { useActionState } from 'react'
import { loginAction } from '@/app/actions/auth'

export default function LoginPage() {
  const [state, formAction, isPending] = useActionState(loginAction, {})

  return (
    <form action={formAction}>
      {state.error && <div className="rounded-md bg-red-50 p-3 text-red-600">{state.error}</div>}
      <div>
        <label htmlFor="email">이메일</label>
        <input id="email" name="email" type="email" required autoComplete="email" />
      </div>
      <div>
        <label htmlFor="password">비밀번호</label>
        <input
          id="password"
          name="password"
          type="password"
          required
          autoComplete="current-password"
        />
      </div>
      <button type="submit" disabled={isPending}>
        {isPending ? '로그인 중...' : '로그인'}
      </button>
    </form>
  )
}

Server Component에서 인증 상태 접근

Server Component는 cookies() API를 통해 HttpOnly 쿠키를 직접 읽을 수 있으므로, 추가 API 호출 없이 인증 상태를 확인할 수 있다.

// lib/auth.ts — 서버 사이드 인증 유틸리티
import { cookies } from 'next/headers'
import { jwtVerify, type JWTPayload } from 'jose'
import { cache } from 'react'

interface User {
  id: string
  email: string
  name: string
  roles: string[]
}

interface AuthPayload extends JWTPayload {
  sub: string
  email: string
  name: string
  roles: string[]
}

const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)

// React cache로 같은 요청 내 중복 검증 방지
export const getAuthUser = cache(async (): Promise<User | null> => {
  const cookieStore = await cookies()
  const token = cookieStore.get('access_token')?.value

  if (!token) return null

  try {
    const { payload } = (await jwtVerify(token, JWT_SECRET)) as { payload: AuthPayload }

    return {
      id: payload.sub,
      email: payload.email,
      name: payload.name,
      roles: payload.roles,
    }
  } catch (error) {
    return null
  }
})

export async function requireAuth(): Promise<User> {
  const user = await getAuthUser()
  if (!user) {
    const { redirect } = await import('next/navigation')
    redirect('/login')
  }
  return user
}
// app/dashboard/page.tsx — 인증된 Server Component
import { requireAuth } from '@/lib/auth'
import { LogoutButton } from '@/components/LogoutButton'

export default async function DashboardPage() {
  const user = await requireAuth()

  return (
    <div>
      <header>
        <h1>대시보드</h1>
        <p>{user.name}님, 환영합니다.</p>
        <span className="text-sm text-gray-500">{user.email}</span>
        <LogoutButton />
      </header>

      {user.roles.includes('admin') && (
        <section>
          <h2>관리자 메뉴</h2>
          {/* 관리자 전용 콘텐츠 */}
        </section>
      )}

      <section>
        <h2>내 정보</h2>
        <p>역할: {user.roles.join(', ')}</p>
      </section>
    </div>
  )
}

Client Component에서 인증 상태 관리

Client Component에서는 서버에서 전달된 인증 정보를 Context로 관리하거나, API 폴링으로 최신 상태를 유지한다.

// contexts/AuthContext.tsx
'use client'

import { createContext, useContext, useCallback, useMemo, type ReactNode } from 'react'
import useSWR from 'swr'

interface User {
  id: string
  email: string
  name: string
  roles: string[]
}

interface AuthContextType {
  user: User | null
  isLoading: boolean
  isAuthenticated: boolean
  login: (email: string, password: string) => Promise<void>
  logout: () => Promise<void>
  refresh: () => Promise<void>
}

const AuthContext = createContext<AuthContextType | undefined>(undefined)

const fetcher = (url: string) =>
  fetch(url, { credentials: 'include' }).then((res) => {
    if (!res.ok) return { user: null }
    return res.json()
  })

export function AuthProvider({
  children,
  initialUser,
}: {
  children: ReactNode
  initialUser: User | null
}) {
  const { data, mutate, isLoading } = useSWR('/api/auth/me', fetcher, {
    fallbackData: { user: initialUser },
    revalidateOnFocus: true,
    revalidateInterval: 5 * 60 * 1000, // 5분마다 갱신
    dedupingInterval: 60 * 1000,
  })

  const user = data?.user ?? null

  const login = useCallback(
    async (email: string, password: string) => {
      const res = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: JSON.stringify({ email, password }),
      })

      if (!res.ok) {
        const error = await res.json()
        throw new Error(error.error || '로그인에 실패했습니다.')
      }

      await mutate()
    },
    [mutate]
  )

  const logout = useCallback(async () => {
    await fetch('/api/auth/logout', {
      method: 'POST',
      credentials: 'include',
    })
    await mutate({ user: null }, { revalidate: false })
  }, [mutate])

  const refresh = useCallback(async () => {
    await mutate()
  }, [mutate])

  const value = useMemo(
    () => ({
      user,
      isLoading,
      isAuthenticated: !!user,
      login,
      logout,
      refresh,
    }),
    [user, isLoading, login, logout, refresh]
  )

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

export function useAuth(): AuthContextType {
  const context = useContext(AuthContext)
  if (context === undefined) {
    throw new Error('useAuth는 AuthProvider 내부에서만 사용할 수 있습니다.')
  }
  return context
}
// app/layout.tsx — Server Component에서 Client Context로 초기값 전달
import { getAuthUser } from '@/lib/auth'
import { AuthProvider } from '@/contexts/AuthContext'

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const user = await getAuthUser()

  return (
    <html lang="ko">
      <body>
        <AuthProvider initialUser={user}>{children}</AuthProvider>
      </body>
    </html>
  )
}

브라우저 저장소별 접근 가능성 표

저장소JS 접근서버 자동 전송XSS 취약CSRF 취약Next.js 서버 접근
localStorageOXOXX
sessionStorageOXOXX
일반 CookieOOOOO
HttpOnly CookieXOXO (SameSite로 방어)O (cookies() API)
Authorization HeaderO (코드 제어)X (수동)O (저장소 의존)XX (직접 불가)

Next.js에서 HttpOnly Cookie가 권장되는 이유:

  1. 서버 사이드 접근 가능: cookies() API로 Server Component, Server Action, Route Handler, 미들웨어 모든 곳에서 접근 가능하다.
  2. XSS 방어: JavaScript로 접근 불가하므로 토큰 탈취 위험이 제거된다.
  3. 자동 전송: 브라우저가 매 요청마다 자동으로 쿠키를 포함하므로 별도의 인터셉터가 불필요하다.
  4. CSRF 방어: SameSite=Lax 또는 Strict 설정으로 크로스 사이트 요청을 차단한다.
  5. SSR 호환: 첫 번째 서버 렌더링 시점에 이미 인증 상태가 확정되어 깜빡임(flash)이 없다.

쿠키 설정 실전

개발환경 vs 프로덕션 쿠키 설정

// lib/cookie-config.ts
export interface CookieConfig {
  httpOnly: boolean
  secure: boolean
  sameSite: 'strict' | 'lax' | 'none'
  path: string
  maxAge: number
  domain?: string
}

const IS_PRODUCTION = process.env.NODE_ENV === 'production'
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN // .example.com

export const ACCESS_TOKEN_COOKIE: CookieConfig = {
  httpOnly: true,
  secure: IS_PRODUCTION, // 개발: false (HTTP), 프로덕션: true (HTTPS)
  sameSite: IS_PRODUCTION ? 'lax' : 'lax',
  path: '/', // 모든 경로에서 전송
  maxAge: 60 * 15, // 15분
  ...(IS_PRODUCTION && COOKIE_DOMAIN && { domain: COOKIE_DOMAIN }),
}

export const REFRESH_TOKEN_COOKIE: CookieConfig = {
  httpOnly: true,
  secure: IS_PRODUCTION,
  sameSite: IS_PRODUCTION ? 'strict' : 'lax',
  path: '/api/auth/refresh', // 리프레시 엔드포인트에서만 전송
  maxAge: 60 * 60 * 24 * 7, // 7일
  ...(IS_PRODUCTION && COOKIE_DOMAIN && { domain: COOKIE_DOMAIN }),
}

// 사용 예시
// response.cookies.set('access_token', token, ACCESS_TOKEN_COOKIE)

각 옵션의 의미를 정리한다.

옵션설명권장값
httpOnlyJS 접근 차단true (항상)
secureHTTPS에서만 전송프로덕션: true, 개발: false
sameSite크로스 사이트 요청 제어lax (일반), strict (리프레시)
path쿠키 전송 경로 제한Access: /, Refresh: /api/auth/refresh
maxAge쿠키 유효 기간 (초)Access: 900, Refresh: 604800
domain쿠키 유효 도메인.example.com (서브도메인 공유 시)

JWT 클레임 파싱

jose 라이브러리는 Edge Runtime과 Node.js 양쪽에서 동작하며, JWT 검증과 클레임 추출을 수행한다.

// lib/jwt.ts
import { jwtVerify, SignJWT, type JWTPayload } from 'jose'

const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)

export interface TokenClaims extends JWTPayload {
  sub: string // 사용자 ID
  email: string // 이메일
  name: string // 이름
  roles: string[] // 역할 목록
  iat: number // 발급 시각
  exp: number // 만료 시각
  iss: string // 발급자
  jti: string // 토큰 고유 ID (블랙리스트용)
}

export async function verifyAccessToken(token: string): Promise<TokenClaims> {
  const { payload } = await jwtVerify(token, JWT_SECRET, {
    algorithms: ['HS256'],
    issuer: 'https://auth.example.com',
    clockTolerance: 15,
  })

  // 필수 클레임 존재 확인
  if (!payload.sub || !payload.email) {
    throw new Error('필수 클레임이 누락되었습니다.')
  }

  return payload as TokenClaims
}

export async function createAccessToken(user: {
  id: string
  email: string
  name: string
  roles: string[]
}): Promise<string> {
  return new SignJWT({
    email: user.email,
    name: user.name,
    roles: user.roles,
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setSubject(user.id)
    .setIssuedAt()
    .setExpirationTime('15m')
    .setIssuer('https://auth.example.com')
    .setJti(crypto.randomUUID())
    .sign(JWT_SECRET)
}

// 프론트엔드에 전달할 필드만 선별
export function sanitizeUserForClient(claims: TokenClaims) {
  // 민감 정보(jti, iat, exp, iss)는 제외
  return {
    id: claims.sub,
    email: claims.email,
    name: claims.name,
    roles: claims.roles,
  }
}

프론트엔드 전달 원칙: JWT에 포함된 모든 클레임을 클라이언트에 전달해서는 안 된다. sub, email, name, roles 같은 표시용 필드만 선별하고, jti, iss, exp 같은 내부 필드는 서버에서만 사용한다.

CORS 설정

next.config.js에서 전역 CORS 헤더

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        // API 경로에 대한 CORS 설정
        source: '/api/:path*',
        headers: [
          {
            key: 'Access-Control-Allow-Origin',
            value: process.env.ALLOWED_ORIGIN || 'https://app.example.com',
          },
          {
            key: 'Access-Control-Allow-Methods',
            value: 'GET, POST, PUT, DELETE, OPTIONS',
          },
          {
            key: 'Access-Control-Allow-Headers',
            value: 'Content-Type, Authorization',
          },
          {
            key: 'Access-Control-Allow-Credentials',
            value: 'true',
          },
          {
            key: 'Access-Control-Max-Age',
            value: '86400',
          },
        ],
      },
    ]
  },
}

module.exports = nextConfig

Route Handler에서 CORS 처리

// app/api/auth/login/route.ts (CORS 프리플라이트 대응)
import { NextRequest, NextResponse } from 'next/server'

const ALLOWED_ORIGINS = ['https://app.example.com', 'https://admin.example.com']

function getCorsHeaders(origin: string | null) {
  const isAllowed = origin && ALLOWED_ORIGINS.includes(origin)
  return {
    'Access-Control-Allow-Origin': isAllowed ? origin : '',
    'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type',
    'Access-Control-Allow-Credentials': 'true',
  }
}

export async function OPTIONS(request: NextRequest) {
  const origin = request.headers.get('origin')
  return new NextResponse(null, {
    status: 204,
    headers: getCorsHeaders(origin),
  })
}

export async function POST(request: NextRequest) {
  const origin = request.headers.get('origin')
  // ... 로그인 로직 ...
  const response = NextResponse.json({ success: true })
  Object.entries(getCorsHeaders(origin)).forEach(([key, value]) => {
    response.headers.set(key, value)
  })
  return response
}

미들웨어에서 CORS 처리

동일한 CORS 로직을 미들웨어에서 중앙 집중적으로 처리할 수도 있다. 이 경우 각 Route Handler에서 CORS 코드를 반복할 필요가 없다.

// middleware.ts 내 CORS 처리 로직 (발췌)
if (request.method === 'OPTIONS') {
  const origin = request.headers.get('origin')
  if (origin && ALLOWED_ORIGINS.includes(origin)) {
    return new NextResponse(null, {
      status: 204,
      headers: {
        'Access-Control-Allow-Origin': origin,
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        'Access-Control-Allow-Credentials': 'true',
        'Access-Control-Max-Age': '86400',
      },
    })
  }
}

로그아웃 및 토큰 무효화

서버 사이드 블랙리스트 + 쿠키 삭제

단순히 쿠키를 삭제하는 것만으로는 이미 탈취된 토큰을 무효화할 수 없다. 완전한 로그아웃을 위해서는 서버 사이드 토큰 블랙리스트가 필요하다.

// lib/token-blacklist.ts
// Redis를 사용한 토큰 블랙리스트 예시
import { Redis } from 'ioredis'

const redis = new Redis(process.env.REDIS_URL!)

export async function blacklistToken(jti: string, expiresAt: number): Promise<void> {
  const ttl = expiresAt - Math.floor(Date.now() / 1000)
  if (ttl > 0) {
    await redis.setex(`blacklist:${jti}`, ttl, '1')
  }
}

export async function isTokenBlacklisted(jti: string): Promise<boolean> {
  const result = await redis.get(`blacklist:${jti}`)
  return result === '1'
}

Server Action 기반 로그아웃

// app/actions/auth.ts (logoutAction — 블랙리스트 포함)
'use server'

import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
import { verifyAccessToken } from '@/lib/jwt'
import { blacklistToken } from '@/lib/token-blacklist'

export async function logoutAction(): Promise<void> {
  const cookieStore = await cookies()
  const token = cookieStore.get('access_token')?.value

  if (token) {
    try {
      const claims = await verifyAccessToken(token)
      // jti와 exp를 사용하여 블랙리스트에 추가
      await blacklistToken(claims.jti, claims.exp!)
    } catch {
      // 토큰이 이미 만료된 경우 무시
    }
  }

  cookieStore.delete('access_token')
  cookieStore.delete('refresh_token')

  revalidatePath('/', 'layout')
  redirect('/login')
}
// components/LogoutButton.tsx
'use client'

import { logoutAction } from '@/app/actions/auth'

export function LogoutButton() {
  return (
    <form action={logoutAction}>
      <button type="submit">로그아웃</button>
    </form>
  )
}

NextAuth.js / Auth.js 비교

직접 구현과 NextAuth.js(Auth.js) 사용의 트레이드오프를 비교한다.

항목직접 구현NextAuth.js / Auth.js
유연성완전한 제어프레임워크 규약에 의존
구현 비용높음 (보안 전문성 필요)낮음 (빠른 시작)
OAuth 통합직접 구현40+ 프로바이더 내장
세션 관리수동 (JWT/DB)자동 (JWT/DB 선택)
토큰 리프레시직접 구현내장 (OAuth 한정)
SSO 연동완전 커스텀 가능제한적
학습 곡선인증 기본기 필요NextAuth API 학습

직접 구현이 적합한 경우:

  • 기존 인증 서버(SSO)와 통합해야 할 때
  • 세밀한 토큰 관리 정책이 필요할 때
  • 다중 도메인/서비스 간 쿠키 공유가 필요할 때
  • 인증 플로우를 완전히 제어해야 할 때

NextAuth.js가 적합한 경우:

  • Google, GitHub 등 소셜 로그인만 필요할 때
  • 빠른 MVP 개발이 목표일 때
  • 인증 보안에 대한 전문성이 부족할 때

보안 트레이드오프

XSS (Cross-Site Scripting)

HttpOnly 쿠키를 사용하면 document.cookie로 토큰을 탈취할 수 없다. 그러나 XSS 공격자가 fetch('/api/auth/me')를 호출하여 사용자 정보를 가져가거나, 인증된 API를 대신 호출할 수 있다. Server Component에서 렌더링된 콘텐츠는 클라이언트 JavaScript가 개입하지 않으므로 XSS에 더 강하다.

CSRF (Cross-Site Request Forgery)

SameSite=Lax 쿠키는 크로스 사이트 POST 요청에 쿠키를 포함하지 않으므로, 대부분의 CSRF 공격을 차단한다. Server Action은 Next.js가 자동으로 CSRF 토큰을 관리하므로 별도 처리가 불필요하다.

Token Theft & Replay

Access Token의 짧은 만료 시간(15분)과 Refresh Token Rotation을 결합하면 토큰 탈취 피해를 최소화할 수 있다. 블랙리스트 메커니즘을 추가하면 탈취된 토큰을 즉시 무효화할 수 있다.

Server Component의 보안 이점

  • Server Component는 서버에서만 실행되므로, 인증 로직과 비밀 키가 클라이언트에 노출되지 않는다.
  • cookies() API는 서버에서만 호출 가능하므로, 클라이언트 조작이 불가능하다.
  • 데이터 페칭 시 서버에서 직접 백엔드를 호출하므로, 토큰이 브라우저를 경유하지 않는다.

체크리스트

Next.js 인증 구현 시 확인해야 할 항목을 정리한다.

  • httpOnly: true로 모든 인증 쿠키 설정
  • secure: true 프로덕션 환경 적용
  • sameSite: 'lax' 이상 설정 (CSRF 방어)
  • Access Token 만료 시간 15분 이하
  • Refresh Token 별도 경로(/api/auth/refresh)로 path 제한
  • Refresh Token Rotation 구현
  • 미들웨어에서 보호 경로 인증 체크
  • Server Component에서 React.cache로 중복 검증 방지
  • 로그아웃 시 서버 사이드 토큰 블랙리스트 적용
  • Edge Runtime 호환 라이브러리 사용 (jose)
  • CORS credentials: true 설정 (크로스 도메인 시)
  • 환경별 쿠키 설정 분리 (개발/프로덕션)
  • JWT 클레임 중 프론트엔드 전달 필드 최소화
  • 에러 메시지에 내부 정보 노출 방지
  • callbackUrl 검증 (오픈 리다이렉트 방지)

흔한 버그와 오해

1. "cookies()는 어디서든 호출 가능하다"

cookies()는 Server Component, Server Action, Route Handler에서만 호출 가능하다. Client Component('use client')에서 호출하면 빌드 에러가 발생한다.

2. "미들웨어에서 DB를 조회해도 된다"

미들웨어는 Edge Runtime에서 실행되며, 모든 요청에 대해 실행된다. DB 호출은 응답 지연의 주요 원인이 된다. 토큰 서명 검증만 수행하고, 상세 권한 검사는 Route Handler나 Server Component에서 수행해야 한다.

3. "cookies().set()은 Server Component에서 호출 가능하다"

cookies().get()은 Server Component에서 호출 가능하지만, cookies().set()cookies().delete()Server Action 또는 Route Handler에서만 호출 가능하다. Server Component의 렌더링 단계에서는 응답 헤더를 수정할 수 없기 때문이다.

4. "Edge Runtime에서 jsonwebtoken 라이브러리를 사용할 수 있다"

jsonwebtoken은 Node.js의 crypto 모듈에 의존하므로 Edge Runtime에서 동작하지 않는다. 미들웨어에서는 반드시 jose 라이브러리를 사용해야 한다.

5. "SameSite=Strict가 가장 안전하니까 항상 Strict를 써야 한다"

SameSite=Strict는 외부 사이트에서 링크를 클릭하여 방문할 때 쿠키를 전송하지 않는다. 소셜 미디어나 이메일 링크를 통해 접속하는 사용자가 매번 재로그인해야 하는 UX 문제가 생긴다. Access Token에는 Lax, Refresh Token에는 Strict를 사용하는 것이 균형 잡힌 전략이다.

6. "Refresh Token도 같은 path에 설정하면 된다"

Refresh Token의 path/로 설정하면 모든 요청에 불필요하게 Refresh Token이 전송된다. 네트워크 대역폭 낭비 외에도, 공격 표면이 넓어진다. /api/auth/refresh로 경로를 제한하여 필요한 요청에서만 전송되도록 한다.

7. "revalidatePath 없이 redirect만 하면 된다"

Server Action에서 쿠키를 변경한 후 redirect()만 호출하면, 캐시된 페이지가 이전 인증 상태를 표시할 수 있다. revalidatePath('/', 'layout')를 호출하여 레이아웃 포함 전체 경로의 캐시를 무효화해야 한다.

참고자료

  1. Next.js Authentication 공식 가이드 — App Router 인증 패턴 공식 문서
  2. Next.js Middleware 문서 — 미들웨어 설정 및 사용법
  3. Next.js cookies() API — 서버 사이드 쿠키 접근 API
  4. Next.js Server Actions — Server Action을 활용한 데이터 변경
  5. jose 라이브러리 GitHub — Edge Runtime 호환 JWT 라이브러리
  6. RFC 7519 - JSON Web Token — JWT 표준 사양
  7. RFC 6265 - HTTP State Management (Cookies) — 쿠키 표준 사양
  8. OWASP Session Management Cheat Sheet — 세션 관리 보안 가이드라인
  9. NextAuth.js (Auth.js) 공식 문서 — Next.js 인증 라이브러리
  10. Vercel Blog - Understanding Next.js Middleware — 미들웨어 아키텍처 설명
  11. MDN - SameSite cookies — SameSite 쿠키 속성 설명
  12. OWASP Cross-Site Request Forgery Prevention — CSRF 방어 가이드