Skip to content
Migrating from NextAuth.js v4? Read our migration guide.
Guides
Refresh Token Rotation

Refresh token rotation is the practice of updating an access_token on behalf of the user, without requiring interaction (eg.: re-sign in). access_tokens are usually issued for a limited time. After they expire, the service verifying them will ignore the value. Instead of asking the user to sign in again to obtain a new access_token, certain providers support exchanging a refresh_token for a new access_token, renewing the expiry time. Let’s see how this can be achieved.

💡

Our goal is to add zero-config support for built-in providers eventually. Let us know if you would like to help.

Implementation

First, make sure that the provider you want to use supports refresh_token’s. Check out The OAuth 2.0 Authorization Framework spec for more details.

Server Side

Depending on the session strategy, refresh_token can be persisted either in a database, or in a cookie, in an encrypted JWT.

💡

Using a JWT to store the refresh_token is less secure than saving it in a database, and you need to evaluate based on your requirements which strategy you choose.

JWT strategy

Using the jwt and session callbacks, we can persist OAuth tokens and refresh them when they expire.

Below is a sample implementation using Google’s Identity Provider. Please note that the OAuth 2.0 request to get the refresh_token will vary between different providers, but the core logic should remain similar.

./auth.ts
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
 
export const { handlers, auth } = NextAuth({
  providers: [
    Google({
      clientId: process.env.AUTH_GOOGLE_ID,
      clientSecret: process.env.AUTH_GOOGLE_SECRET,
      authorization: { params: { access_type: "offline", prompt: "consent" } },
    }),
  ],
  callbacks: {
    async jwt({ token, account }) {
      if (account) {
        // Save the access token and refresh token in the JWT on the initial login, as well as the user details
        return {
          access_token: account.access_token,
          expires_at: account.expires_at,
          refresh_token: account.refresh_token,
          user: token,
        }
      } else if (Date.now() < token.expires_at * 1000) {
        // If the access token has not expired yet, return it
        return token
      } else {
        if (!token.refresh_token) throw new Error("Missing refresh token")
 
        // If the access token has expired, try to refresh it
        try {
          // https://accounts.google.com/.well-known/openid-configuration
          // We need the `token_endpoint`.
          const response = await fetch("https://oauth2.googleapis.com/token", {
            headers: { "Content-Type": "application/x-www-form-urlencoded" },
            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,
            }),
            method: "POST",
          })
 
          const tokens = await response.json()
 
          if (!response.ok) throw tokens
 
          return {
            ...token, // Keep the previous token properties
            access_token: tokens.access_token,
            expires_at: Math.floor(Date.now() / 1000 + tokens.expires_in),
            // Fall back to old refresh token, but note that
            // many providers may only allow using a refresh token once.
            refresh_token: tokens.refresh_token ?? token.refresh_token,
          }
        } catch (error) {
          console.error("Error refreshing access token", error)
          // The error property will be used client-side to handle the refresh token error
          return { ...token, error: "RefreshAccessTokenError" as const }
        }
      }
    },
    async session({ session, token }) {
      session.error = token.error
      return {
        ...session,
        ...token,
      }
    },
  },
})
 
declare module "next-auth" {
  interface Session {
    error?: "RefreshAccessTokenError"
  }
}
 
declare module "next-auth/jwt" {
  interface JWT {
    access_token: string
    expires_at: number
    refresh_token: string
    error?: "RefreshAccessTokenError"
  }
}

Database strategy

Using the database session strategy is very similar, but instead of preserving the access_token and refresh_token in the JWT, we will save it in the database by updating the account value.

./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({
      clientId: process.env.AUTH_GOOGLE_ID,
      clientSecret: process.env.AUTH_GOOGLE_SECRET,
      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()) {
        // If the access token has expired, try to refresh it
        try {
          // https://accounts.google.com/.well-known/openid-configuration
          // We need the `token_endpoint`.
          const response = await fetch("https://oauth2.googleapis.com/token", {
            headers: { "Content-Type": "application/x-www-form-urlencoded" },
            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,
            }),
            method: "POST",
          })
 
          const tokens = await response.json()
 
          if (!response.ok) throw tokens
 
          await prisma.account.update({
            data: {
              access_token: tokens.access_token,
              expires_at: Math.floor(Date.now() / 1000 + tokens.expires_in),
              refresh_token:
                tokens.refresh_token ?? googleAccount.refresh_token,
            },
            where: {
              provider_providerAccountId: {
                provider: "google",
                providerAccountId: googleAccount.providerAccountId,
              },
            },
          })
        } catch (error) {
          console.error("Error refreshing access token", error)
          // The error property will be used client-side to handle the refresh token error
          session.error = "RefreshAccessTokenError"
        }
      }
      return session
    },
  },
})
 
declare module "next-auth" {
  interface Session {
    error?: "RefreshAccessTokenError"
  }
}
 
declare module "next-auth/jwt" {
  interface JWT {
    access_token: string
    expires_at: number
    refresh_token: string
    error?: "RefreshAccessTokenError"
  }
}

Client Side

The RefreshAccessTokenError error that is caught in the session callback is passed to the client. This means that you can direct the user to the sign-in flow if we cannot refresh their token. Don’t forget, calling useSession client-side, for example, requires your component is wrapped with the <SessionProvider />.

We can handle this functionality as a side effect:

pages/home.js
import { useEffect } from "react";
import { signIn, useSession } from "next-auth/react";
 
const HomePage() {
  const { data: session } = useSession();
 
  useEffect(() => {
    if (session?.error === "RefreshAccessTokenError") {
      signIn(); // Force sign in to hopefully resolve error
    }
  }, [session]);
 
  return <div>Home Page</div>;
}

Source Code

A working example can be accessed here.

Auth.js © Balázs Orbán and Team - 2024