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. Refreshing your access_token with other providers will look very similar, you will just need to adjust the endpoint and potentially the contents of the body being sent to them in the request.

💡

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. Depending on the session strategy, the refresh_token can be persisted either in a database, in a cookie, or in an encrypted JWT.

💡

While using a JWT to store the refresh_token is very common, it is less secure than saving it in a database as it is easier for a potential attacker to retrieve from a JWT compared to your applications database. 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 of refreshing the access_token with Google. Please note that the OAuth 2.0 request to get the refresh_token will vary between different providers, but the rest of logic should remain similar.

./auth.ts
import NextAuth, { type User } 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,
      // Google requires "offline" access_type to provide a `refresh_token`
      authorization: { params: { access_type: "offline", prompt: "consent" } },
    }),
  ],
  callbacks: {
    async jwt({ token, account, profile }) {
      if (account) {
        // First login, save the `access_token`, `refresh_token`, and other
        // details into the JWT
 
        const userProfile: User = {
          id: token.sub,
          name: profile?.name,
          email: profile?.email,
          image: token?.picture,
        }
 
        return {
          access_token: account.access_token,
          expires_at: account.expires_at,
          refresh_token: account.refresh_token,
          user: userProfile,
        }
      } else if (Date.now() < token.expires_at * 1000) {
        // Subsequent logins, if the `access_token` is still valid, return the JWT
        return token
      } else {
        // Subsequent logins, if the `access_token` has expired, try to refresh it
        if (!token.refresh_token) throw new Error("Missing refresh token")
 
        try {
          // The `token_endpoint` can be found in the provider's documentation. Or if they support OIDC,
          // at their `/.well-known/openid-configuration` endpoint.
          // i.e. https://accounts.google.com/.well-known/openid-configuration
          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 responseTokens = await response.json()
 
          if (!response.ok) throw responseTokens
 
          return {
            // Keep the previous token properties
            ...token,
            access_token: responseTokens.access_token,
            expires_at: Math.floor(Date.now() / 1000 + (responseTokens.expires_in as number)),
            // Fall back to old refresh token, but note that
            // many providers may only allow using a refresh token once.
            refresh_token: responseTokens.refresh_token ?? token.refresh_token,
          }
        } catch (error) {
          console.error("Error refreshing access token", error)
          // The error property can be used client-side to handle the refresh token error
          return { ...token, error: "RefreshAccessTokenError" as const }
        }
      }
    },
    async session({ session, token }) {
      if (token.user) {
        session.user = token.user as User
      }
 
      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"
  }
}

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 responseTokens = await response.json()
 
          if (!response.ok) throw responseTokens
 
          await prisma.account.update({
            data: {
              access_token: responseTokens.access_token,
              expires_at: Math.floor(
                Date.now() / 1000 + responseTokens.expires_in
              ),
              refresh_token:
                responseTokens.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 can 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:

app/dashboard/page.tsx
"use client";
 
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>;
}
Auth.js © Balázs Orbán and Team - 2024