보호된 라우트
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로 라우트 핸들러를 감싸면 요청의 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로 내보냅니다.
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],
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가 설정된 로그인 화면으로 이동을 안내합니다. /login과 /api/auth 같은 로그인·콜백 경로를 같은 보호 규칙에 포함하면 로그인 자체가 반복 차단될 수 있으므로 공개 경로로 둡니다. 페이지 이동이 필요한 경계와 JSON API의 401 응답을 구분합니다.
정적 파일과 Auth.js 콜백 경로까지 무분별하게 가로채지 않도록 matcher 범위를 좁힙니다.
Proxy와 데이터 경계 구분
Proxy는 페이지 진입을 빠르게 차단하는 데 유용합니다.
하지만 중요한 데이터 변경은 라우트 핸들러나 서버 액션에서도 권한을 다시 확인해야 합니다.
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는 세션 검사까지만 구현한 골격입니다. 주석 위치에서 게시글 조회, 소유권·역할 판정, 삭제를 구현해야 합니다. 인증은 사용자 확인이고, 인가는 해당 사용자의 작업 허용 여부이므로 서로 대체할 수 없습니다.
확인 문제
현재 matcher에 /api/posts는 없습니다. 로그아웃 상태에서 이 API를 직접 POST하면 어디에서 막히나요? 로그인한 요청의 JSON이 정상이라면 이 예제는 게시글을 저장하나요? 별도로 로그인한 사용자가 위 deletePost를 호출하면 삭제가 완료되나요?
직접 요청과 구현된 범위 해설
POST 핸들러 자체의 request.auth.user 검사에서 401을 반환합니다. matcher에 포함되지 않았다는 사실이 API 인증 검사를 없애지는 않습니다. 로그인한 POST는 JSON에서 title을 읽어 author와 함께 응답하지만 저장소 호출은 없습니다. deletePost도 로그인 검사를 통과한 뒤 주석까지 진행할 뿐 실제 삭제 호출은 없습니다. 따라서 성공 응답이나 함수 종료를 저장·삭제 완료의 근거로 사용하려면 해당 업무 처리가 실제로 연결되어 있어야 합니다.
클라이언트 보호의 역할
클라이언트에서는 로그인하지 않은 사용자에게 작성 버튼을 숨길 수 있습니다.
이 처리는 사용자 경험을 개선하지만 보안 경계는 아닙니다.
실제 작성 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: 없음 | 입력을 응답으로 돌려주고 요청이 끝납니다. |
보호 기준
401과 403을 구분하고, 리다이렉트 경로는 내부 허용 목록으로 제한합니다.
다음 절에서는 세션에 역할을 포함하고 관리자와 일반 사용자의 권한을 나눕니다.
연습: 요청 경로별로 검사 순서 추적하기
위 matcher와 authorized 콜백을 사용합니다. 로그인 화면은 공개 경로 /login이며, 콜백이 선택되지 않은 API도 자신의 핸들러에서 검사합니다. POST 예제는 JSON을 읽어 값을 되돌려 주는 코드이고 저장 기능은 없습니다.
- 익명 사용자가
/account/settings와/accounting을 각각 요청하면 어느 경로가 Proxy의 로그인 판단 대상인가요? 인증된 사용자가/dashboard를 요청한 경우도 비교하세요. - 익명 사용자가 화면을 거치지 않고 POST API에 잘못된 JSON을 보냈습니다. 위 POST 함수 본문이 실행된다면
request.json()과401반환 중 어느 것이 먼저 일어나나요? - 로그인한 사용자가 다른 사람의
postId를 삭제 액션에 보냈습니다. 제시된deletePost()뼈대가 이미 삭제를 수행하거나 소유권을 보장하나요? 실제 삭제 전 필요한 입력과 권한 검사를 설명하세요. - 공통 레이아웃에서 로그인 여부를 확인했고, 클라이언트의 삭제 버튼도 숨겼습니다. 이를 근거로 삭제 액션의 검사를 제거해도 되나요?
독립 진입점과 검사 순서 풀이
/account/settings는 matcher가 선택하며 익명 세션이면authorized가false를 반환합니다./accounting은 matcher와 콜백의 보호 루트 조건 모두에 해당하지 않습니다./dashboard는 선택되지만 인증된 사용자에 대해서는true입니다. 공개 경로라는 사실은 다른 API의 업무 권한까지 허가한다는 뜻이 아닙니다.- 익명 여부를 먼저 확인하므로
401을 반환하고 JSON을 읽지 않습니다. Proxy나 다른 인증 설정에서 먼저 응답을 종료하는 경우는 별개입니다. 이 순서는 제시된 POST 함수 본문을 기준으로 합니다. - 뼈대에는 실제 삭제 호출이 없고 소유권 검사도 주석뿐입니다.
postId의 형식과 리소스 존재를 확인하고, 서버 세션의 ID를 저장소의 작성자 ID와 비교하거나 허용된 관리자 정책을 확인한 뒤 삭제해야 합니다. 요청이 제공한 작성자 ID는 판정 근거가 아닙니다. - 제거하면 안 됩니다. 화면과 레이아웃은 모든 요청의 최종 보안 경계가 아닙니다. 액션이 직접 호출되어도 같은 인증·권한·입력 규칙을 적용해야 합니다.