Next.js 보안 가이드: 미들웨어 인증만 믿으면 생기는 함정
Next.js 보안의 핵심인 인증·인가 위치를 정리합니다. 미들웨어(Proxy)만 믿는 패턴, Server Action·Route Handler 검증 누락, NEXT_PUBLIC_ 환경변수 노출을 취약한 코드와 고친 코드로 비교합니다.
Next.js App Router 서비스에서 인증·인가는 데이터에 가장 가까운 서버 코드에서 해야 합니다. 구체적으로는 각 Server Action, 각 Route Handler, 그리고 이들이 공통으로 거치는 데이터 접근 계층(DAL) 안에서 "로그인했는가"와 "이 리소스에 권한이 있는가"를 매번 다시 확인합니다. 미들웨어(Next.js 16부터 이름이 Proxy로 바뀌었습니다)는 로그인 페이지로 돌려보내는 낙관적 확인에만 쓰고, 유일한 방어선으로 두지 않습니다.
자주 보는 실수는 대부분 Next.js 구조를 오해해서 생깁니다. 미들웨어에서 로그인만 확인하고 Server Action에서는 검사를 빼는 경우, NEXT_PUBLIC_ 접두사를 붙인 비밀값이 브라우저 번들에 들어가는 경우, 서버 컴포넌트가 DB 레코드를 통째로 클라이언트 컴포넌트에 넘기는 경우가 대표적입니다. 이 글에서는 각 함정을 취약한 코드와 고친 코드로 나란히 보여주고, 출시 전에 점검할 체크리스트로 마무리합니다.
공식 문서와 OWASP Next.js 치트시트의 권고도 같습니다. Next.js 공식 Proxy 문서는 Proxy가 권한 기반 리다이렉트 같은 낙관적 확인에는 유용하지만 "완전한 세션 관리나 인가 수단으로 쓰면 안 된다"고 적고 있습니다.
미들웨어(Proxy)만으로 인증하면 안 되는 이유
미들웨어 한 곳에서 로그인을 검사하면 코드가 깔끔해 보입니다. 하지만 이 방식에는 구조적인 빈틈이 세 가지 있습니다.
1. matcher 밖의 경로는 검사하지 않습니다. 공식 문서 예제의 matcher를 보면 /((?!api|_next/static|...).*)처럼 api 경로를 제외하는 경우가 많습니다. 이 설정을 그대로 복사하면 /api/* 아래 Route Handler 전체가 미들웨어 검사를 받지 않습니다. 라우트를 새로 만들거나 폴더 구조를 바꾸다가 보호 범위에서 빠지는 일도 흔합니다.
2. 프레임워크 결함으로 우회될 수 있습니다. 2025년 3월 공개된 CVE-2025-29927은 내부용 헤더를 신뢰하는 문제 때문에 미들웨어 실행 자체를 건너뛸 수 있는 취약점이었습니다. Vercel 포스트모템에 따르면 next start로 셀프 호스팅하는 환경이 영향을 받았고, 15.2.3, 14.2.25, 13.5.9, 12.3.5에서 패치되었습니다. 미들웨어에서만 인가하던 서비스는 이 결함 하나로 보호가 사라졌지만, 핸들러 안에서 다시 검증하던 서비스는 피해가 제한적이었습니다.
3. 레이아웃 검사도 비슷한 착각을 낳습니다. 공식 인증 가이드는 레이아웃이 내비게이션 때마다 다시 렌더링되지 않고, 레이아웃에서 return null을 해도 하위 세그먼트와 Server Action 접근을 막지 못한다고 설명합니다.
결론은 간단합니다. 미들웨어는 UX를 위한 1차 필터이고, 실제 인가는 각 진입점 안에서 합니다.
함정 1: Server Action은 공개 POST 엔드포인트입니다
'use server'로 export한 함수는 화면의 버튼을 거치지 않고도 직접 POST 요청으로 호출할 수 있습니다. 공식 Data Security 가이드는 "페이지 수준 인증 확인은 그 안에 정의된 Server Action까지 확장되지 않는다"고 명시합니다. 관리자 페이지에서 비관리자를 리다이렉트했더라도, 그 페이지의 Server Action은 별개의 진입점입니다.
// ❌ 취약: 미들웨어가 /dashboard를 막아주니 괜찮다고 가정
'use server'
import { db } from '@/lib/db'
export async function deletePost(postId: string) {
await db.post.delete({ where: { id: postId } })
}
// ✅ 개선: 액션 안에서 인증 + 소유권(인가) + 입력 검증
'use server'
import { z } from 'zod'
import { verifySession } from '@/lib/dal'
import { db } from '@/lib/db'
const Input = z.string().uuid()
export async function deletePost(rawId: unknown) {
const postId = Input.parse(rawId)
const session = await verifySession() // 로그인 확인
if (!session) throw new Error('Unauthorized')
const post = await db.post.findUnique({ where: { id: postId } })
if (!post || post.authorId !== session.userId) // 내 글인지 확인
throw new Error('Forbidden')
await db.post.delete({ where: { id: postId } })
return { ok: true } // 필요한 값만 반환
}
여기서 놓치기 쉬운 점이 두 가지 있습니다. 첫째, 로그인 확인(인증)만으로는 부족하고 이 리소스가 이 사용자 것인지(인가) 확인해야 합니다. 이 검사가 빠지면 다른 사람의 글 ID만 넣어도 삭제되는 IDOR가 생깁니다. 원리와 방어법은 IDOR 취약점 가이드에 정리해 두었습니다. 둘째, 사용자 ID는 인자로 받지 말고 서버 세션에서 꺼내야 합니다. updateProfile(userId, data)처럼 클라이언트가 userId를 넘기는 설계는 그 값을 바꿔서 보내는 것만으로 다른 사용자 정보를 고칠 수 있습니다.
함정 2: Route Handler를 '내부 API'로 착각하기
app/api/**/route.ts는 URL을 아는 누구나 호출할 수 있습니다. 내 서버 컴포넌트만 호출한다고 해서 내부 API가 되는 것은 아닙니다. 앞서 본 matcher 문제까지 겹치면 아무 검사도 받지 않는 엔드포인트가 됩니다.
// ❌ 취약: 미들웨어 matcher에서 api가 빠져 있는데 검증도 없음
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const orders = await db.order.findMany({
where: { userId: searchParams.get('userId')! },
})
return Response.json(orders)
}
// ✅ 개선: 401/403을 구분하고, 식별자는 세션에서
import { verifySession } from '@/lib/dal'
export async function GET() {
const session = await verifySession()
if (!session) return new Response(null, { status: 401 })
const orders = await db.order.findMany({
where: { userId: session.userId },
select: { id: true, status: true, totalAmount: true },
})
return Response.json(orders)
}
OWASP 치트시트는 Route Handler마다 대상을 공개 / 로그인 사용자 / 서비스 간 호출 / 웹훅으로 분류하라고 권합니다. 웹훅은 세션이 아니라 서명 검증으로 보호해야 하므로 분류가 다르면 방어 방식도 달라집니다. 서버 컴포넌트에서만 쓰는 API라면 HTTP 엔드포인트를 없애고 DAL 함수를 직접 호출하는 편이 공격 표면을 줄이는 가장 확실한 방법입니다.
함정 3: 서버 컴포넌트가 클라이언트로 넘기는 데이터
서버 컴포넌트는 DB에 바로 접근할 수 있어 편리하지만, 그 결과를 'use client' 컴포넌트의 props로 넘기는 순간 데이터가 직렬화되어 브라우저로 전송됩니다. 화면에 이름만 표시하더라도 RSC 페이로드에는 객체 전체가 들어갑니다.
// ❌ 취약: user 레코드 전체(이메일, 전화번호, 내부 플래그 등)가 브라우저로
export default async function Page() {
const user = await db.user.findUnique({ where: { id } })
return <ProfileCard user={user} /> // ProfileCard는 'use client'
}
// ✅ 개선: 화면에 필요한 필드만 담은 DTO를 넘김
import 'server-only'
export async function getProfileDTO(id: string) {
const u = await db.user.findUnique({ where: { id } })
return { name: u.name, avatarUrl: u.avatarUrl }
}
// page.tsx
const profile = await getProfileDTO(id)
return <ProfileCard profile={profile} />
같은 원리가 적용되는 곳이 두 군데 더 있습니다.
- Server Action 반환값: 반환값도 클라이언트로 직렬화됩니다.
return db.user.update(...)처럼 갱신된 레코드를 그대로 돌려주지 말고{ success: true }처럼 필요한 값만 반환합니다. - 컴포넌트 안에 정의한 Server Action의 클로저: 바깥 변수를 캡처하면 그 값이 클라이언트에 갔다가 돌아옵니다. Next.js가 자동으로 암호화하지만 공식 문서는 암호화에만 의존하지 말라고 권고합니다.
보조 장치로 React의 experimental_taintObjectReference, experimental_taintUniqueValue(next.config의 experimental.taint 옵션)를 쓰면 표시해 둔 객체나 값이 클라이언트로 넘어갈 때 오류를 냅니다. 다만 어디까지나 2차 안전망이고, 1차 방어는 DTO로 필드를 거르는 것입니다. 클라이언트에 넘긴 데이터에 개인정보가 섞여 있으면 법적 의무 문제로도 이어지므로 개인정보 안전성 확보조치 개발자 가이드도 함께 확인해 보세요.
함정 4: NEXT_PUBLIC_ 환경변수는 공개값입니다
Next.js는 NEXT_PUBLIC_으로 시작하는 환경변수를 빌드 시점에 클라이언트 JavaScript에 문자열로 삽입합니다. 브라우저 개발자 도구에서 번들을 열어 보면 누구나 볼 수 있습니다. 접두사가 없는 변수만 서버에 남습니다.
# ❌ 위험: 비밀값에 NEXT_PUBLIC_ 접두사
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_xxx
NEXT_PUBLIC_DATABASE_URL=postgres://user:[email protected]/app
NEXT_PUBLIC_ADMIN_API_TOKEN=xxxx
# ✅ 올바름: 비밀값은 접두사 없이, 공개 가능한 값만 NEXT_PUBLIC_
STRIPE_SECRET_KEY=sk_live_xxx
DATABASE_URL=postgres://user:[email protected]/app
NEXT_PUBLIC_SITE_URL=https://example.com
이 실수는 대개 "클라이언트 컴포넌트에서 undefined가 나와서 접두사를 붙였더니 동작했다"는 식으로 생깁니다. 이럴 때는 접두사를 붙일 게 아니라 그 로직을 Server Action이나 Route Handler로 옮겨야 합니다.
판단 기준은 이렇습니다. 그 값이 깃허브 공개 저장소에 올라가도 괜찮은가? 괜찮지 않다면 NEXT_PUBLIC_을 붙이면 안 됩니다. Supabase anon 키처럼 원래 공개를 전제로 한 키도 있지만, 그 경우에는 DB 쪽 정책(RLS)이 실제 방어선이 됩니다. 자세한 내용은 Supabase RLS 보안 가이드를 참고하세요. 이미 비밀값을 공개 변수로 배포했다면 이름만 바꿔서는 소용이 없습니다. 이전 빌드 결과물과 캐시에 남아 있을 수 있으므로 키를 폐기하고 재발급해야 합니다.
권장 구조: DAL 중심으로 인가를 모으기
함정마다 따로 패치하기보다 구조로 막는 편이 오래갑니다. Next.js 공식 문서가 새 프로젝트에 권하는 방식은 데이터 접근 계층(DAL) 입니다.
// lib/dal.ts
import 'server-only' // 클라이언트에서 import하면 빌드 에러
import { cache } from 'react'
import { cookies } from 'next/headers'
import { decrypt } from '@/lib/session'
export const verifySession = cache(async () => {
const token = (await cookies()).get('session')?.value
const session = await decrypt(token)
return session?.userId ? { userId: session.userId, role: session.role } : null
})
운영 원칙은 다음과 같습니다.
- DB 클라이언트와 비밀 환경변수(
process.env)는 DAL 안에서만 import합니다. - Server Action은 입력 검증 후 DAL 함수를 호출하는 얇은 껍데기로 둡니다. 인증과 인가는 DAL에서 수행합니다.
- DAL은 항상 DTO를 반환하고, DB 레코드를 그대로 반환하지 않습니다.
- 미들웨어(Proxy)는 쿠키 존재 여부로 로그인 페이지에 보내는 정도로만 씁니다.
참고로 cache()는 한 번의 렌더링 안에서만 중복 호출을 줄여 줍니다. Server Action과 Route Handler에서는 매번 실행되므로 "한 번 검사했으니 됐다"는 가정은 성립하지 않습니다.
출시 전 점검 체크리스트
- 모든
'use server'함수 첫머리에 세션 확인이 있는가 - 리소스를 수정·삭제할 때 소유자나 테넌트를 비교하는가
- 사용자 ID를 인자가 아니라 세션에서 가져오는가
- Server Action 인자와 Route Handler 입력을 스키마(Zod 등)로 검증하는가
- 모든
route.ts를 공개 / 로그인 / 서비스 / 웹훅으로 분류했는가 - 미들웨어 matcher가
api를 제외해도 핸들러 자체가 안전한가 -
'use client'컴포넌트 props 타입이 DB 모델 전체를 받고 있지 않은가 - Server Action이 DB 레코드를 그대로 반환하지 않는가
-
.env의NEXT_PUBLIC_변수가 모두 공개되어도 괜찮은 값인가 - 빌드 산출물(
.next/static)에서 비밀키 패턴을 검색해 봤는가 - 셀프 호스팅이라면 Next.js가 보안 패치 버전 이상인가
- 화면을 거치지 않고 비로그인 / 타 사용자 / 타 테넌트 권한으로 각 진입점을 직접 호출해 차단되는지 테스트했는가
마지막 항목은 OWASP가 특히 강조하는 부분입니다. UI에서는 버튼이 숨겨져 있어도 요청은 직접 만들 수 있기 때문입니다. 관리자 기능이 있다면 관리자 페이지 보안 체크리스트도 함께 점검하길 권합니다.
자주 묻는 질문
미들웨어(Proxy) 인증 코드는 지워도 되나요?
지울 필요는 없습니다. 비로그인 사용자를 빠르게 로그인 페이지로 보내는 데는 유용합니다. 다만 쿠키만 읽는 가벼운 확인으로 두고, 실제 권한 판단은 각 Server Action·Route Handler·DAL에서 다시 하세요.
Server Action ID가 암호화되어 있으니 외부에서 호출하기 어렵지 않나요?
Next.js는 추측하기 어려운 액션 ID를 만들고 쓰지 않는 액션은 번들에서 제거합니다. 하지만 공식 문서는 이것이 위험을 줄일 뿐이며, Server Action은 직접 POST로 호출될 수 있다고 보고 각 액션 안에서 인증·인가를 검증하라고 안내합니다. 화면에서 쓰는 액션의 ID는 클라이언트에 노출됩니다.
Vercel에 배포하면 이런 문제가 해결되나요?
플랫폼에 따라 특정 프레임워크 결함의 영향이 다를 수는 있습니다. CVE-2025-29927도 Vercel 호스팅은 영향을 받지 않았습니다. 하지만 인가 누락, 과다한 데이터 전달, NEXT_PUBLIC_ 비밀값 노출은 애플리케이션 코드 문제라서 어디에 배포하든 똑같이 생깁니다.
정리
- Next.js 보안의 핵심은 미들웨어가 아니라 각 진입점과 DAL에서의 인가입니다.
- Server Action과 Route Handler는 공개 API로 간주하고 인증, 소유권, 입력을 매번 검증하세요.
- 클라이언트로 가는 데이터는 DTO로 걸러 내고,
NEXT_PUBLIC_에는 공개되어도 괜찮은 값만 넣으세요. - 출시 전에는 화면을 거치지 않고 각 진입점을 직접 호출해 차단되는지 테스트하세요. 직접 점검하기 어렵다면 무료 분석 신청으로 점검을 받아볼 수도 있습니다.
참고 자료
함께 읽으면 좋은 글
AWS 보안 설정 가이드: 초기 스타트업이 하루에 잠글 5가지
AWS 보안 설정을 루트 계정, IAM, S3, 보안 그룹, 감사 로그 순으로 정리했다. 소규모 팀이 하루 안에 점검하고 고칠 수 있는 명령어와 수정 전후 예시, 판단 기준을 담았다.
취약점 점검 리포트 읽는 법: 위험도 해석부터 조치 우선순위까지
취약점 점검 리포트의 위험도를 서비스 맥락으로 해석하고, 조치 순서 결정, 오탐 확인, 재점검 요청, 수정 완료 근거 정리까지 개발팀의 처리 흐름을 안내합니다.
개인정보 유출 사고 사례: 개인정보위 처분에서 반복된 원인 5가지
개인정보 유출 사고 사례를 개인정보위 처분 기준으로 분석해 반복된 원인 5가지를 정리하고, 개발팀이 바로 쓸 코드 예시와 점검 항목으로 바꿔 줍니다.