- Published on
NextAuth.js JWT 세션이 랜덤 로그아웃될 때 점검
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서비스 운영 중 가장 난감한 이슈 중 하나가 “가끔씩 로그아웃된다”입니다. 특히 NextAuth.js를 strategy: "jwt"로 쓰는 경우, 서버에 세션을 저장하지 않으니 더 단순할 것 같지만 실제로는 쿠키/토큰 만료, 서명 키 불일치, 시간 오차, 프록시 설정, 멀티 인스턴스 배포 등 여러 요인이 겹치며 랜덤 로그아웃처럼 보이는 현상이 발생합니다.
이 글은 “유저가 갑자기 로그인 페이지로 튕긴다”, “새로고침하면 세션이 null이다”, “특정 브라우저나 특정 환경에서만 재현된다” 같은 케이스에서, 원인을 빠르게 좁히고 재발 방지까지 하는 실전 점검 가이드입니다.
1) 증상을 먼저 분류: 정말 로그아웃인가, 세션 조회 실패인가
NextAuth에서 “로그아웃처럼 보임”은 크게 3가지로 나뉩니다.
- 쿠키가 브라우저에서 사라짐:
next-auth.session-token(또는 secure prefix)이 삭제/미저장 - 쿠키는 있는데 서버가 토큰을 검증 못 함: 서명 키 불일치, 암호화 키 변경, 토큰 포맷 문제
- 토큰은 유효하지만
useSession()이 null로 나옴: 네트워크 실패, 캐시/프리패치, 라우팅/하이드레이션 타이밍
이 구분을 위해 먼저 다음을 확인합니다.
- DevTools
Application탭에서 쿠키가 남아 있는지 - 네트워크에서
GET/api/auth/session호출이 실패하는지(상태 코드) - 서버 로그에
JWT디코딩/검증 에러가 찍히는지
App Router를 쓴다면 하이드레이션/라우팅 타이밍 문제로 “잠깐 null”이 보일 수도 있습니다. 이 경우 랜덤 로그아웃이 아니라 렌더링 상태 관리 문제일 수 있으니, 관련해서는 Next.js App Router Hydration 오류 7가지 원인도 함께 확인하는 게 좋습니다.
2) 가장 흔한 원인 1: NEXTAUTH_SECRET이 배포마다 달라지는 문제
JWT 전략에서 토큰은 서버가 서명(또는 암호화)하고, 이후 요청에서 이를 검증합니다. 그런데 서버 인스턴스마다 NEXTAUTH_SECRET이 다르면, 어떤 인스턴스에서 발급한 토큰을 다른 인스턴스가 검증하지 못해 세션이 갑자기 null이 됩니다. 로드밸런서 뒤 멀티 인스턴스 환경에서 “랜덤”처럼 보이는 대표 원인입니다.
체크 포인트
- 로컬에서는 문제 없는데 스테이징/프로덕션에서만 발생
- 롤링 배포/오토스케일 이후 빈도가 증가
- 서버 로그에 토큰 검증 실패(예: JWE/JWS 관련 에러)
해결
NEXTAUTH_SECRET을 환경변수로 고정하고, 모든 인스턴스가 동일 값을 사용하게 합니다.- Kubernetes라면 Secret 리소스로 고정 주입합니다.
# 예시: 고정 시크릿 생성
openssl rand -base64 32
# 예시: Kubernetes Secret
apiVersion: v1
kind: Secret
metadata:
name: nextauth-secret
type: Opaque
data:
NEXTAUTH_SECRET: "...base64..."
# 예시: Deployment env 주입
env:
- name: NEXTAUTH_SECRET
valueFrom:
secretKeyRef:
name: nextauth-secret
key: NEXTAUTH_SECRET
3) 가장 흔한 원인 2: 쿠키 옵션 문제(SameSite, Secure, 도메인)
“쿠키가 아예 저장되지 않거나, 특정 상황에서만 빠진다”면 대개 쿠키 속성 문제입니다.
대표 시나리오
http환경에서secure: true로 설정해 쿠키가 저장되지 않음- 서브도메인 간 이동(예:
app.example.com과api.example.com)에서domain미설정 - OAuth 콜백에서
SameSite정책 때문에 쿠키가 누락 - 프록시/로드밸런서 뒤에서
NEXTAUTH_URL이 실제 URL과 불일치
점검/해결 예시
NextAuth 설정에서 쿠키를 명시적으로 조정할 수 있습니다.
// auth.ts 또는 [...nextauth].ts
import NextAuth from "next-auth";
export const { handlers, auth } = NextAuth({
session: {
strategy: "jwt",
},
cookies: {
sessionToken: {
name: process.env.NODE_ENV === "production"
? "__Secure-next-auth.session-token"
: "next-auth.session-token",
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: process.env.NODE_ENV === "production",
},
},
},
});
- OAuth 리다이렉트가 많고 크로스 사이트 이슈가 의심되면
sameSite: "none"이 필요할 수 있습니다. 단, 이 경우secure: true가 필수입니다. - 프록시 뒤에서 프로토콜이 꼬이면 NextAuth가 쿠키를 잘못 설정할 수 있으니,
NEXTAUTH_URL을 실제 외부 URL로 맞추고(예:https://app.example.com), 인프라가X-Forwarded-Proto를 올바르게 전달하는지 확인합니다.
4) 원인 3: 토큰 만료/갱신 로직의 경계값 버그
JWT 세션에서 흔히 하는 패턴이 “액세스 토큰 만료 시 리프레시 토큰으로 재발급”입니다. 이 로직이 경계값에서 실패하면 사용자는 랜덤 로그아웃을 겪습니다.
흔한 실수
expires_at를 초 단위로 받았는데 밀리초로 비교- 서버 시간 오차로 인해 아직 유효한 토큰을 만료로 판단
- 리프레시 요청 실패 시 바로 세션을 null 처리(재시도 없음)
- 동시 요청이 여러 개 발생해 리프레시가 경합하며 한쪽이 실패
견고한 jwt 콜백 예시
아래는 만료 체크를 “현재 시간 + 안전 마진”으로 판단하고, 리프레시 실패 시에도 즉시 로그아웃 대신 에러 플래그를 세션에 전달하는 방식입니다.
import NextAuth from "next-auth";
async function refreshAccessToken(token: any) {
const res = await fetch(process.env.OAUTH_TOKEN_ENDPOINT!, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: token.refreshToken,
client_id: process.env.OAUTH_CLIENT_ID!,
client_secret: process.env.OAUTH_CLIENT_SECRET!,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`refresh_failed: ${res.status} ${text}`);
}
const data = await res.json();
return {
...token,
accessToken: data.access_token,
// expires_in은 보통 초 단위
accessTokenExpiresAt: Date.now() + data.expires_in * 1000,
refreshToken: data.refresh_token ?? token.refreshToken,
refreshError: undefined,
};
}
export const { handlers, auth } = NextAuth({
session: { strategy: "jwt" },
callbacks: {
async jwt({ token, account }) {
// 최초 로그인
if (account) {
return {
...token,
accessToken: account.access_token,
refreshToken: account.refresh_token,
accessTokenExpiresAt: (account.expires_at ?? 0) * 1000,
};
}
const safetyWindowMs = 30 * 1000;
const expiresAt = token.accessTokenExpiresAt as number | undefined;
if (expiresAt && Date.now() + safetyWindowMs < expiresAt) {
return token;
}
// 만료되었거나 만료 시간이 없으면 갱신 시도
try {
return await refreshAccessToken(token);
} catch (e: any) {
return {
...token,
refreshError: e?.message ?? "refresh_failed",
};
}
},
async session({ session, token }) {
(session as any).accessToken = token.accessToken;
(session as any).refreshError = (token as any).refreshError;
return session;
},
},
});
프론트에서는 refreshError가 있을 때만 재로그인 UI를 유도하면 “갑자기 튕김”을 줄일 수 있습니다.
5) 원인 4: 서버 시간(클록 스큐)과 NTP 문제
JWT는 exp 같은 시간 기반 클레임을 사용합니다. 서버 시간이 몇 분만 어긋나도 “어떤 요청은 되고 어떤 요청은 안 되는” 현상이 발생할 수 있습니다.
체크
- 특정 노드에서만 로그아웃이 발생하는지
- 컨테이너 노드 시간 동기화(NTP)가 정상인지
- 클라우드 환경에서 시간 드리프트 알람이 있었는지
대응
- 노드/VM의 시간 동기화를 강제(NTP, chrony)
- 만료 판정에 안전 마진 적용(앞 절 예시)
6) 원인 5: 엣지/서버리스 환경에서의 분산 특성
Vercel/서버리스 또는 Edge Runtime을 섞어 쓰면 다음 같은 문제가 생길 수 있습니다.
- 지역별 PoP에서
NEXTAUTH_SECRET이 다르게 설정됨 - Edge에서 일부 Node API가 제한되어 JWT 처리 흐름이 달라짐
- 환경변수 반영이 배포 타이밍에 따라 달라짐
가능하면 인증 관련 라우트는 런타임을 통일하고, 시크릿/URL을 배포 단위로 강제 고정합니다.
7) “랜덤”을 “재현 가능”으로 바꾸는 로깅/관측 포인트
이 문제는 관측이 없으면 영원히 랜덤입니다. 다음을 최소로 남기면 원인 좁히기가 빨라집니다.
/api/auth/session응답 상태 코드와 지연 시간- JWT 콜백에서 리프레시 성공/실패 카운트
- 토큰 검증 실패 메시지(민감 정보 제외)
- 어떤 인스턴스(파드/서버)에서 실패했는지 식별자
Kubernetes에서 특정 파드에서만 이슈가 나면, 종료/재시작 타이밍이나 사이드카, 프록시 설정도 함께 의심해야 합니다. 운영 환경에서의 재현/분석 관점은 Kubernetes 사이드카 종료 순서 버그 해결 가이드도 참고할 만합니다.
8) 체크리스트: 빠른 원인 진단 순서
- 브라우저에서 세션 쿠키가 사라지는가
NEXTAUTH_SECRET이 모든 인스턴스에서 동일한가NEXTAUTH_URL이 실제 외부 URL과 일치하는가- 쿠키
sameSite/secure/domain이 환경에 맞는가 - 리프레시 토큰 로직에서 초/밀리초 단위 혼동이 없는가
- 서버 시간 동기화가 정상인가
- 특정 노드/리전/브라우저에서만 재현되는가
9) 결론: 대부분은 “시크릿 고정”과 “쿠키/만료 경계값”이다
NextAuth.js JWT 세션의 랜덤 로그아웃은 대개
- 멀티 인스턴스에서
NEXTAUTH_SECRET불일치 - 프록시/도메인/HTTPS 조건에서 쿠키가 누락
- 리프레시 로직의 경계값(시간 단위, 안전 마진, 동시성)
이 세 축에서 발생합니다. 위 체크리스트대로 “쿠키가 없어졌는지”와 “서버가 토큰을 못 읽는지”부터 분리하면, 랜덤처럼 보이던 문제를 빠르게 재현 가능한 버그로 바꿀 수 있습니다.