본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
10장 : 인증 및 권한 관리

보호된 라우트

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

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

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

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

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


서버 컴포넌트 보호

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

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

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

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


라우트 핸들러 보호

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을 반환합니다.

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


여러 경로를 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 범위를 좁힙니다.


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('로그인이 필요합니다.');
  }

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

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

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

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

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


클라이언트 보호의 역할

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

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

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

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


보호 기준

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

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

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

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

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