← 목록으로TROUBLESHOOTING

Supabase 로그인이 새로고침에 풀릴 때 — 원인 4가지를 순서대로 짚는 진단 체크리스트

Supabase 로그인이 새로고침에 풀릴 때 — 원인 4가지를 순서대로 짚는 진단 체크리스트

구글이나 카카오 로그인을 붙이고 나면 거의 모두가 한 번은 겪습니다. 로그인은 분명히 됐는데 새로고침하면 로그아웃되어 있습니다. 에러 메시지는 안 뜨거나, 떠도 Invalid Refresh Token: Session Expired 한 줄이라 원인을 안 알려줍니다.

이 문제는 원인이 하나가 아닙니다. 남의 해결법을 복사해 붙여도 안 고쳐지니 순서대로 짚어야 합니다.

증상부터 세 가지로 나눕니다

같아 보이지만 원인이 다릅니다.

  • 로컬은 되는데 배포하면 풀린다 → 쿠키 도메인, Site URL, 또는 서버 렌더링 경로 문제입니다.
  • 새로고침에서만 풀린다 → 세션이 저장 자체가 안 되고 있습니다. 진단 1·2번입니다.
  • 한참 쓰다가 일정 시간 후 풀린다 → 토큰 갱신 문제입니다. 진단 4번과 마지막 섹션입니다.

어디에 해당하는지 정하고 들어가면 두세 단계를 건너뛸 수 있습니다.

진단 1 — 클라이언트를 매번 새로 만들고 있지 않은가

가장 흔합니다. 컴포넌트 안에서 createClient()를 호출하면 렌더마다 새 인스턴스가 생깁니다.

// 잘못된 패턴 — 렌더마다 새 클라이언트
export default function Page() {
  const supabase = createClient(url, anonKey);
  // ...
}

인스턴스가 여러 개면 같은 저장소의 토큰을 두고 서로 갱신을 시도합니다. 한쪽이 리프레시 토큰을 쓰는 순간 다른 쪽이 들고 있던 토큰은 무효가 됩니다. 그 인스턴스가 다음 요청을 하면 세션이 날아갑니다.

제 프로젝트에서는 이걸 아예 싱글턴으로 막아뒀습니다.

let client: SupabaseClient | null = null;

export function getSupabaseBrowser(): SupabaseClient {
  if (client) return client;
  client = createClient(url, anonKey, {
    auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: true },
  });
  return client;
}

@supabase/ssrcreateBrowserClient를 쓴다면 이미 내부적으로 싱글턴이라 따로 감쌀 필요는 없습니다. 문제는 @supabase/supabase-jscreateClient를 직접 부를 때입니다.

진단 2 — persistSession이 덮어써지지 않았는가

AI가 짜준 코드에서 자주 나오는 패턴입니다. 클라이언트를 자동으로 만들어주는 헬퍼가 끼어들면서 세션 유지 옵션을 통째로 덮어씁니다. 로그인은 되는데 저장이 안 되니 새로고침 한 번에 끝납니다.

옵션을 명시적으로 박아주면 해결됩니다.

createClient(url, anonKey, {
  auth: {
    persistSession: true,
    autoRefreshToken: true,
    detectSessionInUrl: true,
  },
});

검토 없이 받은 코드가 이런 식으로 조용한 버그를 만듭니다. 바이브 코딩이 원래 무슨 뜻이었는지 짚은 글에서도 같은 얘기를 했는데, 돌아가는 것과 맞는 것은 다릅니다.

진단 3 — 서버가 쿠키를 못 읽고 있지 않은가

SSR에서는 세션이 localStorage가 아니라 쿠키에 있습니다. 서버 컴포넌트가 그 쿠키를 못 읽으면 로그인 안 한 사용자로 취급합니다.

공식 문서도 여기서 못을 박습니다.

Never trust supabase.auth.getSession() inside server code. It isn’t guaranteed to revalidate the Auth token.

getSession()은 쿠키에 든 값을 그대로 돌려줄 뿐 서명을 검증하지 않습니다. 서버에서 권한 판단에 쓰면 안 됩니다.

대신 getClaims()를 씁니다. 예전에는 getUser()를 권했지만 지금 공식 가이드는 getClaims()입니다. 비대칭 키를 쓰는 프로젝트라면 공개키로 JWT 서명을 로컬에서 검증하니 네트워크 왕복이 없습니다. 사용자가 방금 차단됐는지까지 즉시 알아야 하는 경우에만 getUser()를 쓰면 됩니다.

createServerClient의 쿠키 어댑터도 확인하세요. getAll이 요청 쿠키를 읽고 setAll이 응답에 다시 써야 합니다. setAll을 비워두면 갱신된 토큰이 브라우저로 돌아가지 않아서 토큰 수명이 끝나는 시점에 로그아웃됩니다.

진단 4 — Site URL과 Redirect URL이 배포 도메인과 맞는가

로컬만 되고 배포하면 안 되는 경우 대부분 여기입니다. Supabase 대시보드의 Authentication 설정에서 Site URL이 localhost:3000으로 남아 있으면 배포 도메인에서 콜백이 돌아올 자리가 없습니다.

구글 로그인카카오 로그인 설정 글에서 리디렉션 URI를 다뤘는데, 도메인을 붙이거나 바꿀 때 이 두 값을 같이 고쳐야 합니다.

Invalid Refresh Token은 이제 버그가 아닙니다

Invalid Refresh Token: Session Expired는 오랫동안 유명한 미해결 이슈였습니다. 로컬에서는 멀쩡한데 프로덕션에서만 재현돼서 더 악명이 높았고요.

지금은 결론이 났습니다. supabase/ssr 이슈 #91이 2026년 8월에 닫혔고 원인은 레이스 컨디션이었습니다. GoTrueClient 생성자가 백그라운드로 초기화를 돌리면서 미들웨어가 시작한 토큰 갱신과 동시에 갱신을 시도한 겁니다. 고치는 방법은 createServerClientskipAutoInitialize: true를 넣어 생성 시점의 초기화를 막는 겁니다. @supabase/ssr@0.8.1부터 정식 배포에 들어가 있습니다.

이 에러를 보고 있다면 버전부터 확인하세요. 0.8.1보다 낮으면 올리는 게 답입니다.

npm install @supabase/ssr@latest @supabase/supabase-js@latest

그래도 안 풀린다면

같은 이슈 스레드에서 나온 지적이 진단을 막는 진짜 이유일 수 있습니다. 미들웨어 예제 코드가 대개 이렇게 생겼습니다.

const { data: { user } } = await supabase.auth.getUser();

error를 안 받습니다. 그래서 토큰이 진짜 만료된 경우와 쿠키 쓰기가 실패한 경우가 똑같아 보입니다. 둘을 구분하려면 에러를 받아서 분기해야 합니다.

const { data: { user }, error } = await supabase.auth.getUser();
if (error) {
  console.error('[auth]', error.name, error.message);
}

이걸 켜두면 다음 재현 때 어느 쪽인지 바로 나옵니다. 에러를 조용히 삼키면 만료된 세션을 로그인된 것으로 취급해서 보호된 경로의 리디렉션이 아예 안 걸리기도 합니다.

세션 만료 시간을 1시간 같은 값으로 직접 줄여둔 경우라면 그 에러는 버그가 아니라 정상 동작입니다. 대시보드의 세션 설정도 같이 확인하세요.