본문으로 건너뛰기

안동민 개발노트

본문 시작

보호된 라우트

auth()와 Proxy matcher로 페이지·라우트 핸들러를 보호하고 인증 검사와 업무 권한을 구분합니다.

보호된 라우트는 인증된 사용자만 접근할 수 있는 페이지나 API입니다.

링크를 숨기거나 클라이언트에서 다른 페이지로 보내는 것만으로는 보호가 완성되지 않습니다.

공격자는 화면을 거치지 않고 URL을 직접 요청할 수 있기 때문입니다.

신뢰할 수 있는 서버 경계에서 auth()로 세션을 검사해야 합니다.

HTML 다이어그램: /docs/next/ch10/ch10-3/1.html

서버 컴포넌트 보호

페이지 하나를 보호할 때는 서버 컴포넌트에서 세션을 확인하는 방식이 가장 명확합니다.

src/app/account/page.tsx
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() 이후에는 페이지 본문이 렌더링되지 않습니다.

이 검사는 요청마다 서버에서 수행됩니다.

여러 페이지가 같은 규칙을 사용하더라도 먼저 각 데이터 접근 지점이 세션을 검사하도록 만듭니다.

HTML 다이어그램: /docs/next/ch10/ch10-3/2.html

라우트 핸들러 보호

API는 화면과 별도로 호출될 수 있으므로 자체 검사가 반드시 필요합니다.

auth로 라우트 핸들러를 감싸면 요청의 auth 속성에서 세션을 읽을 수 있습니다.

src/app/api/posts/route.ts
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를 반환합니다.

로그인은 했지만 해당 작업 권한이 없으면 403 Forbidden을 반환합니다.

두 상태를 구분하면 클라이언트와 운영 로그에서 원인을 정확히 판단할 수 있습니다.

HTML 다이어그램: /docs/next/ch10/ch10-3/3.html

여러 경로를 Proxy에서 보호

여러 페이지에 공통 로그인 경계를 적용할 때는 중앙 인증 모듈의 auth를 Proxy로 내보냅니다.

src/proxy.ts
export { auth as proxy } from '@/auth';

export const config = {
  matcher: ['/account/:path*', '/dashboard/:path*'],
};

matcher는 Proxy를 실행할 경로만 고릅니다.

인증 여부에 따른 허용 판단은 authorized 콜백에서 정의합니다.

src/auth.ts
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 isProtected =
        request.nextUrl.pathname.startsWith('/account') ||
        request.nextUrl.pathname.startsWith('/dashboard');

      if (!isProtected) {
        return true;
      }

      return Boolean(auth?.user);
    },
  },
});

공개 경로는 true를 반환하고 보호 경로는 세션 존재 여부를 반환합니다.

정적 파일과 Auth.js 콜백 경로까지 무분별하게 가로채지 않도록 matcher 범위를 좁힙니다.

HTML 다이어그램: /docs/next/ch10/ch10-3/4.html

Proxy와 데이터 경계 구분

Proxy는 페이지 진입을 빠르게 차단하는 데 유용합니다.

하지만 중요한 데이터 변경은 라우트 핸들러나 서버 액션에서도 권한을 다시 확인해야 합니다.

Proxy 설정이 바뀌거나 내부 함수가 직접 호출되는 경우에도 데이터 규칙이 유지되어야 하기 때문입니다.

src/actions/delete-post.ts
'use server';

import { auth } from '@/auth';

export async function deletePost(postId: string) {
  const session = await auth();

  if (!session?.user) {
    throw new Error('로그인이 필요합니다.');
  }

  // 게시글 소유권 또는 관리자 권한을 확인한 뒤 삭제합니다.
}

인증 검사는 사용자를 확인합니다.

소유권과 역할 검사는 그 사용자가 해당 게시글을 변경할 수 있는지 확인합니다.

두 판단은 서로 대체할 수 없습니다.

HTML 다이어그램: /docs/next/ch10/ch10-3/5.html

중요한 변경 작업은 세션 확인과 리소스 권한 확인을 같은 서버 경계에서 수행합니다.

HTML 다이어그램: /docs/next/ch10/ch10-3/6.html

클라이언트 보호의 역할

클라이언트에서는 로그인하지 않은 사용자에게 작성 버튼을 숨길 수 있습니다.

이 처리는 사용자 경험을 개선하지만 보안 경계는 아닙니다.

실제 작성 API가 auth()를 검사하지 않으면 숨겨진 버튼과 관계없이 요청을 보낼 수 있습니다.

따라서 클라이언트 검사는 화면 표현에, 서버 검사는 데이터 보호에 사용합니다.

HTML 다이어그램: /docs/next/ch10/ch10-3/7.html

보호 기준

페이지 하나는 서버 컴포넌트에서 auth()로 보호합니다.

여러 경로의 공통 진입 규칙은 Proxy와 matcher로 표현합니다.

API와 서버 액션은 호출 지점에서 인증과 업무 권한을 다시 검사합니다.

HTML 다이어그램: /docs/next/ch10/ch10-3/8.html

401403을 구분하고, 리다이렉트 경로는 내부 허용 목록으로 제한합니다.

다음 절에서는 세션에 역할을 포함하고 관리자와 일반 사용자의 권한을 나눕니다.