본문으로 건너뛰기
CodeAuditAgent
전체 글

Next.js App Router 보안 체크리스트

서버 액션, 라우트 핸들러, 미들웨어, 환경 변수, CSRF, 헤더, 웹훅, 사용자별 캐싱을 다루는 App Router 보안 체크리스트.

· 8분 분량 · Lina Source LLC

App Router는 많은 코드를 브라우저에서 서버로 되돌려 놓았습니다. 대체로 보안에는 이득입니다. 쿼리와 시크릿, 비즈니스 로직이 이제 사용자가 읽을 수 없는 곳에서 실행되니까요. 동시에 무엇이 공개 엔드포인트이고 무엇이 그저 함수 호출처럼 보이는지의 경계를 흐려 놓았습니다. Next.js 앱에서 발견되는 심각한 버그 대부분은 그 흐릿함에서 나옵니다.

이 체크리스트는 App Router 코드베이스에서 가장 자주 마주치는 문제들을, 확인할 가치가 있는 순서대로 다룹니다. 특별한 것은 하나도 없습니다. 각 항목은 프레임워크의 편의성이 신뢰 경계를 가리는 지점입니다.

1. 서버 액션은 공개 엔드포인트입니다

'use server'로 표시된 함수는 HTTP 엔드포인트로 컴파일됩니다. 사이트를 열 수 있는 사람이라면 누구나 액션 ID를 찾아 임의의 인자로 호출할 수 있으며, 그 액션을 쓰는 버튼을 UI가 렌더링하든 하지 않든 상관없습니다. 관리자가 아닌 사람에게 폼을 숨긴다고 해서 그 뒤의 액션이 보호되지는 않습니다.

모든 액션에는 다른 API 핸들러와 똑같은 세 단계가 필요합니다. 호출자를 인증하고, 입력을 검증하고, 건드리는 특정 레코드에 대해 인가하는 것입니다. 이 검사들은 액션 본문 안에 두세요. 폼을 렌더링하는 페이지 컴포넌트의 검사는 액션이 호출될 때가 아니라 페이지가 로드될 때 실행되므로 아무것도 보호하지 못합니다.

'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

const Input = z.object({
  projectId: z.string().uuid(),
  name: z.string().trim().min(1).max(100),
});

export async function renameProject(formData: FormData) {
  const session = await auth();
  if (!session?.user) throw new Error('Unauthorized');

  const { projectId, name } = Input.parse({
    projectId: formData.get('projectId'),
    name: formData.get('name'),
  });

  // 소유권 검사는 별도의 조회가 아니라 쓰기의 일부입니다
  const result = await db.project.updateMany({
    where: { id: projectId, ownerId: session.user.id },
    data: { name },
  });
  if (result.count === 0) throw new Error('Not found');

  revalidatePath('/projects');
}

액션을 여러 개 내보내는 헬퍼 파일을 주의하세요. 'use server' 모듈에서 내보낸 모든 함수는 호출 가능하며, 여기에는 누군가 내부 스크립트용으로 추가하고 잊어버린 것도 포함됩니다.

2. 라우트 핸들러에도 같은 검사가 필요합니다

app/api의 라우트 핸들러는 누가 봐도 엔드포인트이지만 실패 유형은 같습니다. 동적 세그먼트로 레코드를 조회하면서 누구의 것인지 확인하지 않는 것이죠. 최근 Next.js 버전에서 params는 Promise입니다. await하고, 검증하고, 쿼리를 호출자 범위로 한정하세요.

// app/api/documents/[id]/route.ts
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const session = await auth();
  if (!session?.user) {
    return Response.json({ error: 'unauthorized' }, { status: 401 });
  }

  const { id } = await params;
  const doc = await db.document.findFirst({
    where: { id, ownerId: session.user.id },
  });
  if (!doc) return Response.json({ error: 'not_found' }, { status: 404 });

  return Response.json(doc, {
    headers: { 'Cache-Control': 'private, no-store' },
  });
}

호출자의 것이 아닌 레코드에는 403이 아니라 404를 반환해, 엔드포인트가 어떤 ID가 존재하는지 확인해 주지 않게 하세요. 인가 판단에 params와 searchParams, 헤더, 쿠키를 신뢰하지 마세요. 모두 공격자가 제어하는 입력입니다.

3. 미들웨어만으로는 인증 경계가 아닙니다

미들웨어(Next.js 16에서는 proxy로 이름이 바뀌었습니다)는 로그아웃한 사용자를 리디렉션하고 헤더를 설정하는 데 유용합니다. 인가가 이루어지는 유일한 장소여서는 안 됩니다. 매처는 틀리기 쉽고, 새 라우트가 그 바깥에 추가되며, 서버 액션은 페이지 경로로 POST하는데 그것이 여러분이 염두에 둔 패턴과 맞지 않을 수 있습니다. 2025년에는 CVE-2025-29927을 통해, 조작된 내부 헤더가 일부 Next.js 버전에서 미들웨어를 완전히 건너뛰게 만들 수 있다는 것이 드러났습니다.

미들웨어는 편의 계층으로 취급하세요. 진짜 검사는 데이터 옆에, 즉 각 액션과 핸들러 안에 두거나, 더 나아가 모든 서버 측 읽기가 거치는 데이터 접근 계층에 두어야 합니다. 데이터 접근 계층은 getProjectForUser(projectId) 같은 함수를 노출하면서 그 안에서 세션 검사와 소유권 필터를 수행하는 단일 서버 전용 모듈입니다. 페이지와 액션, 라우트 핸들러가 데이터베이스 클라이언트를 직접 쓰지 않고 이 계층을 호출하므로, 새 라우트가 검사를 잊어버릴 수 없습니다. 잊어버릴 만한 미검사 경로 자체가 없기 때문입니다.

4. 서버 코드를 서버에 두세요

NEXT_PUBLIC_ 접두사가 붙은 환경 변수는 빌드 시점에 JavaScript 번들에 인라인되어 모든 방문자에게 보입니다. 공개용 Stripe 키나 분석 ID라면 올바른 동작이지만, 그 밖의 것에는 유출입니다. 코드베이스에서 NEXT_PUBLIC_을 검색해, 각 값이 옥외 광고판에 걸려도 괜찮을지 확인하세요. 반대 방향의 실수도 일어납니다. 접두사가 없는 변수를 클라이언트 컴포넌트에서 읽어 브라우저에서 undefined가 나오자, 오류를 없애려고 이름을 NEXT_PUBLIC_으로 바꿔 버리는 것이죠. 어떤 값이 브라우저에 필요하다면, 먼저 브라우저가 그것을 가져도 되는지부터 물어보세요.

// lib/dal.ts
import 'server-only';
import { cache } from 'react';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export const getCurrentUser = cache(async () => {
  const session = await auth();
  if (!session?.user) return null;
  // UI에 필요한 필드만 반환하고, 행 전체는 절대 반환하지 않습니다
  return db.user.findUnique({
    where: { id: session.user.id },
    select: { id: true, name: true, plan: true },
  });
});

server-only 패키지는 클라이언트 컴포넌트가 해당 모듈을 임포트하면 빌드를 실패시켜, 데이터베이스 클라이언트와 시크릿을 읽는 헬퍼가 브라우저로 흘러가지 않게 보호합니다. 서버 컴포넌트가 클라이언트 컴포넌트에 props로 무엇을 넘기는지도 살피세요. 그 경계를 넘는 모든 것은 페이지에 직렬화되므로, 사용자 행 전체를 넘기면 비밀번호 해시와 내부 플래그까지 브라우저로 전달됩니다.

5. CSRF: 프레임워크가 무엇을 막아 주는지 아세요

서버 액션은 POST만 받으며, Next.js는 실행 전에 Origin 헤더와 호스트를 비교합니다. 프록시 뒤나 여러 도메인에 배포한다면 오류가 사라질 때까지 범위를 넓히지 말고 serverActions.allowedOrigins를 신중하게 설정하세요.

라우트 핸들러에는 그런 보호가 없습니다. POST나 PUT, DELETE 핸들러가 쿠키로 인증한다면 다른 사이트의 폼이 여전히 그것에 제출할 수 있습니다. 콘텐츠 타입 확인만으로는 부족합니다. HTML 폼은 프리플라이트 없이 urlencoded와 multipart, text/plain 본문을 보낼 수 있고, 본문을 느슨하게 파싱하는 핸들러는 그것을 받아들입니다. 세션 쿠키에 SameSite=Lax나 Strict를 설정하고, GET으로는 절대 상태를 바꾸지 말고, 쿠키로 인증되는 변경 요청에는 Origin 헤더를 확인하세요. Authorization 헤더의 베어러 토큰만 받는 핸들러는 전통적인 CSRF에 노출되지 않습니다.

6. 보안 헤더와 CSP

Next.js는 기본적으로 보안 헤더를 거의 보내지 않습니다. next.config의 headers() 함수로 추가하거나, 엄격한 Content-Security-Policy를 위해 요청별 nonce가 필요하다면 미들웨어에서 추가하세요.

  • Content-Security-Policy, 가능하면 nonce 기반으로, object-src 'none'과 base-uri 'self'를 포함해서.
  • HTTPS가 모든 곳에서 안정되면 긴 max-age의 Strict-Transport-Security.
  • X-Content-Type-Options: nosniff.
  • Referrer-Policy: strict-origin-when-cross-origin.
  • 클릭재킹을 막기 위한 CSP의 frame-ancestors 또는 X-Frame-Options.
  • X-Powered-By 헤더를 없애기 위한 next.config의 poweredByHeader: false.

7. 속도 제한

내장 속도 제한 기능은 없습니다. 로그인과 회원 가입, 비밀번호 재설정, OTP 인증, 그리고 호출당 비용이 드는 모든 것(이메일, SMS, AI 요청)에는 사용자별·IP별 제한이 필요합니다. 인메모리 카운터는 서버리스나 다중 인스턴스 배포에서 동작하지 않으므로 Redis나 데이터베이스 같은 공유 저장소를 쓰세요. 제한은 미들웨어에만 두지 말고 인증된 사용자를 알 수 있는 액션이나 핸들러 안에서 적용하세요. 공격자가 값싸게 바꿀 수 없는 것을 기준으로 제한을 거세요. 인증된 라우트에는 계정을, 재설정과 OTP 흐름에는 대상 이메일이나 전화번호를 IP와 함께 쓰는 식입니다. 정상적인 클라이언트가 올바르게 물러설 수 있도록 429와 Retry-After 헤더를 반환하세요.

8. 웹훅은 원시 본문으로 검증하세요

웹훅 서명은 제공자가 보낸 바이트 그대로를 대상으로 계산됩니다. 본문을 JSON으로 파싱한 뒤 다시 직렬화하면 그 바이트가 달라져 검증이 실패하고, 그래서 검증을 건너뛰고 싶은 유혹이 생깁니다. 라우트 핸들러에서는 req.text()로 본문을 읽고 다른 무엇보다 먼저 검증하세요.

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('stripe-signature');
  if (!signature) return new Response('Missing signature', { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  // 핸들러는 멱등이어야 합니다. 제공자는 재시도하며 두 번 전달할 수 있습니다
  await handleStripeEvent(event);
  return new Response('ok');
}

처리한 이벤트 ID를 고유 제약과 함께 저장해, 재시도되거나 재전송된 전달이 구독을 두 번 부여하지 않게 하세요. 또한 제공자가 전달 순서를 보장하지 않으므로 순서가 뒤바뀌어 도착하는 이벤트도 처리하세요.

9. 사용자별 데이터를 전역으로 캐싱하지 마세요

App Router는 적극적으로 캐싱하며 기본값은 버전마다 바뀌어 왔습니다. unstable_cache나 'use cache'로 감싼 함수가 '현재 사용자'의 데이터를 반환하면서 캐시 키에 사용자 ID를 포함하지 않으면, 한 사용자의 데이터를 다음 사용자에게 제공하게 됩니다. 동적이어야 했는데 정적으로 렌더링된 페이지, 그리고 API 응답의 CDN 캐싱도 마찬가지입니다. 직접 테스트해 보세요. 두 브라우저에서 서로 다른 두 사용자로 로그인해 같은 페이지를 열어 보는 것입니다. 두 번째 사용자에게 첫 번째 사용자의 데이터가 보인다면 캐시 버그이고, 대개 심각한 문제입니다.

  • 사용자 ID나 테넌트 ID를 캐시된 함수에 명시적으로 전달해 키의 일부가 되게 하세요.
  • 캐시된 함수 안에서 쿠키나 헤더를 읽지 마세요. 바깥에서 읽어 값으로 전달하세요.
  • 사용자별 데이터를 담은 응답에는 Cache-Control: private, no-store를 보내세요.
  • 빌드 출력을 확인하세요. 동적이어야 할 라우트가 정적으로 표시되어서는 안 됩니다.

체크리스트 실행하기

파일 단위가 아니라 라우트 단위로 목록을 훑으세요. 각 액션과 핸들러에 대해 누가 호출할 수 있는지, 어떤 입력을 신뢰하는지, 무엇을 캐싱하는지, 무엇을 반환하는지 확인하는 것입니다. CodeAuditAgent는 공개 GitHub 저장소나 붙여넣은 스니펫에 대해 1차 점검을 수행해 각 발견 사항을 심각도와 CWE, 인용한 코드 줄, 제안 패치와 함께 보고합니다. 어떤 라우트가 애초에 공개여야 하는가 같은 설계 판단은 여전히 팀의 몫입니다.