보호된 라우트
auth()와 Proxy matcher로 페이지·라우트 핸들러를 보호하고 인증 검사와 업무 권한을 구분합니다.
보호된 라우트는 인증된 사용자만 접근할 수 있는 페이지나 API입니다. 링크를 숨기거나 클라이언트에서 다른 페이지로 보내는 것만으로는 보호가 완성되지 않습니다. 공격자는 화면을 거치지 않고 URL을 직접 요청할 수 있기 때문입니다. 그래서 신뢰할 수 있는 서버 코드에서 auth()로 세션을 검사해야 합니다.
먼저 무엇을 믿을 수 있는지 정리해 두겠습니다. 브라우저가 보낸 쿠키는 입력일 뿐입니다. 쿠키 이름이 있다는 사실, 클라이언트가 보낸 userId, JWT 문자열을 단순히 해석해 얻은 값만으로 신원을 확정하지 않으며, 중앙 인증 설정의 auth()가 그 요청의 세션을 검증한 결과를 씁니다. 그 뒤에 "이 사용자가 이 작업을 해도 되는가"라는 업무 권한을 확인합니다. 로그인 확인과 게시글 소유권 확인은 서로 다른 단계입니다.
서버 컴포넌트 보호
페이지 하나를 보호할 때는 서버 컴포넌트에서 세션을 확인합니다. 다음 코드의 /login은 직접 만든 로그인 페이지를 전제로 합니다.
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function AccountPage() {
const session = await auth();
if (!session?.user) {
redirect('/login');
}
return <h1>{session.user.name}님의 계정</h1>;
}redirect() 이후에는 페이지 본문이 렌더링되지 않습니다. 이 검사는 서버가 이 페이지를 렌더링할 때 수행되며, 이미 받은 UI를 클라이언트에서 다시 쓰는 것이 새 권한 검사를 대신하지는 않습니다. 여러 페이지가 같은 규칙을 쓰더라도, 먼저 각 데이터 접근 지점이 세션을 검사하도록 만듭니다.
라우트 핸들러 보호
API는 화면과 별도로 호출될 수 있으므로 자체 검사가 반드시 필요합니다. auth로 Route Handler를 감싸면 요청의 auth 속성에서 세션을 읽을 수 있습니다.
import { auth } from '@/auth';
export const POST = auth(async (request) => {
if (!request.auth?.user) {
return Response.json({ message: '로그인이 필요합니다.' }, { status: 401 });
}
const body = await request.json();
return Response.json({
author: request.auth.user.email,
title: body.title,
});
});이 예제는 로그인하지 않은 요청에 401 Unauthorized를 돌려줍니다. 로그인한 뒤에는 JSON을 읽어 일부 값을 되돌려 줄 뿐, 본문 스키마 검증이나 게시글 저장은 구현하지 않았으며, 잘못된 JSON의 처리도 따로 필요합니다. 로그인은 했지만 그 작업의 권한이 없다면 403 Forbidden을 돌려줍니다. 두 상태를 구분하면 클라이언트와 운영 로그에서 원인을 정확히 판단할 수 있으며, 두 상태 코드의 정의는 네트워크 교재의 상태 코드와 헤더에서 확인할 수 있습니다.
여러 경로를 Proxy에서 보호
여러 페이지에 공통 로그인 검사를 적용할 때는 중앙 인증 모듈의 auth를 Proxy로 내보냅니다. auth는 서버 컴포넌트에서 세션을 읽는 함수이면서, Proxy 자리에 그대로 둘 수 있는 함수이기도 합니다. 그래서 아래 코드는 새 함수를 만들지 않고 as proxy로 이름만 바꿔 다시 내보내며, 함께 내보내는 config가 어느 경로에서 이 Proxy를 실행할지 정합니다.
export { auth as proxy } from '@/auth';
export const config = {
matcher: ['/account/:path*', '/dashboard/:path*'],
};matcher는 Proxy를 실행할 경로만 고르며, 인증 여부에 따라 허용할지는 authorized 콜백에서 정의합니다. 이 교재의 Next.js 16에서는 파일 이름이 proxy.ts이므로, 이전 버전 문서의 middleware.ts 예시를 함께 만들지 않습니다. 이름이 바뀐 이력은 Next.js Proxy 문서의 버전 기록에서 확인할 수 있습니다.
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [GitHub],
pages: {
signIn: '/login',
error: '/login',
},
callbacks: {
authorized({ auth, request }) {
const pathname = request.nextUrl.pathname;
const isProtected = ['/account', '/dashboard'].some(
(root) => pathname === root || pathname.startsWith(`${root}/`),
);
if (!isProtected) {
return true;
}
return Boolean(auth?.user);
},
},
});공개 경로는 true를 돌려주고, 보호 경로는 세션이 있는지를 돌려줍니다. /account와 /account/settings는 보호 대상이고 /accounting은 아닙니다. 단순한 startsWith('/account')와 달리, 루트 바로 뒤에 /가 오는지까지 확인하기 때문입니다. 위 matcher도 /accounting을 고르지 않지만, 이 콜백을 다른 곳에서 다시 쓸 때도 같은 구분이 유지됩니다.
authorized가 보호 경로에서 false를 돌려주면 Auth.js가 pages.signIn에 설정한 로그인 화면(/login)으로 이동을 안내합니다. 앞 글에서 넣은 pages 설정을 이 파일에도 그대로 두는 이유입니다. /login과 /api/auth 같은 로그인과 콜백 경로를 같은 보호 규칙에 넣으면 로그인 자체가 반복해서 막힐 수 있으므로 공개 경로로 둡니다. 페이지 이동이 필요한 경우와 JSON API의 401 응답도 구분합니다. 정적 파일과 Auth.js 콜백 경로까지 무분별하게 가로채지 않도록 matcher의 범위는 좁게 잡습니다.
Proxy와 데이터 검사 구분
Proxy는 페이지 진입을 빠르게 막는 데 유용합니다. 하지만 중요한 데이터 변경은 Route Handler나 서버 액션에서도 권한을 다시 확인해야 합니다. Proxy 설정이 바뀌거나 내부 함수가 직접 호출되는 경우에도 데이터 규칙이 유지되어야 하기 때문입니다.
예를 들어 /account 페이지에서만 삭제 버튼을 보여 주더라도, 삭제 서버 액션은 직접 요청을 받을 수 있습니다. 서버 액션의 postId나 FormData도 외부 입력으로 검증하고, 서버 세션의 사용자 ID와 저장소의 게시글 작성자 ID를 비교해야 합니다. 요청에 들어 있는 authorId를 현재 사용자로 믿으면 안 됩니다. 다음 함수는 로그인 확인만 들어 있는 뼈대이며, 실제 삭제까지 완성한 코드가 아닙니다.
'use server';
import { auth } from '@/auth';
export async function deletePost(postId: string) {
const session = await auth();
if (!session?.user) {
throw new Error('로그인이 필요합니다.');
}
// 게시글 소유권 또는 관리자 권한을 확인한 뒤 삭제합니다.
}위 deletePost는 세션 검사까지만 구현했으므로, 주석 위치에 게시글 조회, 소유권이나 역할 판정, 삭제를 구현해야 합니다. 인증은 사용자를 확인하는 일이고 인가는 그 사용자에게 작업을 허용할지 정하는 일이므로, 어느 한쪽이 다른 쪽을 대신할 수 없습니다.
더 깊이: Proxy 검사가 통째로 건너뛰어진 실제 사례
Proxy 하나에만 기대는 설계가 위험하다는 것은 실제 취약점으로도 드러났습니다. 2025년에 공개된 CVE-2025-29927은 이전 이름인 Middleware에서 하던 인가 검사를 외부 요청이 우회할 수 있게 한 취약점으로, 요청에 x-middleware-subrequest 헤더를 넣는 방식이었습니다. Next.js 저장소의 보안 권고는 이 취약점의 심각도를 Critical(CVSS 3.1 기준 9.1)로 매겼고, 12.3.5, 13.5.9, 14.2.25, 15.2.3에서 고쳤습니다. 이 교재의 Next.js 16은 영향을 받는 버전에 들지 않습니다.
권고는 Vercel에 호스팅한 애플리케이션이 자동으로 보호되었다고 밝혔고, Vercel의 사후 분석은 영향을 받은 것이 next start나 output: 'standalone'으로 직접 운영하던 애플리케이션이었다고 정리합니다. 업그레이드할 수 없는 경우의 대처로는 이 헤더가 들어 있는 외부 요청이 Next.js 애플리케이션에 닿지 않도록 앞단에서 막는 방법이 제시되었습니다. Middleware의 검사만 믿은 페이지는 이런 우회에 그대로 노출되지만, 데이터를 읽고 쓰는 곳에서 세션과 권한을 다시 확인하는 코드라면 앞단의 검사를 건너뛴 요청도 그 자리에서 막힙니다. 본문이 Route Handler와 서버 액션에서 같은 검사를 되풀이하라고 하는 이유가 여기에 있습니다.
클라이언트 보호의 역할
클라이언트에서는 로그인하지 않은 사용자에게 작성 버튼을 숨길 수 있습니다. 이 처리는 사용자 경험을 개선하지만 보안 장치는 아닙니다. 실제 작성 API가 auth()를 검사하지 않으면, 버튼이 숨겨져 있어도 요청을 보낼 수 있습니다. 따라서 클라이언트 검사는 화면 표현에, 서버 검사는 데이터 보호에 씁니다.
공통 레이아웃에 검사를 한 번 넣었다고 그 아래의 데이터 요청과 서버 액션이 모두 보호되지는 않습니다. App Router의 레이아웃은 탐색할 때 다시 쓰일 수 있고, 자식 경로와 액션에도 각자의 진입점이 있습니다. Next.js 인증 안내도 데이터에 가까운 곳의 검사와, Route Handler와 서버 액션 각각의 검사를 강조합니다. 로그아웃 직후 이전 화면이 잠깐 남아 있어도, 새 쓰기 요청을 허용할지는 그 요청의 서버 검사로 정합니다.
페이지 진입 검사와 데이터 요청 검사는 각각 필요합니다
POST handler가 먼저 auth 래퍼의 세션을 확인하여 없으면 401로 끝내고, 있으면 JSON을 읽어 작성자·제목 응답을 만드는 경로입니다. Proxy와 Server Action의 독립 경계는 위 관계도와 표에서 유지하고 DB 저장은 덧붙이지 않습니다.
NEXT · 10.3 · 보호된 라우트
페이지 진입 검사와 데이터 요청 검사는 각각 필요합니다
Proxy를 통과하거나 버튼이 보였다는 사실만으로 API·서버 액션의 권한 검사를 대신할 수 없습니다. 교재의 네 서버 경계는 각각 다른 요청·실행 지점에서 판단합니다.
페이지 · 서버 컴포넌트
AccountPage가 auth()로 session.user를 확인합니다.
- 사용자 없음
- redirect("/login")으로 종료하며 계정 본문을 렌더링하지 않습니다.
- 사용자 있음
- 계정 화면을 렌더링합니다. 이미 받은 UI 재사용이 새 권한 검사는 아닙니다.
API · auth 래퍼
POST /api/posts가 request.auth.user를 독립적으로 확인합니다.
- 사용자 없음
- 401 JSON 응답으로 종료합니다.
- 사용자 있음
- JSON을 읽고 author·title을 응답합니다. 스키마 검증·게시글 저장을 구현한 예제는 아닙니다.
Proxy · 경로 선택과 허용 판단
matcher는 /account·/dashboard 아래에서 Proxy를 실행할 경로를 고릅니다.
authorized 콜백이 보호 경로의 session.user 유무를 판정합니다. matcher에 경로를 넣는 것만으로 로그인 검사가 만들어지지는 않습니다.
서버 액션 · 삭제 시점의 재검사
deletePost가 auth()로 사용자를 확인하고, 없으면 예외를 던집니다.
교재 코드는 세션 검사까지만 구현한 골격입니다. 게시글 조회·소유권 또는 역할 검사·삭제는 주석 위치에 구현해야 합니다.
클라이언트 보호는 화면 표현에 사용합니다. 인증 필요는 401, 로그인 뒤 작업 권한 부족은 403으로 구분합니다. API를 직접 호출하거나 Proxy 설정이 바뀌어도 데이터 경계의 검사는 유지해야 합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- POST /api/posts의 auth 래퍼 안에서 request.auth.user가 없으면 401 JSON으로 끝나고 본문을 읽지 않는다. 있으면 본문의 정상 JSON 전제에서 request.json을 읽고 author와 title을 응답한다. 원 예제에 스키마 검사나 게시글 저장소 호출은 없으며 잘못된 JSON을 별도 상태로 바꾸는 catch도 아직 없다. Proxy 및 서버 액션은 아래 비교의 별도 실행 경계이며 이 흐름 뒤에 연결된 단계가 아니다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| auth 래퍼 확인 POST auth 래퍼가 request.auth.user를 확인합니다. | user 없음: 401 · body 읽기: 거부 분기에서 없음 | 인증 없는 요청은 본문 처리 전에 끝납니다. |
| 인증 후 JSON 읽기 사용자가 있으면 정상 JSON 전제에서 request.json을 읽습니다. | user: 있음 · 본문: request.json · 스키마 검사: 원 예제 없음 | 허용된 요청의 입력을 읽습니다. |
| 공개 응답 author와 title을 반환합니다. | 응답: author·title · 저장소 호출: 없음 · 잘못된 JSON catch: 없음 | 입력을 응답으로 돌려주고 요청이 끝납니다. |
페이지 진입 검사와 데이터 요청 검사는 각각 필요합니다
POST handler가 먼저 auth 래퍼의 세션을 확인하여 없으면 401로 끝내고, 있으면 JSON을 읽어 작성자·제목 응답을 만드는 경로입니다. Proxy와 Server Action의 독립 경계는 위 관계도와 표에서 유지하고 DB 저장은 덧붙이지 않습니다.
NEXT · 10.3 · 보호된 라우트
페이지 진입 검사와 데이터 요청 검사는 각각 필요합니다
Proxy를 통과하거나 버튼이 보였다는 사실만으로 API·서버 액션의 권한 검사를 대신할 수 없습니다. 교재의 네 서버 경계는 각각 다른 요청·실행 지점에서 판단합니다.
페이지 · 서버 컴포넌트
AccountPage가 auth()로 session.user를 확인합니다.
- 사용자 없음
- redirect("/login")으로 종료하며 계정 본문을 렌더링하지 않습니다.
- 사용자 있음
- 계정 화면을 렌더링합니다. 이미 받은 UI 재사용이 새 권한 검사는 아닙니다.
API · auth 래퍼
POST /api/posts가 request.auth.user를 독립적으로 확인합니다.
- 사용자 없음
- 401 JSON 응답으로 종료합니다.
- 사용자 있음
- JSON을 읽고 author·title을 응답합니다. 스키마 검증·게시글 저장을 구현한 예제는 아닙니다.
Proxy · 경로 선택과 허용 판단
matcher는 /account·/dashboard 아래에서 Proxy를 실행할 경로를 고릅니다.
authorized 콜백이 보호 경로의 session.user 유무를 판정합니다. matcher에 경로를 넣는 것만으로 로그인 검사가 만들어지지는 않습니다.
서버 액션 · 삭제 시점의 재검사
deletePost가 auth()로 사용자를 확인하고, 없으면 예외를 던집니다.
교재 코드는 세션 검사까지만 구현한 골격입니다. 게시글 조회·소유권 또는 역할 검사·삭제는 주석 위치에 구현해야 합니다.
클라이언트 보호는 화면 표현에 사용합니다. 인증 필요는 401, 로그인 뒤 작업 권한 부족은 403으로 구분합니다. API를 직접 호출하거나 Proxy 설정이 바뀌어도 데이터 경계의 검사는 유지해야 합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- POST /api/posts의 auth 래퍼 안에서 request.auth.user가 없으면 401 JSON으로 끝나고 본문을 읽지 않는다. 있으면 본문의 정상 JSON 전제에서 request.json을 읽고 author와 title을 응답한다. 원 예제에 스키마 검사나 게시글 저장소 호출은 없으며 잘못된 JSON을 별도 상태로 바꾸는 catch도 아직 없다. Proxy 및 서버 액션은 아래 비교의 별도 실행 경계이며 이 흐름 뒤에 연결된 단계가 아니다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| auth 래퍼 확인 POST auth 래퍼가 request.auth.user를 확인합니다. | user 없음: 401 · body 읽기: 거부 분기에서 없음 | 인증 없는 요청은 본문 처리 전에 끝납니다. |
| 인증 후 JSON 읽기 사용자가 있으면 정상 JSON 전제에서 request.json을 읽습니다. | user: 있음 · 본문: request.json · 스키마 검사: 원 예제 없음 | 허용된 요청의 입력을 읽습니다. |
| 공개 응답 author와 title을 반환합니다. | 응답: author·title · 저장소 호출: 없음 · 잘못된 JSON catch: 없음 | 입력을 응답으로 돌려주고 요청이 끝납니다. |
위 그림은 페이지 진입 검사와 데이터 요청 검사가 각각 필요하다는 점을 보여 줍니다. 위쪽 흐름은 POST /api/posts가 직접 요청을 받았을 때의 처리입니다. auth 래퍼 안에서 request.auth.user가 없으면 본문을 읽기 전에 401 JSON으로 끝나고, 있으면 정상 JSON이라는 전제에서 request.json()을 읽어 author와 title을 응답합니다. 이 예제에는 스키마 검사나 저장소 호출이 없고, 잘못된 JSON을 처리하는 코드도 아직 없습니다. 그림의 단계도 auth 래퍼 확인, 인증 뒤의 JSON 읽기, 응답 반환의 순서입니다.
아래쪽 비교는 이 글의 네 가지 서버 검사를 정리합니다. 페이지에서는 AccountPage가 auth()로 session.user를 확인해 사용자가 없으면 redirect("/login")으로 끝내고, API에서는 auth 래퍼가 request.auth.user를 따로 확인합니다. Proxy에서는 matcher가 실행할 경로를 고르고 authorized 콜백이 허용 여부를 판단하므로, matcher에 경로를 넣는 것만으로 로그인 검사가 만들어지지는 않습니다. 서버 액션에서는 deletePost가 삭제할 때 auth()로 사용자를 다시 확인합니다. 그림의 설명처럼 Proxy 이후의 단계로 이어지는 흐름이 아니라 서로 다른 실행 지점이며, API를 직접 호출하거나 Proxy 설정이 바뀌어도 데이터 쪽의 검사는 유지해야 합니다.
보호 기준
401과 403을 구분하고, 리다이렉트 경로는 내부 허용 목록으로 제한합니다. 다음 글에서는 세션에 역할을 담아 관리자와 일반 사용자의 권한을 나눕니다.
정리
- 쿠키, 요청의
userId, 해석만 한 JWT 값은 입력일 뿐이며, 신원은auth()가 검증한 세션으로 확인합니다. - 서버 컴포넌트는
auth()로 세션을 확인해 없으면redirect()하고, Route Handler는auth로 감싸request.auth를 검사합니다. - 로그인하지 않은 요청에는
401, 로그인했지만 권한이 없는 요청에는403을 돌려줍니다. - Proxy는
matcher로 실행 경로를 고르고authorized콜백으로 허용 여부를 정하며, 로그인과 콜백 경로는 공개 경로로 둡니다. - Proxy, 레이아웃, 숨긴 버튼이 있어도 데이터를 바꾸는 Route Handler와 서버 액션은 인증, 입력, 소유권을 다시 검사합니다.
연습 문제
1. 직접 요청과 구현된 범위
지금의 matcher에는 /api/posts가 없습니다. 로그아웃 상태에서 이 API에 직접 POST하면 어디에서 막힐까요? 로그인한 요청의 JSON이 정상이라면 이 예제는 게시글을 저장할까요? 따로 로그인한 사용자가 deletePost를 호출하면 삭제가 끝날까요?
해설 보기
POST 핸들러 자체의 request.auth.user 검사에서 401을 돌려줍니다. matcher에 포함되지 않았다고 API의 인증 검사가 없어지지는 않습니다. 로그인한 POST는 JSON에서 title을 읽어 author와 함께 응답하지만, 저장소 호출은 없습니다. deletePost도 로그인 검사를 통과한 뒤 주석까지 진행할 뿐 실제 삭제 호출은 없습니다. 성공 응답이나 함수 종료를 저장이나 삭제가 끝났다는 뜻으로 쓰려면, 그 업무 처리가 실제로 연결되어 있어야 합니다.
2. 경로별로 Proxy의 판단 따라가기
위 matcher와 authorized 콜백을 씁니다. 로그인 화면은 공개 경로인 /login입니다. 익명 사용자가 /account/settings와 /accounting을 각각 요청하면, 어느 경로가 Proxy의 로그인 판단 대상이 될까요? 인증된 사용자가 /dashboard를 요청한 경우와도 비교해 보세요.
해설 보기
/account/settings는 matcher가 고르며, 익명 세션이므로 authorized가 false를 돌려줍니다. /accounting은 matcher와 콜백의 보호 루트 조건 어디에도 해당하지 않습니다. /dashboard는 matcher가 고르지만 인증된 사용자이므로 true입니다. 공개 경로라는 사실이 다른 API의 업무 권한까지 허가한다는 뜻은 아닙니다.
3. 잘못된 JSON과 401의 순서
익명 사용자가 화면을 거치지 않고 POST API에 잘못된 JSON을 보냈습니다. POST 함수 본문이 실행된다면 request.json()과 401 반환 가운데 무엇이 먼저 일어날까요?
해설 보기
익명인지를 먼저 확인하므로 401을 돌려주고 JSON은 읽지 않습니다. 이 순서는 POST 함수 본문을 기준으로 한 것이며, Proxy나 다른 인증 설정이 먼저 응답을 끝내는 경우는 별개입니다.
4. 다른 사람의 postId
로그인한 사용자가 다른 사람의 postId를 삭제 액션에 보냈습니다. deletePost() 뼈대가 이미 삭제를 수행하거나 소유권을 보장할까요? 실제로 삭제하기 전에 필요한 입력 검사와 권한 검사를 설명해 보세요.
해설 보기
뼈대에는 실제 삭제 호출이 없고, 소유권 검사도 주석뿐입니다. postId의 형식과 리소스가 있는지 확인하고, 서버 세션의 ID를 저장소의 작성자 ID와 비교하거나 허용된 관리자 정책을 확인한 뒤 삭제해야 합니다. 요청이 보낸 작성자 ID로는 판단하지 않습니다.
5. 레이아웃 검사와 숨긴 버튼
공통 레이아웃에서 로그인 여부를 확인했고, 클라이언트의 삭제 버튼도 숨겼습니다. 그러니 삭제 액션의 검사는 없애도 될까요?
해설 보기
없애면 안 됩니다. 화면과 레이아웃은 모든 요청을 마지막으로 막아 주는 장치가 아닙니다. 액션이 직접 호출되어도 같은 인증, 권한, 입력 규칙을 적용해야 합니다.
다음 글 역할 기반 접근 제어에서는 세션에 역할을 담고, 관리자와 일반 사용자의 권한을 서버에서 나누는 방법을 알아봅니다.