Next.js 인증 및 세션
백엔드로부터 세션(토큰 또는 세션 ID)을 받아 저장, 갱신 및 검증해야 하는 프론트엔드에 관한 문서입니다. 프로젝트가 라이브러리나 제공자(Auth.js, Better Auth, Clerk 등)를 사용하는 경우 세션 메커니즘은 해당 라이브러리가 처리하지만, 섹션 2, 6~10의 규칙은 여전히 적용됩니다. 코드는 references/implementation.md를 참조하세요.
규칙 우선순위. 사용자 지침과 프로젝트의 agent 파일이 더 중요합니다. 백엔드가 다른 방식을 강제하는 경우(예: 자체적으로 쿠키를 설정하는 경우) 백엔드를 따르고 그 차이점을 README 또는 ADR에 기록하세요.
표기: MUST — 위반할 수 없음; SHOULD — 기본적으로 따라야 하며, 예외는 이유가 필요함.
1. 모델 선택
| 모델 | 사용 시기 |
|---|---|
| BFF: Next route handler가 백엔드로부터 토큰을 받아 HttpOnly 쿠키에 저장 | 백엔드가 응답 본문에 토큰을 반환하는 경우. Next의 기본 옵션 |
| 백엔드가 직접 HttpOnly 쿠키 설정 | 프론트엔드와 API가 같은 사이트에 있고, 백엔드를 제어할 수 있는 경우 |
| 라이브러리 또는 제공자 | OAuth, MFA, 사용자 관리가 필요한 경우 — 직접 구현하지 마세요 |
| Access는 메모리에, refresh는 HttpOnly 쿠키에 | BFF가 없는 SPA |
토큰을 localStorage/sessionStorage에 저장 | 금지: XSS 공격 하나로 세션이 탈취됨 |
두 번째 결정 사항 — BFF가 프록시할 내용:
| 옵션 | 장점 | 단점 |
|---|---|---|
| login / refresh / logout만 프록시, 나머지 요청은 API에 직접 쿠키와 함께 전송 | BFF가 병목이 아님; 코드 감소 | API가 쿠키에서 토큰을 읽고 CORS를 구성해야 함; 쿠키가 API 도메인에 도달해야 함 |
| 모든 요청을 BFF를 통해 전송 | API가 쿠키와 CORS를 몰라도 됨; 단일 origin | 모든 요청에 추가 홉 발생; BFF 확장 필요 |
선택 사항은 ADR에 기록합니다.
2. 쿠키 (MUST)
- 접근 권한을 부여하는 모든 것(access, refresh, 세션 ID)에 대해
HttpOnly. - 프로덕션에서는
Secure. 로컬에서 브라우저는http://localhost에서Secure를 허용하지만, 다른 http 호스트에서는 허용하지 않습니다. - 기본적으로
SameSite=Lax.Strict는 CSRF에 대해 더 안전하지만, 이메일이나 다른 사이트의 링크를 통해 방문한 사용자가 첫 번째 탐색에서는 인증되지 않은 것으로 보입니다 — 의식적으로 선택하고 그 이유를 기록하세요. Path=/; 수명은 토큰의exp또는 백엔드 응답을 기준으로 하며, '대충' 설정하지 마세요.- SHOULD: 이름에
__Host-접두사 사용(Secure,Path=/,Domain없음 필요) — 서브도메인에서 쿠키를 변조할 수 없습니다. - 쿠키는 서버에서만 설정 및 삭제됩니다(route handler, Server Action,
proxy.ts). Server Component는 쿠키를 설정할 수 없습니다. - JS에서 읽을 수 없는 쿠키가 유일한 세션 전달자입니다.
is_authenticated와 같은HttpOnly가 없는 플래그는 UI를 위한 힌트로만 허용되며, 접근 결정에 절대 사용되지 않습니다.
3. BFF: route handlers (MUST)
- 로그인은 스키마(
zod)로 유효성 검사; 잘못된 형식의 본문은400. - 변경하는 핸들러는
Origin을 애플리케이션의 origin과 비교:SameSite=Lax는 같은 사이트의 서브도메인으로부터의 요청을 보호하지 않습니다. Server Action은 자체적으로Origin과Host를 비교합니다. - 클라이언트 컨텍스트를 백엔드에 전달:
X-Forwarded-For,User-Agent— 그렇지 않으면 백엔드의 rate limit이 서버 IP만 볼 수 있습니다. - 백엔드 오류는
kind와 상태 코드(401,403,422,429(Retry-After포함),503)가 포함된 응답으로 변환되며, 백엔드 텍스트는 외부로 유출되지 않습니다. - Refresh: 백엔드가 refresh 토큰을 거부한 경우 — 쿠키를 삭제하고
401응답. 백엔드를 사용할 수 없는 경우(네트워크, 5xx) — 쿠키를 그대로 두고503응답: 일시적인 장애로 인해 로그아웃되면 안 됩니다. - Logout: 백엔드에서 토큰을 폐기(오류는 로깅하되 표시하지 않음), 어떤 경우든 쿠키를 삭제합니다.
4. 세션 확인 위치 (MUST)
| 위치 | 하는 일 | 하지 않는 일 |
|---|---|---|
proxy.ts (Next 16+, 이전에는 middleware.ts) | 쿠키 존재 여부에 따른 낙관적 확인: 로그인 페이지로 리디렉션 및 로그인 페이지에서 리디렉션 | DB나 API에 접근하지 않음; 유일한 보호 수단이 아님 |
Data Access Layer (server-only 모듈의 verifySession()) | 데이터 근처에서 확인: 서버 컴포넌트, route handler, Server Action | — |
| 레이아웃 | 아무것도 하지 않음: 레이아웃은 탐색 시 다시 렌더링되지 않음 | 접근을 확인하지 않음 |
| 백엔드 | 진실 공급원: 모든 요청에서 토큰 확인 | — |
- 모든 route handler와 Server Action은 공개 진입점이며 자체적으로
verifySession()을 호출합니다. proxy.ts는 refresh 쿠키(또는 세션 ID)를 확인합니다. access는 만료되었을 수 있지만 세션은 유효할 수 있습니다.- 경로 일치는 세그먼트 기준:
/app및/app/…와 일치하지만/apple과는 일치하지 않습니다. cacheComponents(Next 16.4 프로젝트에서 기본값)를 사용하는 경우, 세션 읽기 및 사용자 데이터 캐싱에는 고유한 규칙이 있습니다. DAL을 작성하기 전에node_modules/next/dist/docs/01-app/02-guides/authentication-with-cache-components.md를 읽으세요.- 권한(역할, 소유권)은 확인된 데이터(서명(JWKS,
jose) 또는 백엔드 응답)를 기반으로 결정됩니다. 확인되지 않은 JWT payload는 접근 근거가 될 수 없습니다. - Server Component는 쿠키를 업데이트할 수 없습니다. 서버 렌더링 시점에 access가 만료된 경우:
proxy.ts가 세션을 갱신하거나(응답에 쿠키를 설정할 수 있음 — 문서 탐색에 대해서만 수행하고 prefetch에는 수행하지 않으며, refresh 토큰 순환을 고려), 페이지가 클라이언트 로딩을 렌더링하고 클라이언트가 refresh를 수행합니다.
5. 클라이언트: 경쟁 상태 없는 refresh (MUST)
- '인증되지 않음' 판별: HTTP
401, 또는 GraphQL 응답(200)에서extensions.code === "UNAUTHENTICATED"인 오류가 하나 이상 있는 경우(errors.some,errors[0]아님). 메시지 텍스트 비교는 백엔드가 코드를 제공하지 않는 경우에만, 단일 함수에서, 주석과 테스트와 함께 수행합니다. - 단일 비행(Single-flight): 401을 받은 병렬 요청은 하나의 공통 refresh를 기다렸다가 그 결과를 사용합니다. Promise는
finally에서 재설정됩니다. - 원래 요청은 한 번 재시도됩니다. 두 번째 401은 세션이 손실된 것이며, 두 번째 refresh 없이 처리합니다.
- refresh 요청 자체는 인터셉터를 통과하지 않습니다(그렇지 않으면 재귀 발생).
- refresh 결과는 세 가지 결과를 구분합니다: 발급됨(
true) → 재시도; 거부됨(false) → 세션 만료; 네트워크 또는 5xx(예외) →network오류, 계정에서 로그아웃되지 않음. - 로그아웃 중에는 refresh가 차단됩니다. 그렇지 않으면 로그아웃 시점에 401로 실패한 요청이 즉시 세션을 복원합니다.
- 만료된 세션: 요청 캐시를 지우고 현재 페이지의
callbackUrl과 함께 로그인 페이지로 이동합니다. - 재시도할 요청의 본문은 재시도 가능해야 합니다(문자열,
FormData). 일회용ReadableStream은 안 됩니다.
6. 로그인 후 리디렉션 (MUST)
callbackUrl(next,returnTo)은getSafeRedirect(raw, fallback, appOrigin)함수로 확인합니다:\및 제어 문자를 거부하고(브라우저는/\.evil.com및/<tab>/evil.com을//evil.com으로 변환),new URL(raw, appOrigin)을 구문 분석하고, origin을 비교한 후 경로만 반환합니다. '/로 시작하고//로 시작하지 않음' 확인은 충분하지 않습니다.- 이에 대한 테스트 세트는
references/implementation.md, 섹션 7에 있습니다. - 로그인 후 서버 컴포넌트는 새 쿠키를 볼 수 있어야 합니다: 전체 탐색(
window.location.assign) 또는router.replace+router.refresh()를 사용합니다.
7. 로그아웃 (MUST)
- 서버가 쿠키를 삭제합니다(섹션 3).
- 클라이언트가 요청 캐시(
queryClient.clear())와 모든 클라이언트 측 사용자 데이터 스토어를 지웁니다: 동일한 탭의 다음 사용자는 잠시라도 이전 사용자의 데이터를 볼 수 없습니다. - 캐시된 보호 화면으로 '뒤로' 이동할 수 없도록 로그인 페이지로 이동합니다(
replace또는 전체 탐색). - 로그아웃 확인은 제품 선택 사항; 요청 중 버튼 차단은 필수.
- SHOULD: 다른 탭이 로그아웃을 알 수 있어야 함(
BroadcastChannel또는 플래그에 대한storage이벤트). 그렇지 않으면 첫 번째 401까지 데이터를 계속 표시합니다.
8. 링크의 토큰 (비밀번호 재설정, 이메일 확인)
- 토큰은 URL에서 한 번 읽어 상태에 저장한 후, 토큰 없이 주소를 즉시 대체합니다(
router.replace(pathname)): 기록에 남지 않고Referer로 전송되지 않습니다. - 이러한 페이지에는
Referrer-Policy: no-referrer를 사용하고 타사 스크립트(분석, 위젯)를 전혀 사용하지 않습니다. - 토큰은 로깅되지 않습니다. 일회성 및 수명은 백엔드가 보장합니다. 프론트엔드는 '링크가 만료되었습니다'라는 명확한 화면과 '새 링크 요청' 작업을 표시합니다.
9. 로그인 및 복구 폼
- 비밀번호 복구는 존재하는 이메일과 존재하지 않는 이메일에 대해 동일하게 응답합니다('계정이 있는 경우 이메일이 전송되었습니다'). 누가 등록되었는지 공개하지 않습니다. 백엔드가 공개하는 경우 제한 사항으로 기록합니다.
- 전송은 요청 중에 차단됩니다. 재전송은 두 번째 요청을 생성하지 않습니다.
429:Retry-After가 있는 경우 대기 시간 메시지를 표시합니다.- 필드:
type="email"+autoComplete="email"; 비밀번호는 로그인 시autoComplete="current-password", 등록 및 변경 시"new-password"; 붙여넣기를 차단하지 않습니다. - '비밀번호 표시' 버튼:
type="button",aria-label및aria-pressed, 필드에서 포커스를 빼앗지 않습니다. - '이메일 또는 비밀번호가 잘못되었습니다' 오류는 무엇이 잘못되었는지 구체적으로 알리지 않습니다.
- 서버 오류는 코드별로 필드에 매핑됩니다. 일반 오류는 폼 위에 표시됩니다.
10. 데이터 및 로그
- 사용자별 응답은 공유 캐시(
"use cache", CDN)에 저장되지 않습니다. 서버의 QueryClient는 요청당 새로 생성됩니다. - 로그에는 토큰, 비밀번호, 이메일이 없습니다. 최대 사용자 ID와 오류
kind만 기록합니다. - 세션 토큰 payload는 최소화합니다: ID와 역할, 개인 데이터는 포함하지 않습니다.
11. 테스트
- 단위:
getSafeRedirect(references의 전체 세트); '인증되지 않음' 판별 (401, GraphQL 코드, 빈 본문, JSON 대신 HTML). - 단일 비행: 401을 받은 N개의 병렬 요청 → 하나의 refresh, N번 재시도; refresh 거부됨 → 재시도 없음,
onSessionExpired한 번 호출; refresh 네트워크 오류 →network오류, 로그아웃 없음; 성공적인 refresh 후 두 번째 401 → 두 번째 refresh 없음; 로그아웃 중 → refresh 없음. - Route handlers: 쿠키 플래그; refresh 거부됨 → 쿠키 삭제; 백엔드 사용 불가 → 쿠키 유지,
503; 잘못된 본문 →400; 잘못된Origin→403. - e2e: 로그인 → 보호된 페이지 → 로그아웃 → 보호된 페이지 접근 불가; access 만료(테스트 환경에서 짧은 TTL) → 투명한 refresh; 비밀번호 재설정 링크 → 주소 표시줄에서 토큰 사라짐.
12. 완료 조건(Definition of Done)
- 어떤 토큰도 JS에서 읽히지 않음; 세션에
localStorage를 사용하지 않음. - 쿠키:
HttpOnly, 프로덕션에서Secure,SameSite,Path, 수명은 토큰 기준. - Refresh: 백엔드 거부와 백엔드 사용 불가를 구분함.
- 결과가 있는 단일 비행, 한 번 재시도, 로그아웃 중 차단 — 테스트로 검증됨.
-
proxy.ts— 낙관적 확인만 수행; DAL은 handlers, actions 및 서버 컴포넌트에서 호출됨. - 전체 테스트 세트가 포함된
getSafeRedirect. - 로그아웃 시 클라이언트 캐시 및 스토어 지움.
- 링크의 토큰이 주소 표시줄에서 제거됨.
- 로그에 토큰 및 개인 데이터 없음.
- 모델 및 프록시 선택이 ADR에 기록됨.