NextAuth v5 JWT 콜백: OAuth와 Credentials 사용자 한 곳에서 분기하기
OAuth 경로에서는 user 객체에 회사 관계가 없어 토큰 초기화에 실패합니다. account.type 분기와 추가 DB 조회로 해결한 방법을 정리했습니다.
1. 문제 상황
기존 프로젝트는 Credentials(이메일·비밀번호) 로그인만 쓰고 있었습니다. jwt 콜백은 아주 단순했습니다.
// Before — Credentials only
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id!;
token.role = user.role;
token.companyId = user.companyId;
token.canEditRecords = user.canEditRecords;
// ... 그 외 권한 플래그
}
return token;
},
}
authorize 함수가 DB에서 User + Company를 join으로 가져와서 모든 권한 필드를 채운 다음 그대로 token에 복사했습니다. 깔끔했습니다.
여기에 카카오·Google OAuth를 추가한 순간부터 이 구조가 무너졌습니다.
증상
OAuth로 로그인하면 세션은 만들어지지만 session.user.role, session.user.companyId, session.user.canEditRecords 같은 필드가 전부 undefined로 나왔습니다. 미들웨어는 role을 기준으로 분기하니 PLATFORM_ADMIN인지 VIEWER인지 모르고, 권한 체크는 undefined가 truthy냐 falsy냐에 따라 무작위로 통과하거나 막혔습니다.
원인 추측
OAuth 프로바이더는 authorize 함수가 없습니다. NextAuth가 프로바이더에서 받아온 profile을 Adapter의 createUser로 넘겨서 DB에 저장한 다음, jwt 콜백의 user 인자에 그 결과를 넘겨줍니다. 문제는 createUser가 반환하는 Prisma User는 관계(company)를 포함하지 않는다는 점입니다. Credentials의 authorize에서는 prisma.user.findUnique({ include: { company: true }})로 직접 가져왔지만, Adapter는 그렇게 해주지 않습니다.
결과적으로 OAuth 경로의 user 객체는 다음과 같이 빈약합니다.
// OAuth 경로에서 넘어오는 user 객체
{
id: "abc123",
email: "[email protected]",
name: "홍길동",
emailVerified: null,
image: "https://.../profile.jpg",
}
role, companyId, canEditRecords 같은 필드가 없습니다. token.role = user.role을 그대로 실행하면 token.role이 undefined가 됩니다.
2. 원인 분석
2.1 NextAuth의 두 프로바이더 경로
NextAuth v5에서 jwt 콜백은 프로바이더 종류와 관계없이 로그인 시 호출되지만, user 인자의 구조가 프로바이더마다 다릅니다.
| 프로바이더 | user의 출처 |
포함되는 필드 |
|---|---|---|
| Credentials | 여러분이 작성한 authorize 함수의 반환값 |
여러분이 넣은 그대로 (커스텀 가능) |
| OAuth (OIDC) | Adapter의 createUser / getUserByAccount 반환값 = Prisma의 User 기본 select |
Prisma 기본 필드만 (관계 제외) |
Credentials의 authorize는 여러분이 직접 DB 쿼리를 짜기 때문에 원하는 만큼 풍부한 객체를 리턴할 수 있습니다. 반면 OAuth는 Adapter가 대신 DB를 다루고, 그 기본 구현은 관계를 포함하지 않습니다.
2.2 account 파라미터로 경로 구분
jwt 콜백의 두 번째 인자 account를 보면 어느 프로바이더로 들어왔는지 알 수 있습니다.
async jwt({ token, user, account, trigger, session }) {
// account.type === 'credentials' → Credentials 로그인
// account.type === 'oidc' | 'oauth' → 소셜 로그인
// account === null → 후속 요청 (세션 유지)
}
account가 null인 경우는 "이미 로그인된 세션이 재요청으로 들어온 경우"입니다. 이때는 user도 undefined이고, 기존 token을 그대로 반환하거나 세션 만료 체크만 하면 됩니다.
2.3 세션 update 트리거
또 한 가지 까다로운 부분은 trigger === 'update' 케이스입니다. 클라이언트에서 useSession().update({...})을 호출하면 jwt 콜백이 다시 불리는데, 이때 session 인자로 클라이언트가 보낸 데이터가 들어옵니다. 이 트리거는 여러 상황에서 필요했습니다.
- 2FA 검증 완료: 로그인 직후에는
token.twoFactorVerified = false였다가, 사용자가 TOTP 코드를 입력하면true로 승격 - 온보딩 완료: OAuth 사용자가 회사 정보를 채우고 나면
companyId,role을 토큰에 반영 - 2FA 활성화·해제: 설정에서 2FA를 켜거나 끄면
twoFactorEnabled반영
문제는 이 업데이트가 "로그인 시점의 초기화"와 같은 콜백에서 일어난다는 겁니다. if (user) { ... } 블록과 if (trigger === 'update') { ... } 블록이 한 함수에 공존해야 합니다.
3. 해결 방법
3.1 전체 구조
jwt 콜백을 네 단계로 쪼갰습니다.
- 초기 로그인:
user가 있으면 프로바이더별로 분기해 토큰 초기화 - 세션 업데이트 트리거: 2FA·온보딩·권한 변경 사유에 따라 토큰 필드 갱신
- 비활성 타임아웃 체크: 마지막 활동 시각이 너무 오래됐으면 빈 토큰 반환
- 활동 시각 갱신: 매 요청마다
lastActivity업데이트
3.2 초기 로그인: OAuth vs Credentials 분기
async jwt({ token, user, account, trigger, session }) {
// 1. 초기 로그인 — user가 존재할 때만 실행
if (user) {
token.id = user.id!;
token.twoFactorVerified = false; // 로그인 직후는 2FA 미검증
token.lastActivity = Date.now();
if (account?.type === 'credentials') {
// Credentials: authorize()에서 반환한 풍부한 객체 → 그대로 복사
token.role = user.role;
token.companyId = user.companyId;
token.companyName = user.companyName;
token.canEditRules = user.canEditRules;
token.canChangeRoles = user.canChangeRoles;
token.canViewRecords = user.canViewRecords;
token.canEditRecords = user.canEditRecords;
token.twoFactorEnabled = user.twoFactorEnabled;
} else {
// OAuth: adapter가 반환한 User에는 관계 없음 → 직접 DB 조회
const dbUser = await prisma.user.findUnique({
where: { id: user.id! },
include: { company: true },
});
if (dbUser) {
token.role = dbUser.role;
token.companyId = dbUser.companyId;
token.companyName = dbUser.company?.name ?? null;
token.canEditRules = dbUser.canEditRules;
token.canChangeRoles = dbUser.canChangeRoles;
token.canViewRecords = dbUser.canViewRecords;
token.canEditRecords = dbUser.canEditRecords;
token.twoFactorEnabled = dbUser.twoFactorEnabled;
}
}
}
// ... 다음 단계 계속
}
여기서 핵심 아이디어는 OAuth 경로에서 한 번의 추가 DB 쿼리를 수락한다는 것입니다. authorize에서 이미 같은 쿼리를 하고 있으니 중복처럼 보이지만, OAuth는 Adapter가 createUser/getUserByAccount를 부를 때 관계 파라미터를 주입할 방법이 없습니다. 한 번의 로그인당 추가 쿼리 한 번이면 수용 가능한 비용이고, 이후 요청은 JWT에 캐시되어 DB를 다시 치지 않습니다.
3.3 세션 업데이트 트리거
// 2. 세션 업데이트 트리거 — 2FA / 온보딩 / 권한 변경
if (trigger === 'update') {
// 2FA 검증 완료 시
if (session?.twoFactorVerified !== undefined) {
token.twoFactorVerified = session.twoFactorVerified;
}
// 2FA 활성화 상태 변경 (설정에서 켜기/끄기)
if (session?.twoFactorEnabled !== undefined) {
token.twoFactorEnabled = session.twoFactorEnabled;
}
// 온보딩 완료 시 — companyId, role, 권한 플래그 반영
if (session?.companyId !== undefined) {
token.companyId = session.companyId;
token.companyName = session.companyName ?? null;
token.role = session.role ?? token.role;
token.canEditRules =
session.canEditRules ?? token.canEditRules;
token.canChangeRoles = session.canChangeRoles ?? token.canChangeRoles;
token.canViewRecords = session.canViewRecords ?? token.canViewRecords;
token.canEditRecords = session.canEditRecords ?? token.canEditRecords;
}
}
여기서 주의할 점 두 가지입니다.
session?.field !== undefined 체크를 쓴 이유: 클라이언트는 업데이트할 필드만 넘깁니다. undefined면 "이 필드는 바꾸지 말라"는 의미이고, null은 "이 필드를 null로 설정하라"(예: 회사 탈퇴)는 의미입니다. session?.field만 체크하면 null과 undefined를 구분할 수 없으니 !== undefined를 명시적으로 씁니다.
?? token.role fallback: 클라이언트가 보낸 데이터에 해당 필드가 없을 때 기존 토큰 값을 유지하기 위함입니다. 온보딩 플로우에서 실수로 role을 안 넘기면 undefined가 되는데, 이걸 덮어쓰면 미들웨어의 모든 권한 체크가 깨집니다.
3.4 비활성 타임아웃 체크
// 3. 세션 비활성 타임아웃
if (token.lastActivity && typeof token.lastActivity === 'number') {
const timeout = getSessionTimeout(token.role as Role);
const elapsed = Date.now() - (token.lastActivity as number);
if (elapsed > timeout) {
// 빈 토큰 반환 → 세션 무효화
logger.warn('[Auth] Session expired due to inactivity', {
userId: token.id as string,
role: token.role as string,
elapsed: Math.round(elapsed / 1000 / 60) + ' minutes',
});
return {} as typeof token;
}
}
// 4. 활동 시각 갱신 (매 요청마다)
token.lastActivity = Date.now();
return token;
역할별로 타임아웃이 다릅니다. 관리자는 2시간, 일반 사용자는 24시간입니다.
export const SESSION_TIMEOUT = {
ADMIN: 2 * 60 * 60 * 1000, // 2시간
VIEWER: 24 * 60 * 60 * 1000, // 24시간
} as const;
export function getSessionTimeout(role: Role): number {
if (role === 'VIEWER') return SESSION_TIMEOUT.VIEWER;
return SESSION_TIMEOUT.ADMIN;
}
빈 토큰을 반환하면 다음 session 콜백에서 token.id가 없어 session.user가 비고, 미들웨어가 이를 감지해 /login?reason=session_expired로 리다이렉트합니다.
4. 클라이언트 측: 세션 업데이트 호출
서버의 jwt 콜백은 "받아서 적용"이지만 호출은 클라이언트에서 합니다. 두 가지 주요 시점이 있습니다.
4.1 2FA 검증 완료 시
// components/auth/TwoFactorVerifyForm.tsx
'use client';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
export function TwoFactorVerifyForm() {
const { update } = useSession();
const router = useRouter();
async function handleVerify(code: string) {
const res = await fetch('/api/auth/2fa/verify', {
method: 'POST',
body: JSON.stringify({ code }),
});
if (!res.ok) {
// 에러 처리
return;
}
// ← 핵심: 세션 업데이트 트리거
await update({ twoFactorVerified: true });
router.replace('/dashboard');
}
// ... 폼 UI
}
update({ twoFactorVerified: true })가 서버로 가면 jwt 콜백이 trigger === 'update'로 재호출되고, session.twoFactorVerified = true를 받아 토큰에 반영합니다. 클라이언트는 이후 자동으로 새 세션을 받습니다.
4.2 온보딩 완료 시
// app/onboard/OnboardForm.tsx
'use client';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
export function OnboardForm() {
const { update } = useSession();
const router = useRouter();
async function handleSubmit(data: {
companyName: string;
name: string;
}) {
const res = await fetch('/api/auth/onboarding', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) return;
const { user } = await res.json();
// ← 온보딩 완료 데이터를 세션에 반영
await update({
companyId: user.companyId,
companyName: user.companyName,
role: user.role,
canEditRules: user.canEditRules,
canChangeRoles: user.canChangeRoles,
canViewRecords: user.canViewRecords,
canEditRecords: user.canEditRecords,
});
router.replace('/dashboard');
}
// ... 폼 UI
}
온보딩 API는 Company 생성 → User.companyId·role 업데이트 → Employee 레코드 생성을 트랜잭션으로 처리한 뒤 업데이트된 값을 반환합니다. 클라이언트는 이 값을 그대로 update()에 넘깁니다.
4.3 중요: await update()를 반드시 기다릴 것
update()는 Promise를 반환합니다. 이걸 기다리지 않고 router.replace('/dashboard')를 바로 호출하면, 토큰이 갱신되기 전에 대시보드 페이지가 렌더링됩니다. 대시보드는 companyId가 있어야 데이터를 가져오는데, 이 시점에 아직 null이라 "온보딩이 필요합니다" 페이지로 되돌려 보내지는 무한 루프가 발생합니다. 반드시 await를 붙여야 합니다.
5. 핵심 개념 정리
| 개념 | 역할 | 관련 코드 |
|---|---|---|
jwt 콜백 |
토큰 생성·갱신의 중앙 진입점 | auth/index.ts |
user 인자 |
초기 로그인 시에만 존재. 프로바이더마다 구조 다름 | 분기 처리 |
account.type |
`'credentials' | 'oidc' |
trigger === 'update' |
useSession().update() 호출 시 |
2FA·온보딩 반영 |
session 인자 |
update()에 전달된 클라이언트 페이로드 |
필드 덮어쓰기 |
return {} |
세션 무효화 신호 | 비활성 타임아웃 |
Credentials vs OAuth 요약 테이블
| 항목 | Credentials | OAuth |
|---|---|---|
user 출처 |
authorize() 반환값 |
Adapter의 createUser/getUserByAccount |
| 관계 필드 포함 | 직접 제어 가능 (include 사용) |
기본 미포함 → 추가 쿼리 필요 |
| 초기 권한 셋업 | authorize()에서 처리 |
jwt 콜백에서 처리 |
account.provider |
'credentials' |
'kakao', 'google' 등 |
account.type |
'credentials' |
'oidc' 또는 'oauth' |
6. 베스트 프랙티스
- [ ]
user존재 여부 체크를 항상 맨 위에 두기. 후속 요청은user === undefined이므로 그 뒤에user.role같은 접근이 있으면 크래시합니다. - [ ] OAuth 경로에서만 DB 쿼리. Credentials는
authorize가 이미 다 가져왔으므로 추가 쿼리는 낭비입니다. - [ ]
session?.field !== undefined로 명시적 체크.null과undefined를 구분해야 회사 탈퇴 같은 경우에null설정이 가능합니다. - [ ]
trigger === 'update'블록은if (user)밖에 둘 것. 업데이트는 로그인 이후에 일어나고 이 시점엔user가 없습니다. - [ ] 클라이언트에서
await update()를 기다릴 것. 기다리지 않으면 네비게이션과 토큰 갱신 타이밍이 꼬입니다. - [ ] 빈 토큰 반환으로 세션 만료 표현.
return {}이 NextAuth에게 "이 세션은 무효"라는 신호입니다. - [ ] 역할별 타임아웃 분리. 관리자는 짧게, 일반 사용자는 길게. 불필요한 재로그인 마찰을 줄입니다.
7. FAQ
Q1. OAuth 추가 쿼리가 부담스러운데, authorize처럼 Adapter를 바꿔서 관계를 포함시킬 수 없나요?
A. 가능하지만 복잡합니다. createUser, getUser, getUserByEmail, getUserByAccount 등 여러 메서드에서 전부 include: { company: true }를 추가해야 합니다. 또한 Adapter의 리턴 타입은 NextAuth가 정한 AdapterUser이므로 관계 필드가 타입에서 누락되고 내부에서 사용되지 않습니다. 결국 jwt 콜백에서 한 번 더 조회하는 게 더 간결합니다.
Q2. 매 요청마다 lastActivity를 DB에 저장해야 하지 않나요?
A. JWT 전략을 쓰면 토큰이 클라이언트 쿠키에 암호화되어 저장되고, lastActivity 필드는 그 안에 포함됩니다. DB는 건드리지 않아도 됩니다. 반대로 database session 전략을 쓰면 매 요청마다 DB update가 발생해 부하가 커집니다. JWT 전략의 핵심 장점 중 하나입니다.
Q3. return {}이 정말 세션을 무효화하나요? 단순히 빈 객체를 반환해서 버그가 나는 건 아닌가요?
A. NextAuth v5의 session 콜백은 token.id가 없으면 session.user를 만들지 않습니다. 미들웨어는 req.auth?.user?.id를 보고 세션 유효성을 판단하므로, 빈 토큰 → 빈 session → isLoggedIn === false 흐름이 자연스럽게 성립합니다. 다만 프로덕션 전에 만료 시나리오를 반드시 E2E로 테스트해보세요.
Q4. 2FA 활성화는 서버 API에서 User.twoFactorEnabled = true로 업데이트하는데, 왜 굳이 update() 호출이 필요한가요?
A. 토큰은 쿠키에 저장된 상태 스냅샷입니다. DB를 바꿔도 기존 토큰의 twoFactorEnabled는 false 그대로입니다. 다음 요청 때 미들웨어가 "2FA 미설정이네" 판단하고 2FA 설정 페이지를 건너뛰게 됩니다. update()로 토큰 자체를 갱신해야 즉시 반영됩니다.
Q5. allowDangerousEmailAccountLinking 때문에 카카오로 가입한 사용자가 Google로도 로그인 가능한데, 이 경우 jwt 콜백이 어떻게 처리하나요?
A. Adapter가 getUserByEmail로 기존 사용자를 찾아 기존 User.id에 새 Account 레코드만 링크합니다. jwt 콜백은 기존 User.id를 받아 OAuth 분기로 진입하고, 관계 쿼리로 기존 권한·회사 정보를 그대로 가져옵니다. 사용자는 어떤 프로바이더로 로그인하든 같은 세션을 받습니다.
8. 참고 자료
- Auth.js
jwt콜백 공식 문서 - NextAuth v5 migration 가이드
useSession().update()문서- 검색 키워드: "NextAuth v5 jwt callback trigger update", "NextAuth session update without refresh"
9. 다음 단계
JWT 콜백이 정리되면 남는 문제는 "OAuth 사용자는 회사 정보를 어느 타이밍에 받는가?"입니다. 기존 이메일 가입은 3단계 폼에서 회사 정보를 받았지만, OAuth는 그 단계가 없으므로 별도 온보딩 페이지가 필요합니다. 다음 글에서는 OAuth와 이메일 가입을 하나의 온보딩 플로우로 통합하는 방법을 다루겠습니다.
시리즈 목차:
- NextAuth PrismaAdapter + 암호화된 이메일: Blind Index 커스텀 Adapter
- NextAuth v5 JWT 콜백: OAuth와 Credentials 사용자 한 곳에서 분기하기 ← 현재 글
- 통합 온보딩 페이지: OAuth와 이메일 가입을 하나의 플로우로