Refresh token rotation is the practice of updating an access_token
on behalf of the user, without requiring interaction (eg.: re-sign in). access_token
s 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.
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.
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:
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.