Full output
Claude Opus 5 원문 결과물
세션 인증 PR 코드 리뷰 / Anthropic / Claude Opus 5 / 실행일 2026-08-01
요약
이 PR은 현재 상태로 머지할 수 없습니다. middleware.ts가 쿠키 값의 유효성을 전혀 검증하지 않아 임의의 access_token 쿠키만으로 /dashboard·/settings가 열리고, verifyAccessToken이 exp를 확인하지 않으며 AUTH_SECRET 미설정 시 하드코딩된 "dev-secret"으로 서명하므로 토큰 위조와 무기한 세션 유지가 모두 성립합니다. 여기에 리프레시 회전이 이전 토큰을 폐기하지 않아 PR의 핵심 기능인 "회전"이 실제로는 토큰 누적일 뿐이고, CDN 앞단에서 Set-Cookie가 실린 인증 응답이 캐시될 여지가 남아 있습니다.
결함
-
[Critical] 미들웨어가 쿠키 존재만 확인해 보호 경로 인증을 우회할 수 있다
- 위치:
middleware.ts·middleware - 문제 코드:
const token = req.cookies.get("access_token")?.value; if (!token) { return NextResponse.redirect(new URL("/login", req.url)); } return NextResponse.next(); - 영향: 서명·만료·페이로드 검증이 전혀 없어 값이 무엇이든 통과합니다. 로그인하지 않은 공격자가
curl -H 'Cookie: access_token=x' https://app.example.com/dashboard를 보내거나 브라우저 콘솔에서document.cookie = "access_token=x; path=/"를 실행하면(로그인 전이라 동일 이름의 httpOnly 쿠키가 없으므로 JS 쓰기가 막히지 않습니다)/dashboard,/settings하위 전 경로가 그대로 렌더링됩니다. 이 PR이 내세운 유일한 페이지 보호 장치가 무력화됩니다. - 수정 방향: 미들웨어에서 서명과
exp를 실제로 검증하고 실패 시/login으로 리다이렉트합니다. 미들웨어는 Edge 런타임에서 실행되어node:crypto를 쓸 수 없으므로,lib/auth/tokens.ts의sign/verifyAccessToken을 Web Crypto(crypto.subtle.importKey+sign/verify)로 재작성해 Node 라우트와 미들웨어가 같은 구현을 공유하게 합니다. 그 전까지는 페이지 데이터 로드 지점에서 서버 측 재검증을 필수로 두어, 미들웨어 통과만으로 보호 데이터가 노출되지 않게 합니다.
- 위치:
-
[Critical]
AUTH_SECRET이 없으면 공개된 기본 시크릿으로 서명해 임의 사용자 토큰을 위조할 수 있다- 위치:
lib/auth/tokens.ts·SECRET - 문제 코드:
const SECRET = process.env.AUTH_SECRET ?? "dev-secret"; - 영향: 다중 인스턴스 배포에서 새 인스턴스나 신규 환경에 환경변수 주입이 누락되면 프로세스가 정상 기동한 채
"dev-secret"으로 서명·검증합니다. 이 값은 저장소에 그대로 적혀 있으므로, 공격자가{"sub":"<관리자 id>","email":"admin@example.com","exp":<미래값>}를 base64url로 인코딩하고 HMAC-SHA256을 붙이면 유효한 access token이 됩니다./api/auth/me는 이 토큰으로 해당 사용자의 역할과isAdmin을 반환하고, 이후 이 토큰을 신뢰하는 모든 경로가 관리자 세션으로 열립니다. 기동에 실패하지 않으므로 배포 사고로 인지되지도 않습니다. - 수정 방향: 폴백을 제거하고 모듈 로드 시점에
if (!process.env.AUTH_SECRET) throw new Error("AUTH_SECRET is required")로 즉시 기동을 실패시킵니다. 최소 32바이트 길이 검증도 함께 두고, 로컬 개발용 값은.env.local로 분리합니다.
- 위치:
-
[Critical]
verifyAccessToken이exp를 검증하지 않아 access token이 영구적으로 유효하다- 위치:
lib/auth/tokens.ts·verifyAccessToken - 문제 코드:
if (sign(body) !== signature) { return null; } const payload = JSON.parse( Buffer.from(body, "base64url").toString(), ) as any; return payload; - 영향:
exp를 페이로드에 넣기만 하고 어디서도 비교하지 않습니다. 쿠키maxAge는 브라우저 보관 기간일 뿐이라 토큰 문자열 자체를 얻은 공격자에게는 아무 제약이 되지 않습니다. XSS·로그 유출·프록시 로그 등으로 access token 한 개가 새면 15분이 아니라 시크릿을 교체할 때까지 무기한으로/api/auth/me및 access token을 신뢰하는 모든 경로에 접근할 수 있습니다. 비밀번호를 바꿔도 무효화되지 않습니다. - 수정 방향: 서명 검증 직후
if (typeof payload?.exp !== "number" || payload.exp < Date.now()) return null;를 추가하고,sub·email이 문자열인지까지 확인한 뒤AccessPayload로 반환합니다.
- 위치:
-
[Critical] 리프레시 회전이 이전 토큰을 폐기하지 않아 탈취된 refresh token이 계속 유효하다
- 위치:
app/api/auth/refresh/route.ts·POST(lib/auth/tokenStore.ts·deleteRefreshToken미사용) - 문제 코드:
const nextRefreshToken = issueRefreshToken(); await saveRefreshToken(nextRefreshToken, record.userId); - 영향: 새 토큰을 저장할 뿐
record.token을 삭제·무효화하지 않습니다. 공격자가 refresh token 한 개를 탈취하면, 피해자가 이후 정상적으로 몇 번을 갱신하든 탈취본은 발급 후 14일 내내 유효해 언제든 새 access token을 받아 갈 수 있습니다. 회전 방식이 제공해야 할 재사용 탐지(이미 사용된 refresh token이 다시 오면 해당 사용자 세션 전체 폐기)도 성립하지 않으므로 침해를 감지할 수단이 없습니다. 부수적으로refreshToken테이블 행이 갱신할 때마다 무한 증가하며 만료 행을 지우는 경로도 없습니다. - 수정 방향: 새 토큰 저장과 이전 토큰 삭제를 하나의 트랜잭션으로 묶습니다(
db.$transaction([db.refreshToken.delete({ where: { token } }), db.refreshToken.create({ ... })])). 재사용 탐지를 위해 즉시 삭제 대신usedAt/replacedBy를 기록하고, 이미 사용된 토큰이 다시 들어오면 해당userId의 refresh token을 전부 폐기한 뒤 401을 반환합니다. 만료 행 정리 작업도 함께 둡니다.
- 위치:
-
[Critical] 인증 응답에 캐시 금지 헤더가 없어 앞단 CDN이 세션 쿠키와 사용자 정보를 공유 캐시에 담을 수 있다
- 위치:
app/api/auth/login/route.ts,app/api/auth/refresh/route.ts,app/api/auth/me/route.ts· 각 응답 생성부 - 문제 코드:
const res = NextResponse.json({ userId: user.id }); res.cookies.set("access_token", accessToken, { ... });return NextResponse.json({ id: user.id, email: user.email, roles: roleNames, isAdmin: hasAdminAccess(roleNames), }); - 영향: 세 응답 모두
Cache-Control을 지정하지 않습니다. 앞단 CDN이 200 응답을 기본 TTL로 캐시하는 설정이면,/api/auth/me의 200 응답이 캐시되어 A 사용자의 이메일·역할·isAdmin이 뒤이어 요청한 B 사용자에게 그대로 내려갑니다. 로그인/갱신 응답이 캐시되는 경우에는Set-Cookie까지 함께 재사용되어 다른 사용자가 A의 세션 쿠키를 그대로 받아 계정을 탈취합니다. 애플리케이션 로그에는 정상 200만 남아 원인 추적도 어렵습니다. - 수정 방향: 모든 인증 응답에
res.headers.set("Cache-Control", "no-store")를 붙이고,/api/auth/me에는Vary: Cookie도 함께 지정합니다. CDN 쪽에서도/api/auth/*경로를 캐시 제외로 명시해 이중으로 막습니다.
- 위치:
-
[Major] 로그인 실패 응답이 갈라져 등록된 이메일을 열거할 수 있다
- 위치:
app/api/auth/login/route.ts·POST - 문제 코드:
if (!user) { return NextResponse.json( { error: "등록되지 않은 이메일입니다." }, { status: 404 }, ); } - 영향: 기존에는 단일 401이던 응답을 미가입 404 / 비밀번호 불일치 401로 나눴습니다. 공격자가 유출된 이메일 목록을 임의 비밀번호로 순회하면 상태 코드만 보고 이 서비스의 가입자 명단을 그대로 추려낼 수 있고, 추려낸 계정에만 크리덴셜 스터핑을 집중할 수 있습니다. 같은 원인으로 미가입 경로는 bcrypt 검증을 건너뛰어 응답 시간이 눈에 띄게 짧아, 상태 코드를 동일하게 바꿔도 타이밍만으로 구분이 가능합니다. 이 PR의 회귀입니다.
- 수정 방향: 두 경우 모두 401과 동일한 메시지("이메일 또는 비밀번호가 올바르지 않습니다.")로 되돌립니다. 사용자를 찾지 못한 경우에도 더미 해시로
verifyPassword를 수행해 응답 시간을 맞춥니다.
- 위치:
-
[Major] refresh 쿠키 수명(7일)이 저장소 TTL·PR 설명(14일)과 어긋난다
- 위치:
app/api/auth/login/route.ts,app/api/auth/refresh/route.ts·res.cookies.set("refresh_token", ...)(lib/auth/tokenStore.ts·REFRESH_TTL_MS) - 문제 코드:
res.cookies.set("refresh_token", refreshToken, { ... maxAge: 60 * 60 * 24 * 7, });const REFRESH_TTL_MS = 14 * 24 * 60 * 60 * 1000; - 영향: DB 레코드와 PR 설명은 14일인데 쿠키는 7일입니다. 갱신 요청을 보내지 않는 사용자(예: 주말 포함 8일 만에 재방문)는 refresh token이 서버에 멀쩡히 살아 있는데도 쿠키가 사라져 로그인 화면으로 밀려납니다. 반대로 서버에는 아무도 제시할 수 없는 레코드가 7일 더 남아 4번 항목의 행 누적을 키웁니다. 두 값이 상수로 각각 흩어져 있어 이후 수정 때도 다시 어긋나기 쉽습니다.
- 수정 방향:
REFRESH_TTL_MS를 단일 출처로 두고maxAge: REFRESH_TTL_MS / 1000으로 계산해 로그인·갱신 양쪽에 같은 값을 쓰게 합니다. 쿠키 설정 코드도 공통 헬퍼로 묶어 두 라우트가 같은 옵션을 공유하게 합니다.
- 위치:
-
[Major] refresh token을 평문으로 저장해 DB 열람만으로 계정을 탈취할 수 있다
- 위치:
lib/auth/tokenStore.ts·saveRefreshToken,readRefreshToken - 문제 코드:
await db.refreshToken.create({ data: { token, userId, expiresAt: Date.now() + REFRESH_TTL_MS }, }); - 영향: refresh token은 비밀번호 없이 access token을 계속 받아 낼 수 있는 베어러 자격증명인데 그대로 저장됩니다. 읽기 전용 DB 계정, 스테이징으로 복사한 운영 백업, SQL 인젝션이나 관리 콘솔 조회 등 "테이블을 읽을 수 있는" 모든 경로가 곧바로 전체 사용자 계정 탈취로 이어집니다. 4번 항목과 겹쳐, 폐기되지 않은 과거 토큰까지 전부 즉시 사용 가능한 상태로 남아 있습니다.
- 수정 방향: 원문 토큰은 쿠키로만 내보내고 저장은
sha256(token)해시로 합니다.readRefreshToken도 해시로 조회하도록 바꿉니다(랜덤 32바이트라 별도 솔트·느린 해시는 불필요합니다). 조회 성능을 위해 해시 컬럼에 유니크 인덱스를 둡니다.
- 위치:
-
[Minor]
getUserWithRoles가 역할 수만큼 쿼리를 반복한다(N+1)- 위치:
lib/auth/users.ts·getUserWithRoles - 문제 코드:
const roles = []; for (const roleId of user.roleIds) { const role = await db.role.findUnique({ where: { id: roleId } }); if (role) { roles.push(role); } } - 영향: 역할이 5개인 사용자의
/api/auth/me한 번에 6개의 쿼리가 순차로 발생합니다. 5번 항목을 고쳐 이 응답을no-store로 만들면 매 페이지 진입마다 원본까지 도달하므로, 사용자 수 × 역할 수만큼 DB 커넥션 점유와 지연이 곱해집니다. 다중 인스턴스에서 트래픽이 몰릴 때 커넥션 풀 고갈로 이어질 수 있습니다. - 수정 방향:
db.role.findMany({ where: { id: { in: user.roleIds } } })한 번으로 대체합니다. Prisma 스키마에 user–role 관계가 정의되어 있다면db.user.findUnique({ where: { id: userId }, include: { roles: true } })로 단일 쿼리로 끝냅니다.
- 위치:
-
[Minor] 서명 비교가 상수 시간이 아니다
- 위치:
lib/auth/tokens.ts·verifyAccessToken - 문제 코드:
if (sign(body) !== signature) { - 영향: JS 문자열
!==는 첫 불일치 문자에서 즉시 종료합니다. 공격자가 같은 body에 서명 후보를 바꿔 가며 대량 요청을 보내 응답 시간 분포로 앞자리부터 맞춰 나가는 시도가 이론적으로 가능합니다. 네트워크 지터 때문에 실제 성공 난도는 높지만, 인증 경로에서 굳이 남길 이유가 없는 약점입니다. - 수정 방향:
crypto.timingSafeEqual(Buffer.from(sign(body)), Buffer.from(signature))로 비교하되, 길이가 다르면 먼저false를 반환해 예외를 피합니다.
- 위치:
-
[Minor] 토큰 페이로드를 검증 없이
as any로 반환하고JSON.parse예외를 처리하지 않는다- 위치:
lib/auth/tokens.ts·verifyAccessToken - 문제 코드:
const payload = JSON.parse( Buffer.from(body, "base64url").toString(), ) as any; return payload; - 영향: 반환 타입은
AccessPayload | null인데 실제로는 검증되지 않은 값이any를 거쳐 통과하므로,payload.sub가undefined거나 객체여도 타입 검사에 걸리지 않고getUserWithRoles(undefined)까지 흘러갑니다. 또JSON.parse실패가 잡히지 않아 서명 검증을 통과한 손상된 토큰(2번 항목처럼 시크릿이 알려진 상황 포함)에서 401 대신 500이 나가고, 스택 트레이스가 중앙 로그에 30일간 쌓입니다. - 수정 방향:
try/catch로 감싸 파싱 실패 시null을 반환하고,sub·email이 문자열이고exp가 숫자인지 확인한 뒤에만AccessPayload로 반환합니다.as any캐스팅은 제거합니다.
- 위치:
-
[Minor] 로그인 요청 본문을 검증하지 않아 잘못된 입력이 500으로 나간다
- 위치:
app/api/auth/login/route.ts·POST - 문제 코드:
const { email, password } = await req.json(); - 영향: 본문이 JSON이 아니면
req.json()이 던지는 예외가 잡히지 않아 400이어야 할 요청이 500이 됩니다.{"email": null}처럼 필드가 빠지면findUserByEmail(null)이 Prisma 층에서 터지고,{"email": {"contains": "@"}}같은 객체를 넣으면 Prisma가 조건 객체로 해석하려다 예외를 냅니다. 봇 트래픽만으로 5xx 알람이 오염되고 인증 경로의 실제 장애를 가립니다. - 수정 방향:
try/catch로 파싱을 감싸고,email·password가 비어 있지 않은 문자열인지(zod 등 스키마 검증 또는 명시적typeof확인) 검사한 뒤 실패 시 400을 반환합니다.
- 위치:
-
[Minor] 로그인 실패 시 이메일을 로그에 남긴다
- 위치:
app/api/auth/login/route.ts·POST - 문제 코드:
console.warn(`login failed for ${email}`); - 영향: 개인정보인 이메일이 중앙 로그 수집기에 30일간 평문으로 축적되어, 로그 열람 권한만 있으면 실패 이력이 있는 사용자 목록을 볼 수 있습니다. 이메일 입력란에 비밀번호를 잘못 친 사용자의 자격증명이 그대로 저장되는 경우도 생깁니다. 로그 보관 기간 동안 삭제 요청 대응 범위도 넓어집니다.
- 수정 방향: 식별자를 해시하거나
user.id만 남기고, 이벤트 종류·시각·IP 등 구조화 필드로 기록합니다. 원문 이메일이 반드시 필요하면 마스킹(a***@example.com) 후 기록합니다.
- 위치:
확인 필요
refreshToken모델의expiresAt컬럼 타입:saveRefreshToken은Date.now()숫자를 넣고readRefreshToken은found.expiresAt < Date.now()로 숫자 비교합니다. 스키마가DateTime이면 생성 시점 또는 만료 판정이 어긋납니다. Prisma 스키마가 diff에 없어 결함으로 단정하지 않습니다.- 같은 함수의 반환 타입
Promise<RefreshRecord | null>: Prisma가 돌려주는 레코드에id·createdAt같은 추가 필드가 있을 텐데 캐스팅 없이 선언 타입과 맞는지, 즉RefreshRecord가 실제 모델과 정합한지 확인이 필요합니다. refreshToken.token컬럼의 유니크 인덱스 여부:findUnique({ where: { token } })가 성립하려면 유니크 제약이 있어야 합니다. 이번 PR에 마이그레이션이 포함되어 있는지 확인해 주세요.ADMIN_ROLES에"billing"이 들어간 것이 의도인지: 현재 정의상 결제 담당자에게isAdmin: true가 내려갑니다. 프런트엔드가 이 값으로 관리 메뉴를 노출한다면 권한 확대가 되는데, 제품 정책을 모르는 상태라 결함으로 적지 않았습니다. 역할 목록을 코드 상수로 고정할지 DB 권한으로 뺄지도 함께 정해야 합니다.createSessionToken→issueAccessToken이름 변경의 나머지 호출부: diff에는 로그인 라우트만 담겨 있고 시그니처도user.id단일 인자에서 객체 인자로 바뀌었습니다. diff 밖 호출부가 모두 정리되었는지 확인해 주세요.- access 쿠키 만료 이후의 갱신 흐름:
maxAge15분이 지나면 쿠키가 사라져 미들웨어가 곧장/login으로 보냅니다./api/auth/refresh를 만료 전에 호출하는 클라이언트 코드가 이번 PR 범위 밖에 있는지, 없다면 15분마다 강제 로그아웃이 되는 것은 아닌지 확인이 필요합니다. - 로그인·갱신 경로의 요청 빈도 제한: 6번을 고쳐 응답을 통일해도 비밀번호 대입 자체를 막지는 못합니다. CDN이나 WAF 층에 레이트 리밋이 이미 있는지 알려 주세요.
secure: true고정: 로컬 개발이http://127.0.0.1이라면 쿠키가 설정되지 않습니다. 개발 환경에서 HTTPS를 쓰는지, 환경별 분기가 필요한지 확인이 필요합니다.
종합 판단
반려 — 쿠키 값 미검증에 따른 보호 경로 우회, 기본 시크릿 폴백에 의한 토큰 위조, exp 미검증으로 인한 무기한 세션, 이전 refresh token 미폐기까지 인증 체계의 근간이 성립하지 않아, 개별 수정이 아니라 발급·검증·회전 경로 전체를 다시 짜고 재리뷰해야 합니다.