Next.js 16 인증이 견고하다고 생각했지만, 어느 오후에 반증을 받았다.
출처: Dev.to
몇 달 전, 클라이언트 프로젝트를 최종 사전 릴리스 검토하면서 인수인계를 앞두고 있었습니다.
보호된 라우트는 제대로 보호됐고, 인증되지 않은 사용자는 리다이렉트되었습니다. 토큰도 올바르게 만료되었습니다. 세 가지 다른 방법으로 테스트했으며 모든 것이 정상적으로 동작했습니다. 그래서 더 이상 고민하지 않아도 된다고 확신했습니다.
그때 뭔가를 발견했습니다.
유효한 세션과 올바른 역할, 그리고 정확한 JWT를 가진 사용자가 ID만 알면 다른 사용자의 청구서를 요청할 수 있었습니다. 로그에는 오류가 없었고, 실패한 요청도 없었습니다. 인증은 제가 직접 만든 모든 레이어에서 기술적으로는 올바르게 동작하고 있었습니다.
문제는 제가 하나의 레이어만 만들고 끝냈다는 점이었습니다.
그 오후 이후로 저는 Next.js 16 인증에 대한 사고 방식을 완전히 바꾸었고, 그때부터는 다르게 구현하고 있습니다.
인증 코드를 한 줄이라도 쓰기 전에 확인해야 할 한 가지
Next.js 15에서 온 경우, middleware.ts는 Next.js 16에서 폐기되고 proxy.ts로 대체되었습니다. 프로젝트 내 위치는 동일하지만 파일명과 내보내는 함수 이름이 다릅니다. middleware.ts는 Edge 런타임 사용 사례에서는 여전히 동작하지만, 향후 버전에서 제거될 예정입니다.
// Before: middleware.ts
export function middleware(request: NextRequest) { ... }
// After: proxy.ts
export function proxy(request: NextRequest) { ... }
전체 화면 모드
(코드 블록은 그대로 유지)
인증과 관련된 핵심 변화는 런타임입니다. middleware.ts는 기본적으로 Edge 런타임을 사용했으며, 이는 암호화 지원이 제한적이었습니다. Edge에서 JWT를 검증하려면 가벼운 라이브러리를 사용하거나 토큰에 사용된 알고리즘에 따라 우회 방법을 찾아야 했습니다.
proxy.ts는 Next.js 16에서 Node.js 런타임에서 실행됩니다. jose 같은 표준 JWT 라이브러리가 완전히 동작합니다. Edge 런타임에서 있었던 Node‑crypto 제한이 사라져, 인증에 있어 큰 개선이 이루어졌습니다.
# 이름 변경 및 기타 Next.js 16 파괴적 변경 사항 적용
npx @next/codemod@canary upgrade latest
# 미들웨어만 마이그레이션하고 싶다면
npx @next/codemod@canary middleware-to-proxy .
전체 화면 모드
실행 후 프로젝트 루트에 proxy.ts가 존재하고 내보내는 함수 이름이 proxy인지 확인하세요. 혼동을 피하려면 middleware.ts는 삭제합니다. Edge 런타임에서는 아직 지원되지만 Next.js 16에서는 폐기되었으며, 프레임워크는 앞으로 proxy.ts를 기대합니다.
두 파일을 동시에 두면 어느 파일이 실제 요청을 처리하는지 혼란스러워질 수 있습니다.
중요 제약: proxy.ts는 리다이렉트, 리라이트, 헤더 수정, 직접 응답 등을 위해 설계되었습니다. 느린 데이터 페칭이나 전체 세션 관리를 위해 사용해서는 안 되며, 주요 인가 레이어로 활용해서도 안 됩니다. 이는 프록시가 빠른 네트워크 경계 작업에 집중하도록 설계된 것입니다.
proxy.ts가 보안 레이어가 아닌 이유 (보여지는 것과 달리)
대부분의 Next.js 인증 튜토리얼이 놓치는 부분입니다. 눈에 띄지는 않지만, 테스트는 통과시키고 실제 운영에서는 깨지는 경우가 많습니다.
proxy.ts는 어떤 것이 렌더링되기 전 네트워크 경계에서 실행됩니다. 세션 쿠키를 읽고 JWT를 검증하며 역할을 확인하고, 리다이렉트하거나 요청을 계속 진행합니다. 빠르고 필수적이며 실제로 유용합니다. 모든 인증 설정에 필요합니다.
하지만 URL 패턴만 볼 수 있습니다.
예를 들어, 프록시는 사용자가 /dashboard/invoices에 접근할 수 있다고 판단하면 작업을 마칩니다. URL에 어떤 청구서 ID가 들어 있는지, 그 청구서가 누구에게 속하는지, 요청한 사용자가 볼 권한이 있는지는 전혀 알 수 없습니다. 프록시 입장에서는 URL이 매치되고, 역할이 매치되고, 토큰이 유효하니 녹색 신호만 보인 것입니다.
그 차이가 바로 청구서 사건의 원인이었습니다. 프록시는 올바르게 동작했지만, 잘못된 대상에 대해 올바르게 판단한 것이었습니다.
// proxy.ts: 역할 기반 라우트 보호, 올바르고 유용함
const ROLE_ROUTES: Record<string, string[]> = {
"/admin": ["admin"],
"/dashboard": ["admin", "user", "moderator"],
}
// 프록시는 /dashboard/invoices/abc123을 보고 허용하지만
// abc123이 현재 사용자에게 속하는지 알 방법이 없음
// 프록시 버그가 아니라 카테고리 불일치임
전체 화면 모드
proxy.ts는 소유권 질문에 답할 수 없습니다. URL 패턴만으로는 소유권을 판단할 수 없으며, 이를 위해서는 데이터베이스가 필요합니다. 그리고 데이터베이스는 프록시가 실행되는 곳이 아닙니다.
proxy.ts를 완전한 보안 레이어로 간주하는 것은 설정 실수가 아니라 정신 모델의 오류입니다. 거의 모든 인증 튜토리얼이 여기서 멈추면서 저지르는 실수이기도 합니다.
프록시가 틈을 보였을 때도 실행되는 검증
Server Component는 페이지가 렌더링될 때 실행됩니다. 이것은 명백해 보이지만, 중요한 함의를 가지고 있습니다: Server Component는 프록시 뒤에서 두 번째 방어선 역할을 합니다. 프록시 매처가 잘못 설정돼 라우트가 통과하더라도, 이 검증은 여전히 실행됩니다.
여기서 비로소 프록시가 구조적으로 잡을 수 없는 데이터베이스 기반 권한 결정을 잡아낼 수 있습니다.
// app/dashboard/billing/page.tsx
import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { getUserPermissions, getUserInvoices } from "@/lib/data"
export default async function BillingPage() {
const headerStore = await headers()
const userId = headerStore.get("x-user-id")
// 프록시가 이 라우트에서 틈을 보였어도 이 검증은 실행됨
if (!userId) {
redirect("/login")
return
}
// 역할은 JWT에서 가져오므로 DB 호출이 필요 없음
// 권한은 자주 바뀔 수 있어 DB 호출이 필요
const permissions = await getUserPermissions(userId)
if (!permissions.includes("billing:read")) {
redirect("/unauthorized")
}
const invoices = await getUserInvoices(userId)
return (
// ... UI 렌더링
)
}
전체 화면 모드
역할과 권한을 구분하는 이유는 의도적입니다. 역할은 JWT에 넣어도 충분히 안정적이며, 프록시는 데이터베이스에 접근하지 않고도 역할을 확인할 수 있습니다. 반면 권한은 접근 설정이 바뀔 때마다 즉시 반영되어야 하므로, JWT가 만료될 때까지 기다릴 수 없습니다. 따라서 프록시에서는 역할, Server Component에서는 권한을 다루는 것이 바람직합니다. 이 경계가 중요합니다.
x-user-id 헤더는 프록시가 요청을 전달하기 전에 설정합니다:
// proxy.ts 내부, JWT 검증 후
const requestHeaders = new Headers(request.headers)
requestHeaders.set("x-user-id", payload.sub as string)
requestHeaders.set("x-user-role", role)
return NextResponse.next({
request: { headers: requestHeaders },
})
// 헤더는 요청에 붙이고, 응답에 붙이지 않음
// Server Component는 headers() 로 들어오는 요청 헤더를 읽음
// 응답에 헤더를 붙이면 브라우저에 전달될 뿐, 페이지에서는 볼 수 없음
전체 화면 모드
처음 작성했을 때 헤더를 응답에 붙여 버렸습니다. 오류는 없었지만 headers() 호출이 두 필드 모두 null을 반환해 페이지가 모든 사용자를 /login 으로 리다이렉트했습니다. 완전 인증된 관리자조차도 말이죠. 이 실수 때문에 꽤 오랜 시간을 허비했습니다.
어떤 상황에서도 청구서 버그를 잡아냈을 백업 레이어
프록시와 Server Component가 동시에 틈을 보였더라도, 아래 레이어는 여전히 작동합니다.
사용자별 데이터를 반환하는 모든 데이터 함수는 userId 를 매개변수로 받아 쿼리 안에서 사용합니다. 이는 편의가 아니라 실제 접근 제어입니다.
// lib/data.ts
// 사고 전 데이터 레이어
export async function getInvoice(invoiceId: string) {
return db.query(
"SELECT * FROM invoices WHERE id = $1",
[invoiceId]
)
}
// 유효한 세션만 있으면 누구든지 임의의 ID 로 호출 가능
// 오류도, 로그도 없이 요청자에게 데이터가 반환됨
// 사고 후 데이터 레이어
export async function getInvoice(invoiceId: string, userId: string) {
return db.query(
"SELECT * FROM invoices WHERE id = $1 AND user_id = $2",
[invoiceId, userId]