-
QuestionI'm using Auth.js v5 (beta) with Next.js 16 App Router and a custom OAuth provider (Keycloak). My setup:
The problem: when the access token expires, the session is treated as expired and the user gets redirected to the login page — even though the refresh token is still valid for days. I've implemented refresh token rotation in the callbacks: {
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.accessTokenExpires = Date.now() + account.expires_in * 1000;
return token;
}
// Return token if still valid
if (Date.now() < token.accessTokenExpires) {
return token;
}
// Attempt refresh
const refreshed = await refreshAccessToken(token);
return refreshed;
},
}
async function refreshAccessToken(token) {
const res = await fetch(`${KEYCLOAK_URL}/protocol/openid-connect/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
refresh_token: token.refreshToken,
}),
});
if (!res.ok) throw new Error("Refresh failed");
const data = await res.json();
return {
...token,
accessToken: data.access_token,
refreshToken: data.refresh_token ?? token.refreshToken,
accessTokenExpires: Date.now() + data.expires_in * 1000,
};
} Issues I'm hitting:
What I've tried:
Questions:
How to reproduce (optional)
Expected behavior (optional) Auth.js should deduplicate concurrent refresh token requests so that only one refresh call is made, and all parallel requests share the result. Information about Prisma Schema, Client Queries and Environment (optional)
|
Beta Was this translation helpful? Give feedback.
Replies: 1 comment
-
|
This is a known pain point with Auth.js v5 + App Router. The core issue is that the 1. Single-flight token refresh — deduplicate with a shared Promise The // lib/auth-refresh.ts
let inflightRefresh: Promise<any> | null = null;
export async function refreshAccessTokenOnce(token: any) {
if (inflightRefresh) return inflightRefresh;
inflightRefresh = refreshAccessToken(token).finally(() => {
inflightRefresh = null;
});
return inflightRefresh;
}
async function refreshAccessToken(token: any) {
const res = await fetch(`${KEYCLOAK_URL}/protocol/openid-connect/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
refresh_token: token.refreshToken,
}),
});
if (!res.ok) throw new Error("Refresh failed");
const data = await res.json();
return {
...token,
accessToken: data.access_token,
refreshToken: data.refresh_token ?? token.refreshToken,
accessTokenExpires: Date.now() + data.expires_in * 1000,
};
}Then in your auth config: callbacks: {
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.accessTokenExpires = Date.now() + account.expires_in * 1000;
return token;
}
if (Date.now() < token.accessTokenExpires) return token;
try {
return await refreshAccessTokenOnce(token);
} catch {
return { ...token, error: "RefreshTokenError" };
}
},
}All parallel RSC requests share the same in-flight Promise — only one hits Keycloak. 2. Proactive refresh — avoid the race window entirely Instead of waiting for the token to expire and then racing, refresh it before it expires: async jwt({ token, account }) {
if (account) { /* ... initial login ... */ }
const bufferMs = 60 * 1000; // refresh 60s before expiry
if (Date.now() < token.accessTokenExpires - bufferMs) {
return token; // still fresh, no refresh needed
}
try {
return await refreshAccessTokenOnce(token);
} catch {
return { ...token, error: "RefreshTokenError" };
}
}This way, the refresh happens during normal navigation while the access token is still valid. By the time it actually expires, you already have a new one. 3. Handle refresh failure gracefully on the client Instead of letting middleware redirect to sign-in immediately, surface the error so the client can retry: // In your session callback
async session({ session, token }) {
session.error = token.error;
return session;
}// In your client component
"use client";
import { useSession, signIn } from "next-auth/react";
export function AuthGuard({ children }) {
const { data: session } = useSession();
if (session?.error === "RefreshTokenError") {
signIn("keycloak"); // force re-auth only when refresh truly fails
return null;
}
return children;
}Regarding Discussion #3940 — Auth.js hasn't built in single-flight refresh because the TL;DR: Combine proactive refresh (60s buffer before expiry) + in-flight Promise deduplication. This eliminates the race condition and keeps users logged in as long as the refresh token is valid. |
Beta Was this translation helpful? Give feedback.
This is a known pain point with Auth.js v5 + App Router. The core issue is that the
jwtcallback has no built-in concurrency control, and Next.js 16 fires parallel RSC requests on navigation. Here's how to solve each part:1. Single-flight token refresh — deduplicate with a shared Promise
The
jwtcallback runs in the same Node.js process (assuming self-hosted, not Edge), so module-level state works: