Skip to content
Migrating from NextAuth.js v4? Read our migration guide.
가이드Refresh Token Rotation
💡

현재로서는 자동 Refresh Token 갱신을 위한 내장 솔루션이 없습니다. 이 가이드는 여러분의 애플리케이션에서 이를 구현하는 데 도움을 줄 것입니다. 우리의 목표는 내장 프로바이더에 대해 설정 없이도 사용할 수 있는 기능을 추가하는 것입니다. 도움을 주고 싶다면 저희에게 알려주세요.

리프레시 토큰 회전이란?

리프레시 토큰 회전은 사용자의 재인증 없이도 access_token을 업데이트하는 방법입니다.
access_token은 일반적으로 제한된 시간 동안만 유효합니다. 만료되면 서비스에서 이를 무시하게 되고, access_token은 더 이상 사용할 수 없게 됩니다.
사용자에게 다시 로그인을 요청하는 대신, 많은 프로바이더들은 초기 로그인 시 더 긴 유효 기간을 가진 refresh_token을 발급합니다.
Auth.js 라이브러리는 이 refresh_token을 사용해 사용자의 재로그인 없이도 새로운 access_token을 얻도록 설정할 수 있습니다.

구현

다음 가이드에는 보안상의 이유로 refresh_token이 일반적으로 한 번만 사용 가능하다는 사실에서 비롯된 고유한 제한이 있습니다. 즉, 성공적으로 리프레시한 후에는 refresh_token이 무효화되어 다시 사용할 수 없습니다. 따라서 여러 요청이 동시에 토큰을 리프레시하려고 시도할 경우 경쟁 조건이 발생할 수 있습니다. Auth.js 팀은 이 문제를 인지하고 있으며, 향후 해결책을 제공할 계획입니다. 이는 여러 요청이 동시에 토큰을 리프레시하지 못하도록 하는 “락” 메커니즘을 포함할 수 있지만, 이는 애플리케이션에서 병목 현상을 일으킬 가능성이 있습니다. 또 다른 가능한 해결책은 인증된 요청 중에 토큰이 만료되지 않도록 백그라운드 토큰 리프레시를 사용하는 것입니다.

먼저, 사용하려는 프로바이더가 refresh_token을 지원하는지 확인하세요. 자세한 내용은 OAuth 2.0 인증 프레임워크 스펙을 참조하세요. 세션 전략에 따라 refresh_token은 쿠키 내부의 암호화된 JWT에 저장되거나 데이터베이스에 저장될 수 있습니다.

JWT 전략

💡

refresh_token을 쿠키에 저장하는 방법은 더 간단하지만, 보안 측면에서는 덜 안전합니다. strategy: "jwt"를 사용할 때 발생할 수 있는 위험을 줄이기 위해, Auth.js 라이브러리는 refresh_token암호화된 JWT로 변환한 후 HttpOnly 쿠키에 저장합니다. 하지만 여러분의 요구사항에 따라 어떤 전략을 선택할지 평가해야 합니다.

jwtsession 콜백을 사용하면 OAuth 토큰을 유지하고, 토큰이 만료될 때 이를 갱신할 수 있습니다.

아래는 Google을 사용하여 access_token을 갱신하는 예제 구현입니다. refresh_token을 얻기 위한 OAuth 2.0 요청은 각 프로바이더마다 다를 수 있지만, 나머지 로직은 비슷하게 유지됩니다.

./auth.ts
import NextAuth, { type User } from "next-auth"
import Google from "next-auth/providers/google"
 
export const { handlers, auth } = NextAuth({
  providers: [
    Google({
      // Google은 `refresh_token`을 제공하기 위해 "offline" access_type을 요구합니다.
      authorization: { params: { access_type: "offline", prompt: "consent" } },
    }),
  ],
  callbacks: {
    async jwt({ token, account }) {
      if (account) {
        // 첫 로그인 시, `access_token`, 만료 시간, `refresh_token`을 저장합니다.
        return {
          ...token,
          access_token: account.access_token,
          expires_at: account.expires_at,
          refresh_token: account.refresh_token,
        }
      } else if (Date.now() < token.expires_at * 1000) {
        // 이후 로그인 시, `access_token`이 아직 유효한 경우
        return token
      } else {
        // 이후 로그인 시, `access_token`이 만료된 경우, 갱신을 시도합니다.
        if (!token.refresh_token) throw new TypeError("Missing refresh_token")
 
        try {
          // `token_endpoint`는 프로바이더의 문서에서 찾을 수 있습니다. 또는 OIDC를 지원하는 경우,
          // `/.well-known/openid-configuration` 엔드포인트에서 확인할 수 있습니다.
          // 예: https://accounts.google.com/.well-known/openid-configuration
          const response = await fetch("https://oauth2.googleapis.com/token", {
            method: "POST",
            body: new URLSearchParams({
              client_id: process.env.AUTH_GOOGLE_ID!,
              client_secret: process.env.AUTH_GOOGLE_SECRET!,
              grant_type: "refresh_token",
              refresh_token: token.refresh_token!,
            }),
          })
 
          const tokensOrError = await response.json()
 
          if (!response.ok) throw tokensOrError
 
          const newTokens = tokensOrError as {
            access_token: string
            expires_in: number
            refresh_token?: string
          }
 
          return {
            ...token,
            access_token: newTokens.access_token,
            expires_at: Math.floor(Date.now() / 1000 + newTokens.expires_in),
            // 일부 프로바이더는 refresh 토큰을 한 번만 발급하므로, 새로운 토큰이 없으면 기존 토큰을 유지합니다.
            refresh_token: newTokens.refresh_token
              ? newTokens.refresh_token
              : token.refresh_token,
          }
        } catch (error) {
          console.error("Error refreshing access_token", error)
          // 토큰 갱신에 실패한 경우, 페이지에서 처리할 수 있도록 에러를 반환합니다.
          token.error = "RefreshTokenError"
          return token
        }
      }
    },
    async session({ session, token }) {
      session.error = token.error
      return session
    },
  },
})
 
declare module "next-auth" {
  interface Session {
    error?: "RefreshTokenError"
  }
}
 
declare module "next-auth/jwt" {
  interface JWT {
    access_token: string
    expires_at: number
    refresh_token?: string
    error?: "RefreshTokenError"
  }
}

데이터베이스 전략

데이터베이스 세션 전략을 사용하는 것도 비슷하지만, 대신 제공자(provider)에 대한 access_token, expires_at, refresh_tokenaccount에 저장합니다.

./auth.ts
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { PrismaClient } from "@prisma/client"
 
const prisma = new PrismaClient()
 
export const { handlers, signIn, signOut, auth } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    Google({
      authorization: { params: { access_type: "offline", prompt: "consent" } },
    }),
  ],
  callbacks: {
    async session({ session, user }) {
      const [googleAccount] = await prisma.account.findMany({
        where: { userId: user.id, provider: "google" },
      })
      if (googleAccount.expires_at * 1000 < Date.now()) {
        // 액세스 토큰이 만료된 경우, 토큰을 새로고침 시도
        try {
          // https://accounts.google.com/.well-known/openid-configuration
          // `token_endpoint`가 필요합니다.
          const response = await fetch("https://oauth2.googleapis.com/token", {
            method: "POST",
            body: new URLSearchParams({
              client_id: process.env.AUTH_GOOGLE_ID!,
              client_secret: process.env.AUTH_GOOGLE_SECRET!,
              grant_type: "refresh_token",
              refresh_token: googleAccount.refresh_token,
            }),
          })
 
          const tokensOrError = await response.json()
 
          if (!response.ok) throw tokensOrError
 
          const newTokens = tokensOrError as {
            access_token: string
            expires_in: number
            refresh_token?: string
          }
 
          await prisma.account.update({
            data: {
              access_token: newTokens.access_token,
              expires_at: Math.floor(Date.now() / 1000 + newTokens.expires_in),
              refresh_token:
                newTokens.refresh_token ?? googleAccount.refresh_token,
            },
            where: {
              provider_providerAccountId: {
                provider: "google",
                providerAccountId: googleAccount.providerAccountId,
              },
            },
          })
        } catch (error) {
          console.error("액세스 토큰 새로고침 중 오류 발생", error)
          // 토큰 새로고침에 실패한 경우, 페이지에서 처리할 수 있도록 오류 반환
          session.error = "RefreshTokenError"
        }
      }
      return session
    },
  },
})
 
declare module "next-auth" {
  interface Session {
    error?: "RefreshTokenError"
  }
}

에러 처리

토큰 갱신이 실패한 경우, 강제로 재인증을 진행할 수 있습니다.

app/dashboard/page.tsx
import { useEffect } from "react"
import { auth, signIn } from "@/auth"
 
export default async function Page() {
  const session = await auth()
  if (session?.error === "RefreshTokenError") {
    await signIn("google") // 새로운 액세스 토큰과 리프레시 토큰을 얻기 위해 강제로 로그인
  }
}
Auth.js © Balázs Orbán and Team - 2025