Description:
Logged-in users are redirected back to the login flow randomly several times per day. It happens even when their session should still be valid. Our app must attach a bearer token in the Authorization header for every API request, so we suspect our 401 handling or refresh flow may be contributing.
Environment:
-
Next.js (App Router) with
@auth0/nextjs-auth0(edge) -
Universal Login
-
Organizations enabled
Current approach (summary):
-
Refresh tokens via
GET /api/auth/refresh-sessionusinggetAccessToken(req, res, { refresh: true }), then mirror to a cookie namedaccess_token. -
Browser Axios client:
-
Proactively calls
/api/auth/refresh-sessionwhen the cached token is near expiry. -
Injects
Authorization: Bearer <access_token>on every API call. -
If refresh returns 401, clears local cache and currently redirects to
/api/auth/login.
-
Questions:
-
Is there a recommended best practice for apps that must always attach
Authorization: Bearer <token>to every API request? -
Should we handle this differently than our current approach (refresh endpoint + header injection + redirect to login on 401)?
-
Are there known intermittent cases where
getAccessTokencan return 401 even when the session cookie appears valid (edge runtime and organizations enabled)?
Relevant code (redacted):
// app/api/auth/refresh-session/route.ts
import { getAccessToken } from "@auth0/nextjs-auth0/edge";
import { NextRequest, NextResponse } from "next/server";
import { jwtDecode } from "jwt-decode";
import { cookies } from "next/headers";
import { handleReqError } from "../../../../utils/errorHandler";
import { getAccessToken } from "@auth0/nextjs-auth0/edge";
import { NextRequest, NextResponse } from "next/server";
import { jwtDecode } from "jwt-decode";
import { cookies } from "next/headers";
import { handleReqError } from "../../../../utils/errorHandler";
export async function GET(req: NextRequest) {
try {
const cookieStore = await cookies();
const existingToken = cookieStore.get("access_token")?.value;
const res = new NextResponse();
if (!existingToken) {
// No cached token, get fresh one
try {
const { accessToken } = await getAccessToken(req, res, {
refresh: true,
});
res.cookies.set("access_token", accessToken, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24,
});
const updatedDecoded = jwtDecode(accessToken);
return NextResponse.json(
{ token: accessToken, exp: updatedDecoded.exp },
res
);
} catch (error) {
return handleReqError(error, "Failed to get fresh token", req);
}
}
const decoded: any = jwtDecode(existingToken);
const now = Math.floor(Date.now() / 1000);
const is_Expired = decoded.exp && decoded.exp < now;
const hasMissingClaims =
!decoded[`${process.env.BASE_API_URL}/claims/user_id`];
if (is_Expired || hasMissingClaims) {
try {
const { accessToken } = await getAccessToken(req, res, {
refresh: true,
});
if (!accessToken) {
return handleReqError(
{ response: { status: 401 } },
"No token received from Auth0",
req
);
}
res.cookies.set("access_token", accessToken, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24,
});
const updatedDecoded = jwtDecode(accessToken);
return NextResponse.json(
{
token: accessToken,
exp: updatedDecoded.exp,
isFirstLogin: hasMissingClaims,
},
res
);
} catch (error) {
return handleReqError(error, "Failed to refresh token", req);
}
}
// token is still valid and has the required claim
return NextResponse.json({ ok: true, isFirstLogin: hasMissingClaims }, res);
} catch (error) {
return handleReqError(error, "Token validation failed", req);
}
}
// browser Axios client (simplified)
// - Proactive refresh via /api/auth/refresh-session
// - Injects Authorization header
// - On 401 from refresh, clears cache and redirects to login
import axios from "axios";
let cachedToken: string | null = null;
let tokenExpiry = 0;
let refreshPromise: Promise<void> | null = null;
async function refreshIfNeeded() {
const now = Math.floor(Date.now() / 1000);
const needs = !cachedToken || !tokenExpiry || now >= tokenExpiry - 60;
if (!needs) return;
if (!refreshPromise) {
refreshPromise = fetch("/api/auth/refresh-session", { credentials: "include" })
.then(async (res) => {
if (!res.ok) throw new Error(`Refresh failed: ${res.status}`);
const { token, exp } = await res.json();
cachedToken = token ?? null;
tokenExpiry = exp ?? now + 3600;
})
.catch((e) => {
cachedToken = null;
tokenExpiry = 0;
throw e;
})
.finally(() => { refreshPromise = null; });
}
await refreshPromise;
}
export function createBrowserAuthClient(baseURL = "/api") {
const client = axios.create({ baseURL, withCredentials: true });
client.interceptors.request.use(async (config) => {
await refreshIfNeeded();
if (cachedToken) {
config.headers = config.headers ?? {};
config.headers.Authorization = `Bearer ${cachedToken}`;
}
return config;
});
client.interceptors.response.use(
(r) => r,
(err) => {
if (err?.response?.status === 401) {
cachedToken = null;
tokenExpiry = 0;
// current behavior: send user to login
window.location.replace("/api/auth/login");
}
return Promise.reject(err);
}
);
return client;
}