역할 기반 접근 제어
Auth.js 토큰과 세션에 역할을 전달하고 서버에서 관리자·회원 권한을 일관되게 검사합니다.
로그인 여부만으로 모든 기능의 사용 권한이 결정되지는 않습니다.
회원은 자신의 게시글을 수정할 수 있고 관리자는 신고된 게시글을 숨길 수 있다고 가정해 보겠습니다.
이처럼 역할에 따라 허용 작업을 나누는 방식을 역할 기반 접근 제어(Role-Based Access Control, RBAC)라고 합니다.
역할 정보는 화면 장식이 아니라 서버의 데이터 변경 규칙에 사용해야 합니다.
역할 모델 정하기
먼저 애플리케이션에서 사용할 역할을 좁게 정의합니다.
export type UserRole = 'member' | 'admin';역할 이름은 구현 기술보다 업무 의미를 드러내야 합니다.
권한이 늘어나면 역할마다 할 수 있는 작업을 별도 함수로 정리합니다.
import type { UserRole } from './auth-types';
export function canModeratePosts(role: UserRole) {
return role === 'admin';
}여러 컴포넌트에 role === 'admin' 조건을 반복하는 것보다 정책 함수를 공유하는 편이 변경에 안전합니다.
세션 타입 확장
TypeScript가 사용자 역할을 알 수 있도록 Auth.js 모듈 타입을 확장합니다.
import type { DefaultSession } from 'next-auth';
import type { UserRole } from '@/auth-types';
declare module 'next-auth' {
interface Session {
user: {
id: string;
role: UserRole;
} & DefaultSession['user'];
}
interface User {
role: UserRole;
}
}
declare module 'next-auth/jwt' {
interface JWT {
id: string;
role: UserRole;
}
}타입 선언은 실제 값을 만들지 않습니다.
다음 콜백에서 토큰과 세션에 같은 값을 넣어야 합니다.
토큰에서 세션으로 역할 전달
JWT 세션 전략에서는 로그인 시 사용자 정보를 토큰에 저장하고 세션 생성 시 필요한 값만 노출합니다.
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [GitHub],
session: { strategy: 'jwt' },
callbacks: {
jwt({ token, user }) {
if (user) {
token.id = user.id ?? token.sub!;
token.role = user.role ?? 'member';
}
return token;
},
session({ session, token }) {
session.user.id = token.id;
session.user.role = token.role;
return session;
},
},
});예제의 기본 역할은 학습을 위한 값입니다.
실제 서비스에서는 이메일 문자열만 보고 관리자 역할을 부여하지 않습니다.
검증된 데이터베이스 레코드에서 역할을 읽고, 역할 변경 시 기존 토큰을 언제 갱신할지도 정해야 합니다.
서버 컴포넌트에서 관리자 페이지 보호
관리자 페이지는 서버에서 역할을 확인합니다.
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function AdminPage() {
const session = await auth();
if (!session?.user) {
redirect('/login');
}
if (session.user.role !== 'admin') {
redirect('/forbidden');
}
return <h1>관리자 화면</h1>;
}로그인하지 않은 상태와 권한이 부족한 상태를 구분합니다.
관리자 링크를 숨기는 클라이언트 코드가 있어도 이 서버 검사는 유지합니다.
API에서 역할과 소유권 검사
게시글 수정 권한은 역할만으로 충분하지 않을 수 있습니다.
일반 회원은 자신의 게시글만 수정하고 관리자는 모든 게시글을 수정할 수 있다고 가정합니다.
먼저 요청 본문에서 수정 가능한 필드만 골라내는 데이터 전송 객체(Data Transfer Object, DTO) 검증 함수를 만듭니다.
export type UpdatePostInput = {
title?: string;
content?: string;
};
const ALLOWED_FIELDS = new Set(['title', 'content']);
export function parseUpdatePostInput(value: unknown): UpdatePostInput | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return null;
}
const body = value as Record<string, unknown>;
const fields = Object.keys(body);
if (fields.length === 0 || fields.some((field) => !ALLOWED_FIELDS.has(field))) {
return null;
}
const input: UpdatePostInput = {};
if ('title' in body) {
if (typeof body.title !== 'string') return null;
const title = body.title.trim();
if (title.length < 1 || title.length > 100) return null;
input.title = title;
}
if ('content' in body) {
if (typeof body.content !== 'string') return null;
const content = body.content.trim();
if (content.length < 1 || content.length > 10_000) return null;
input.content = content;
}
return input;
}@/repositories/posts는 findPost와 updatePost를 내보내는 애플리케이션의 저장소 모듈입니다.
findPost는 작성자 ID를 포함한 게시글 또는 null을 반환하고, updatePost는 위 UpdatePostInput 타입만 받도록 구현합니다.
import { auth } from '@/auth';
import { parseUpdatePostInput } from '@/lib/posts/update-post-input';
import { findPost, updatePost } from '@/repositories/posts';
export const PATCH = auth(async (request, context) => {
const user = request.auth?.user;
if (!user) {
return Response.json({ message: '로그인이 필요합니다.' }, { status: 401 });
}
const { postId } = await context.params;
const post = await findPost(postId);
if (!post) {
return Response.json({ message: '게시글이 없습니다.' }, { status: 404 });
}
const canEdit = post.authorId === user.id || user.role === 'admin';
if (!canEdit) {
return Response.json({ message: '수정 권한이 없습니다.' }, { status: 403 });
}
const contentType = request.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
return Response.json({ message: 'JSON 본문이 필요합니다.' }, { status: 415 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ message: '올바른 JSON 형식이 아닙니다.' }, { status: 400 });
}
const input = parseUpdatePostInput(body);
if (!input) {
return Response.json(
{ message: 'title과 content만 사용할 수 있으며 각각 1~100자, 1~10,000자여야 합니다.' },
{ status: 400 },
);
}
const updated = await updatePost(postId, input);
return Response.json(updated);
});역할 검사는 소유권 검사를 보완합니다.
로그인 여부, 리소스 존재 여부, 소유권과 역할을 순서대로 나누면 실패 원인이 명확해집니다.
허용 목록에 없는 authorId나 role 같은 필드는 거부하고, 검증을 통과한 title과 content만 저장소에 전달하므로 대량 할당 취약점을 막을 수 있습니다.
Proxy의 역할 제한
Proxy에서도 request.auth?.user.role을 확인할 수 있습니다.
하지만 복잡한 게시글 소유권은 데이터베이스 조회가 필요한 업무 규칙입니다.
Proxy에는 관리자 경로의 빠른 진입 차단처럼 단순한 규칙만 둡니다.
최종 권한 검사는 데이터에 가장 가까운 라우트 핸들러나 서버 액션에서 수행합니다.
권한 설계 점검
클라이언트가 보낸 역할 값을 신뢰하지 않습니다.
세션 역할은 검증된 데이터에서 만들고 필요한 최소 정보만 노출합니다.
관리자 여부만 검사하지 말고 게시글 소유권 같은 업무 규칙도 함께 확인합니다.
권한 부족은 403, 인증 필요는 401로 구분합니다.
중요한 작업은 누가 어떤 리소스를 변경했는지 감사 로그를 남깁니다.
역할은 기능을 분류하는 출발점이며, 실제 보호는 서버의 일관된 정책 검사로 완성됩니다.