역할 기반 접근 제어
Auth.js 토큰과 세션에 역할을 전달하고 서버에서 관리자·회원 권한을 일관되게 검사합니다.
로그인 여부만으로 모든 기능의 사용 권한이 결정되지는 않습니다.
회원은 자신의 게시글을 수정할 수 있고 관리자는 신고된 게시글을 숨길 수 있다고 가정해 보겠습니다.
이처럼 역할에 따라 허용 작업을 나누는 방식을 역할 기반 접근 제어(Role-Based Access Control, RBAC)라고 합니다.
역할 정보는 화면 장식이 아니라 서버의 데이터 변경 규칙에 사용해야 합니다.
사용자마다 조건을 복제하지 않고 역할이 허용할 행동을 정의한다.
- User 서버
확인한 현재 사용자
- Role admin, editor, member 같
책임 묶음
- Permission post
edit, user:manage 같은 행동
- Resource 정책
지키는 실제 대상
역할 모델 정하기
먼저 애플리케이션에서 사용할 역할을 좁게 정의합니다.
export type UserRole = 'member' | 'admin';역할 이름은 구현 기술보다 업무 의미를 드러내야 합니다.
권한이 늘어나면 역할마다 할 수 있는 작업을 별도 함수로 정리합니다.
import type { UserRole } from './auth-types';
export function canModeratePosts(role: UserRole) {
return role === 'admin';
}여러 컴포넌트에 role === 'admin' 조건을 반복하는 것보다 정책 함수를 공유하는 편이 변경에 안전합니다.
페이지, action, route가 같은 판단 함수를 호출하게 만든다.
- Server Page 렌더 전 actor
action을 전달한다.
- Server Action 변경 전 actor
resource를 전달한다.
- Route Handler 응답 전 같
policy를 호출한다.
- Policy 역할, permission, 소유권
한 규칙으로 판정한다.
세션 타입 확장
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;
}
}타입 선언은 실제 값을 만들지 않습니다.
다음 콜백에서 토큰과 세션에 같은 값을 넣어야 합니다.
타입과 콜백의 필드 이름이 다르면 role이 경계에서 사라진다.
- User 로그인 원본
role을 얻는다.
- JWT 서버 세션 전략의 role
보관한다.
- Session 필요한 role만 명시적
노출한다.
- Policy 서버 actor
permission을 판정한다.
토큰에서 세션으로 역할 전달
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;
},
},
});예제의 기본 역할은 학습을 위한 값입니다.
실제 서비스에서는 이메일 문자열만 보고 관리자 역할을 부여하지 않습니다.
검증된 데이터베이스 레코드에서 역할을 읽고, 역할 변경 시 기존 토큰을 언제 갱신할지도 정해야 합니다.
한 콜백이 저장하고 다음 콜백이 필요한 공개 필드만 복사한다.
- 로그인 사용자 DB 또
Provider에서 role을 얻는다.
- jwt callback token.role에
정규화해 저장한다.
- session callback
session.user.role로 필요한 값만 옮긴다.
서버 컴포넌트에서 관리자 페이지 보호
관리자 페이지는 서버에서 역할을 확인합니다.
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>;
}로그인하지 않은 상태와 권한이 부족한 상태를 구분합니다.
관리자 링크를 숨기는 클라이언트 코드가 있어도 이 서버 검사는 유지합니다.
메뉴 노출 여부와 무관하게 페이지 자체가 admin permission을 검사한다.
- auth() 서버
actor를 만든다.
- admin 허용 policy
permission을 확인한 뒤 렌더한다.
- 거부 안전한 페이지
redirect하거나 오류를 반환한다.
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만 저장소에 전달하므로 대량 할당 취약점을 막을 수 있습니다.
누가 요청했고 자원이 있는지 확인한 뒤 행동 허용 여부를 판정한다.
- 1세션 없음 401 Unauthorized
- 2자원 없음 404 Not Found
- 3소유권·role 부족 403 Forbidden
- 4허용 업무 로직 실행
Proxy의 역할 제한
Proxy에서도 request.auth?.user.role을 확인할 수 있습니다.
하지만 복잡한 게시글 소유권은 데이터베이스 조회가 필요한 업무 규칙입니다.
Proxy에는 관리자 경로의 빠른 진입 차단처럼 단순한 규칙만 둡니다.
최종 권한 검사는 데이터에 가장 가까운 라우트 핸들러나 서버 액션에서 수행합니다.
초기 분기는 가볍게, 데이터 권한은 최신 서버 상태로 확정한다.
- Proxy 경로
세션의 거친 role로 빠르게 분기한다.
- Final data auth 최신 role
permission, 소유권으로 최종 판정한다.
권한 설계 점검
클라이언트가 보낸 역할 값을 신뢰하지 않습니다.
세션 역할은 검증된 데이터에서 만들고 필요한 최소 정보만 노출합니다.
관리자 여부만 검사하지 말고 게시글 소유권 같은 업무 규칙도 함께 확인합니다.
권한 부족은 403, 인증 필요는 401로 구분합니다.
중요한 작업은 누가 어떤 리소스를 변경했는지 감사 로그를 남깁니다.
역할은 기능을 분류하는 출발점이며, 실제 보호는 서버의 일관된 정책 검사로 완성됩니다.
성공 화면만 보지 말고 권한이 없는 요청이 어디서 막히는지 확인한다.
- Role source 역할
어디서 오고 언제 갱신되는가
- Central policy 모든 서버 진입점
같은 규칙을 쓰는가
- Final check 데이터 접근 직전에
다시 검사하는가
- Deny path 401, 403, 404
로그가 일관적인가