본문 바로가기
C.W.K.
Stream
Lesson 06 of 09 · published

보호 라우트는 여러 겹으로 지켜

~20 min · defense in depth, auth

Level 0호기심
0 XP0/68 lessons0/11 achievements
0/120 XP to next level120 XP to go0% complete

인증 검사 하나에 모든 책임을 걸지 마

각 경계는 서로 다른 우회로를 막아. Proxy만 통과했다고 데이터가 안전한 것도 아니고, 화면을 숨겼다고 직접 POST가 사라지는 것도 아니야.

경계주로 막는 것
Proxy로그인하지 않은 사용자와 bot이 보호 URL에 들어오는 일을 빠르게 돌려보내.
레이아웃미리보기 배포처럼 Proxy를 거치지 않은 서버 렌더링 경로에서 데이터 노출을 막아.
서버 액션·라우트 핸들러인터넷 어디서든 직접 들어올 수 있는 프로그램형 POST를 검증해.

첫 두 겹은 경험, 마지막 겹은 보안이야

Proxy는 불필요한 화면 진입을 줄이고, 레이아웃은 보호 페이지를 렌더링하지 않게 해. 하지만 페이지를 거치지 않고 변경 엔드포인트만 호출할 수 있으므로 액션과 핸들러의 인증·권한 검사는 절대 생략할 수 없어.

같은 정책을 공유하되 검사는 각 경계에서 해

역할과 소유권 판단을 순수한 공통 함수로 추출하면 규칙이 어긋나는 일을 줄일 수 있어. 그래도 이전 경계의 결과를 신뢰해 다음 검사를 건너뛰지는 마.

보호 기능 하나를 골라 세 경계를 각각 우회해 봐. Proxy를 건너뛴 preview URL, 레이아웃 없이 액션 직접 POST, 다른 사용자의 자원 ID를 시험하고 최종 쓰기 경계가 모두 거부해야 해. 같은 권한 함수를 공유해도 각 요청에서 신원과 최신 소유권을 다시 읽어.

겹이 많다고 같은 약한 검사 세 번이면 방어가 깊어지지 않아. Proxy는 빠른 이동, 레이아웃은 데이터 노출, 액션은 실제 변경처럼 서로 다른 실패를 맡아야 해. 최종 경계가 혼자서도 안전하고 앞의 경계는 비용과 경험을 줄이는 구조가 좋아.

보호 페이지 URL 직접 방문, preview URL, 서버 액션 직접 POST, 다른 사용자 자원 ID를 하나씩 시도해. 앞 경계를 우회해도 가장 깊은 handler가 쓰기를 막고, 사용자에게는 적절한 redirect나 403이 보이며 서버 로그에는 같은 정책 결과가 남아야 해. 권한 실패 응답은 자원의 존재 자체를 알려도 되는지도 고려해. 어떤 시스템에서는 403보다 404가 정보 노출을 줄여. 다만 감사 로그에는 실제 거부 원인을 남겨 운영자가 정책 오류와 공격을 구분할 수 있게 해.

권한 규칙은 공통 함수로 모으되 민감한 읽기와 쓰기마다 그 함수를 다시 호출해. 앞단의 리디렉션 결과를 뒤쪽 보안 경계가 신뢰해서는 안 돼.

Code

첫째 계층 — Proxy·ts
// proxy.ts
export function proxy(request: NextRequest) {
  const session = request.cookies.get('session');
  if (!session && request.nextUrl.pathname.startsWith('/app')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  return NextResponse.next();
}
둘째 계층 — 레이아웃·tsx
// app/(app)/layout.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function AppLayout({ children }: { children: React.ReactNode }) {
  const session = await auth();
  if (!session) redirect('/login');
  return (
    <>
      <AppNav user={session.user} />
      {children}
    </>
  );
}
셋째 계층 — 모든 액션 검증·ts
'use server';
import { auth } from '@/auth';

export async function deleteAccount() {
  const session = await auth();
  if (!session) throw new Error('Unauthorized');
  await db.user.delete({ where: { id: session.user.id } });
}

External links

Exercise

민감한 작업 하나에 Proxy·레이아웃·액션 검사를 둬. 각 앞 경계를 우회해도 가장 깊은 검사가 다른 사용자와 익명 요청을 막는지 확인해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.