Next.js x Supabase 429 Invalid Refresh Token

2025-09-18
nextjssupabaseInvalidRefreshTokenmiddleware429_error

ECS cloudwatch nextjs 서버 로그

TL;DR

미들웨어와 API 라우트(혹은 또 다른 서버 코드)가 동시에 supabase.auth.getUser()를 호출하면 같은 리프레시 토큰을 두 번 쓰려다 충돌합니다. 초기 로드에 RSC(Server Components) 가 쿠키를 쓰기(write) 해야 하는 상황이 아니라면, 미들웨어에서의 인증 갱신을 아예 제거하고 단일 엔드포인트(/api/auth/user)에서만 getUser() → 갱신하게 만들면 깔끔하게 해결됩니다.

미들웨어에서 갱신?

Supabase의 공식 가이드는 Next.js App Router에서 Server Components는 쿠키를 쓸 수 없다는 제약 때문에, 만료된 토큰을 미들웨어가 먼저 갱신하고 이를 요청/응답 쿠키에 반영하라고 안내합니다. 이렇게 하면 서버 컴포넌트는 새 토큰을 읽기만 하면 되죠. 또한 서버 측 보호에는 getUser()를 쓰라고 명시합니다. (서버 코드에서 getSession()만 믿지 말 것)

이슈 발생 - Invalid Refresh Token: Refresh Token Not Found / Already Used

대표적인 사례가 GitHub 이슈 supabase/ssr #68입니다. 미들웨어에서 갱신을 걸어 두면 하루쯤 지나 만료 시점에 페이지를 열 때 미들웨어가 갱신을 시도하고 동시에 다른 서버 코드(레이아웃, API 라우트 등)도 getUser()를 호출하면서 같은 리프레시 토큰을 중복 사용하게 됩니다.
그 결과 “Invalid Refresh Token: Refresh Token Not Found” 같은 에러가 발생합니다.

비슷한 류의 보고에서 두 요청이 동시에 리프레시를 시도하면 한쪽이 성공하고 다른 한쪽은 “Already Used” 로 실패한다는 분석도 있습니다.
즉, 리프레시 토큰은 1회성이라 동시 갱신에 취약합니다.

근본 원인 - 초기 로드에서 동시에 getUser()

// 🚫 여러 곳에서 동시에 토큰 갱신 시도
// middleware.ts
export async function middleware(request: NextRequest) {
	const supabase = createServerClient(...);
	const { data: { user } } = await supabase.auth.getUser(); // 첫 번째 갱신 시도
	// ...
}

// api/auth/user/route.ts
export async function GET() {
	const supabase = createServerClient(...);
	const { data: { user } } = await supabase.auth.getUser(); // 두 번째 갱신 시도
	// ...
}

// 결과 - 한쪽이 "Invalid Refresh Token" 에러 발생

아키텍처 수준에서 보면 문제는 단순합니다.

  1. 페이지 요청이 들어오고 미들웨어가 supabase.auth.getUser() → 갱신 시도
  2. 거의 동시에 글로벌 네비게이션이 /api/auth/user 를 호출하고 그 안에서도 getUser() → 다시 갱신 시도
  3. 같은 리프레시 토큰이 두 번 쓰이며 한쪽은 성공, 다른 한쪽은 실패(“Not Found / Already Used”)

해결 전략

미들웨어 유지 + 초기 로드에서 다른 곳은 갱신 금지

// ✅ 단일 엔드포인트에서만 토큰 갱신

// middleware.ts - 간단한 라우트 보호만 수행
export async function middleware(request: NextRequest) {
	const protectedRoutes = ["/******"];
	if (protectedRoutes.includes(request.nextUrl.pathname)) {
		const supabase = createServerClient(...);
		const { data: { session } } = await supabase.auth.getSession(); // 갱신 없이 세션만 확인
		
		if (!session) {
			return NextResponse.redirect(new URL("/signin", request.url));
		}
	}
	
	return NextResponse.next();
}

// /api/auth/session/route.ts - 여기서만 토큰 갱신 처리
export async function GET() {
	const supabase = await supabaseServer();
	const { data: { session }, error: sessionError } = await supabase.auth.getSession();

	if (!session) {
	return NextResponse.json({ session: null }, { status: 200 });
	}

	// 토큰 갱신이 필요한 경우 여기서만 수행
	const { data: { user } } = await supabase.auth.getUser();
	// ...
}

RSC에서 초기 서버 렌더에 사용자 정보를 꼭 써야 한다면 미들웨어를 유지할 수밖에 없습니다.

  • 초기 로드 시 getUser()를 호출하는 API 라우트/레이아웃/다른 서버 코드를 없애거나 뒤로 미루기
  • 글로벌 네비게이션을 서버 컴포넌트화하고, 미들웨어가 요청 쿠키에 주입한 갱신 토큰만 읽게 구성
    → 이렇게 하면 한 요청 내에서는 미들웨어만 갱신을 수행하고, RSC는 검증만 하므로 중복 갱신이 발생하지 않습니다.

마무리

이 문제의 핵심은 “리프레시 토큰은 단 한 번만 안전하게 쓸 수 있다”**는 사실입니다. **동시에 두 군데에서 getUser()를 호출하면 한쪽은 성공하고, 다른 한쪽은 “Refresh Token Not Found / Already Used”로 실패하면서 사용자가 로그아웃되는 경쟁 조건이 생깁니다.
초기 로드에서 갱신 주체를 하나로 만들고(미들웨어 또는 특정 API 라우트) 나머지는 읽기 전용으로 설계하면 문제는 깔끔하게 사라집니다.

  • TL;DR
  • 미들웨어에서 갱신?
  • 이슈 발생 - Invalid Refresh Token: Refresh Token Not Found / Already Used
  • 근본 원인 - 초기 로드에서 동시에 getUser()
  • 해결 전략
    • 미들웨어 유지 + 초기 로드에서 다른 곳은 갱신 금지
  • 마무리