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 in the refreshAccessToken() function will vary between different providers, but the core logic should remain similar.
import { Auth } from "@auth/core"
import { type TokenSet } from "@auth/core/types"
import Google from "@auth/core/providers/google"
export default Auth(new Request("https://example.com"), {
  providers: [
    Google({
      clientId: process.env.GOOGLE_ID,
      clientSecret: process.env.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
        return {
          access_token: account.access_token,
          expires_at: Date.now() + account.expires_in * 1000,
          refresh_token: account.refresh_token,
        }
      } else if (Date.now() < token.expires_at) {
        // If the access token has not expired yet, return it
        return token
      } else {
        // 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.GOOGLE_ID,
              client_secret: process.env.GOOGLE_SECRET,
              grant_type: "refresh_token",
              refresh_token: token.refresh_token,
            }),
            method: "POST",
          })
          const tokens: TokenSet = await response.json()
          if (!response.ok) throw tokens
          return {
            ...token, // Keep the previous token properties
            access_token: tokens.access_token,
            expires_at: Date.now() + tokens.expires_in * 1000,
            // 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
    },
  },
})
declare module "@auth/core/types" {
  interface Session {
    error?: "RefreshAccessTokenError"
  }
}
declare module "@auth/core/jwt" {
  interface JWT {
    access_token: string
    expires_at: number
    refresh_token: string
    error?: "RefreshAccessTokenError"
  }
}
Database strategy​
Using the database strategy is very similar, but instead of preserving the access_token and refresh_token, we save it, well, in the database.
import { Auth } from "@auth/core"
import { type TokenSet } from "@auth/core/types"
import Google from "@auth/core/providers/google"
import { PrismaAdapter } from "@next-auth/prisma-adapter"
import { PrismaClient } from "@prisma/client"
const prisma = new PrismaClient()
export default Auth(new Request("https://example.com"), {
  adapter: PrismaAdapter(prisma),
  providers: [
    Google({
      clientId: process.env.GOOGLE_ID,
      clientSecret: process.env.GOOGLE_SECRET,
      authorization: { params: { access_type: "offline", prompt: "consent" } },
    }),
  ],
  callbacks: {
    async session({ session, user }) {
      const [google] = await prisma.account.findMany({
        where: { userId: user.id, provider: "google" },
      })
      if (google.expires_at < 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.GOOGLE_ID,
              client_secret: process.env.GOOGLE_SECRET,
              grant_type: "refresh_token",
              refresh_token: google.refresh_token,
            }),
            method: "POST",
          })
          const tokens: TokenSet = await response.json()
          if (!response.ok) throw tokens
          await prisma.account.update({
            data: {
              access_token: tokens.access_token,
              expires_at: Date.now() + tokens.expires_in * 1000,
              refresh_token: tokens.refresh_token ?? google.refresh_token,
            },
            where: {
              provider_providerAccountId: {
                provider: "google",
                providerAccountId: google.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 "@auth/core/types" {
  interface Session {
    error?: "RefreshAccessTokenError"
  }
}
declare module "@auth/core/jwt" {
  interface JWT {
    access_token: string
    expires_at: number
    refresh_token: string
    error?: "RefreshAccessTokenError"
  }
}
Client Side​
The RefreshAccessTokenError error that is caught in the refreshAccessToken() method is passed to the client. This means that you can direct the user to the sign-in flow if we cannot refresh their token.
We can handle this functionality as a side effect:
import { signIn, useSession } from "next-auth/react";
import { useEffect } from "react";
const HomePage() {
  const { data: session } = useSession();
  useEffect(() => {
    if (session?.error === "RefreshAccessTokenError") {
      signIn(); // Force sign in to hopefully resolve error
    }
  }, [session]);
return (...)
}
Source Code​
A working example can be accessed here.