I'm using next.js as frontend, django as backend ,...
تم الإنشاء في: ٢ مارس ٢٠٢٥
تم الإنشاء في: ٢ مارس ٢٠٢٥
I'm using next.js as frontend, django as backend , and use jwt token. but I find out after I login to the webpage, after a while like less then a hour I have to log in again, can you help me diagnose what's the problem? this is my actions.ts file in utils folder
"use server";
import { cookies } from "next/headers";
/**
const refreshToken = await getRefreshToken();
if (!refreshToken) {
// If there's no refresh token, just reset cookies and return
await resetAuthCookies();
return null;
}
try {
const response = await fetch("http://localhost:8000/api/auth/token/refresh/", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ refresh: refreshToken }),
});
textconst data = await response.json(); console.log("Response - Refresh:", data); if (data?.access) { // Store new access token in the cookies const cookieStore = await cookies(); cookieStore.set("session_access_token", data.access, { httpOnly: true, secure: false, maxAge: 60 * 60, // 60 minutes path: "/", }); return data.access; } else { // If refresh fails or no "access" in response, reset everything await resetAuthCookies(); return null; }
} catch (error) {
console.error("Refresh error:", error);
await resetAuthCookies();
return null;
}
}
/**
cookieStore.set("session_userid", userId, {
httpOnly: true,
secure: false,
maxAge: 60 * 60 * 24 * 7, // One week
path: "/",
});
cookieStore.set("session_access_token", accessToken, {
httpOnly: true,
secure: false,
maxAge: 60 * 60, // 60 minutes
path: "/",
});
cookieStore.set("session_refresh_token", refreshToken, {
httpOnly: true,
secure: false,
maxAge: 60 * 60 * 24 * 7, // One week
path: "/",
});
}
/**
cookieStore.set("session_userid", "", { path: "/" });
cookieStore.set("session_access_token", "", { path: "/" });
cookieStore.set("session_refresh_token", "", { path: "/" });
}
/**
/**
if (!accessToken) {
accessToken = await handleRefresh();
}
return accessToken;
}
/**
Here are a few common pitfalls that can cause you to lose authentication within an hour (or even sooner) despite having a refresh token:
No Automatic Call to handleRefresh()
In your snippet, handleRefresh()
only gets called inside getAccessToken()
when accessToken
is completely missing from the cookie store. If the token is simply expired (but still in the cookie), getAccessToken()
won’t automatically trigger a refresh. It’s possible the browser is holding on to an expired token for a little while before removing it, which forces you to log back in.
How to fix:
401 Unauthorized
(i.e., when the access token is expired).maxAge
for the access token cookie is only 60 minutes
You’ve set:
jsmaxAge: 60 * 60 // 60 minutes
for the access token. That means your cookie instructs the browser to drop the token after an hour. If you’re not triggering handleRefresh()
before that hour is up, you’ll lose the token—and then be forced to re-login the next time you do something that needs authorization.
Refresh Token Not Actually Being Sent or Set
session_refresh_token
is actually being stored in the browser as an HTTP-only cookie.localhost:3000
) from your Django API (e.g., localhost:8000
), you need to ensure cross-site cookies are allowed. That often requires setting sameSite: 'none'
and secure: true
if you’re on HTTPS.fetch
or axios
with credentials: 'include'
. Otherwise the browser won’t send your refresh token to the backend, and the refresh endpoint will fail (or not even see your refresh cookie).Simple JWT (or Other JWT Package) Settings on the Django Side
If you’re using django-rest-framework-simplejwt, check your settings in settings.py
for something like:
pythonSIMPLE_JWT = { 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), ... }
If REFRESH_TOKEN_LIFETIME
is very short or if ROTATE_REFRESH_TOKENS
or other flags are not set how you expect, you might be invalidating the refresh token sooner than you realize.
Missing or Overriding Cookies
Sometimes the code setting the cookie does not fully clear out old cookies, or the domain/path don’t match. (By default, sameSite
might be Lax
in Next.js, which can block cross-domain usage of cookies.) Make sure:
path
and domain
match your usage.jscookieStore.set("session_refresh_token", refreshToken, { httpOnly: true, secure: true, // Use true in production sameSite: "none", maxAge: 60 * 60 * 24 * 7, path: "/", });
credentials: 'include'
.Implement a Token-Refresh Flow
fetch
or axios
on the client) that catches 401
and tries handleRefresh()
.handleRefresh()
on behalf of the user.Check Cookie Policies (sameSite, secure, domain/port)
Since you’re on localhost
, try:
jscookieStore.set("session_refresh_token", refreshToken, { httpOnly: true, secure: false, // On localhost only, otherwise true sameSite: "lax", // or "none" if you do cross-site maxAge: 60 * 60 * 24 * 7, path: "/", });
If your Next app is localhost:3000
and your Django app is localhost:8000
, that’s considered cross-site. You generally must use sameSite: 'none'
and secure: true
for modern browsers to accept cross-site cookies. Also remember to make your fetch
calls with credentials: 'include'
.
Double-Check Django Settings
Make sure the Refresh Token lifetime is actually a week (or whatever you set). Also confirm that the “refresh” endpoint is working in real-time by checking the network tab or your server logs. Often you’ll see an error message like “Token is invalid or expired” if the refresh has already been invalidated.
With these changes in place—most importantly, setting up a real flow that calls handleRefresh()
before or when the access token expires—you should be able to stay logged in for as long as your refresh token is valid.
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
...
}
I do have set this in my django backend
so how do you think I should change my codes? this codes is just I leaning from the other's project, that person's original codes look like this:
'use server';
import { cookies } from 'next/headers';
export async function handleRefresh() {
console.log('handleRefresh');
textconst refreshToken = await getRefreshToken(); const token = await fetch('http://localhost:8000/api/auth/token/refresh/', { method: 'POST', body: JSON.stringify({ refresh: refreshToken }), headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } }) .then(response => response.json()) .then((json) => { console.log('Response - Refresh:', json); if (json.access) { cookies().set('session_access_token', json.access, { httpOnly: true, secure: false, maxAge: 60 * 60, // 60 minutes path: '/' }); return json.access; } else { resetAuthCookies(); } }) .catch((error) => { console.log('error', error); resetAuthCookies(); }) return token;
}
export async function handleLogin(userId: string, accessToken: string, refreshToken: string) {
cookies().set('session_userid', userId, {
httpOnly: true,
secure: false,
maxAge: 60 * 60 * 24 * 7, // One week
path: '/'
});
textcookies().set('session_access_token', accessToken, { httpOnly: true, secure: false, maxAge: 60 * 60, // 60 minutes path: '/' }); cookies().set('session_refresh_token', refreshToken, { httpOnly: true, secure: false, maxAge: 60 * 60 * 24 * 7, // One week path: '/' });
}
export async function resetAuthCookies() {
cookies().set('session_userid', '');
cookies().set('session_access_token', '');
cookies().set('session_refresh_token', '');
}
//
// Get data
export async function getUserId() {
const userId = cookies().get('session_userid')?.value
return userId ? userId : null
}
export async function getAccessToken() {
let accessToken = cookies().get('session_access_token')?.value;
textif (!accessToken) { accessToken = await handleRefresh(); } return accessToken;
}
export async function getRefreshToken() {
let refreshToken = cookies().get('session_refresh_token')?.value;
return refreshToken;
}
I want to set the best way for my own project help me find where should I change my codes
Below is a more robust pattern you can adapt. I’ll summarize the “why” first, then show exactly where and how to adjust your code.
Access Token Expiration
You have ACCESS_TOKEN_LIFETIME = 60 minutes
. That’s perfectly normal, but it means you need a systematic way to refresh before you lose that token entirely. Right now you only call handleRefresh()
if the accessToken
cookie is missing, but a missing cookie ≠ an expired token. The cookie can still be present in the browser even after the token has expired. That leads to 401 errors (or failing requests), forcing a re-login.
Automatic Refresh Flow
Typically, one of these strategies is used:
handleRefresh()
automatically (using your stored refresh token) and retry the request.exp
(expiration) claim, and refresh if the token is near or past its expiration.Cookie Settings (Cross-Site vs. Same-Site)
If your Next.js app is running on localhost:3000
and your Django backend on localhost:8000
, that is considered cross-site in modern browsers. So you often need to set sameSite: 'none'
and secure: true
for cookies to be sent cross-site. And your fetch
calls must explicitly set credentials: 'include'
.
Because you’re currently using secure: false
(which is okay on localhost), you may also need sameSite: 'none'
if you want the browser to send cookies from :3000
to :8000
.
Below is a revised version of your actions that addresses the biggest pitfalls. Feel free to merge these changes into your own code. I’ve annotated the key lines:
ts"use server"; import { cookies } from "next/headers"; // 1. Use "credentials: 'include'" on the client side when fetching or do SSR calls // from Next.js server side. Otherwise, no cookies get sent to Django cross-site. // // 2. If you need cross-site cookies (different domain/port), set sameSite='none' // *and* secure=true (except on plain localhost dev). /** * Refresh the access token using the stored refresh token. */ export async function handleRefresh() { console.log("handleRefresh"); const refreshToken = await getRefreshToken(); if (!refreshToken) { // If there's no refresh token, reset cookies and return await resetAuthCookies(); return null; } try { const response = await fetch("http://localhost:8000/api/auth/token/refresh/", { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, // Important: If this is cross-origin, you need credentials: 'include' // to actually send cookies. But since this is a "server action", // Next.js automatically has access to set/get cookies. // If you do any client-side fetch, you'd add credentials: 'include'. body: JSON.stringify({ refresh: refreshToken }), }); if (!response.ok) { // Possibly 401 or 403 if the refresh token is invalid console.error("Refresh failed with status:", response.status); await resetAuthCookies(); return null; } const data = await response.json(); console.log("Response - Refresh:", data); if (data?.access) { // Store new access token in the cookies const cookieStore = cookies(); cookieStore.set("session_access_token", data.access, { httpOnly: true, // In production, do secure: true. On localhost dev, false is okay. secure: false, // If cross-site: sameSite: "none" is needed // sameSite: "none", maxAge: 60 * 60, // 60 minutes path: "/", }); return data.access; } else { await resetAuthCookies(); return null; } } catch (error) { console.error("Refresh error:", error); await resetAuthCookies(); return null; } } /** * Handle the login process by storing userId/accessToken/refreshToken as cookies. */ export async function handleLogin( userId: string, accessToken: string, refreshToken: string ) { const cookieStore = cookies(); cookieStore.set("session_userid", userId, { httpOnly: true, secure: false, // sameSite: "none", // If cross-site maxAge: 60 * 60 * 24 * 7, // One week path: "/", }); cookieStore.set("session_access_token", accessToken, { httpOnly: true, secure: false, // sameSite: "none", // If cross-site maxAge: 60 * 60, // 60 minutes path: "/", }); cookieStore.set("session_refresh_token", refreshToken, { httpOnly: true, secure: false, // sameSite: "none", // If cross-site maxAge: 60 * 60 * 24 * 7, // One week path: "/", }); } /** * Resets (clears) all authentication-related cookies. */ export async function resetAuthCookies() { const cookieStore = cookies(); // Setting them to empty strings also effectively removes them, but you might // want to set 'expires' to a past date or maxAge=0 to more explicitly remove them. cookieStore.set("session_userid", "", { path: "/" }); cookieStore.set("session_access_token", "", { path: "/" }); cookieStore.set("session_refresh_token", "", { path: "/" }); } /** * Retrieves the "session_userid" cookie (if any). */ export async function getUserId() { const cookieStore = cookies(); const userId = cookieStore.get("session_userid")?.value; return userId || null; } /** * Retrieves the "session_access_token" cookie. * If missing, tries to refresh it once. */ export async function getAccessToken() { const cookieStore = cookies(); let accessToken = cookieStore.get("session_access_token")?.value; // WARNING: This only refreshes if the cookie is *missing*. // If the cookie is present but the token is expired, you'll need either: // a) 401/403-based refresh or // b) decode the token to see if it's expired if (!accessToken) { accessToken = await handleRefresh(); } return accessToken; } /** * Retrieves the "session_refresh_token" cookie (if any). */ export async function getRefreshToken() { const cookieStore = cookies(); const refreshToken = cookieStore.get("session_refresh_token")?.value; return refreshToken || null; }
handleRefresh()
– if the response isn’t OK (e.g., 401), reset cookies so the user doesn’t get stuck with invalid tokens.secure: false
is okay for local dev. In production with HTTPS, always use secure: true
.sameSite: 'none'
.sameSite: 'none'
, you must use secure: true
in a production HTTPS environment.getAccessToken()
logic – If your only check is “no cookie => refresh,” then when the cookie is still present (but the token is expired), your backend calls will fail with 401. You either need:
handleRefresh()
and retry.exp
is near or past, call handleRefresh()
before making a request.If you’re making API calls from your client (e.g., inside React components) using fetch
or axios
, it’s common to do something like:
ts// Pseudocode using fetch: async function myFetch(url, options) { let resp = await fetch(url, { // ... credentials: 'include', // Very important if you want to send cookies cross-site ...options }); if (resp.status === 401) { // Attempt to refresh: await handleRefresh(); // Retry once: resp = await fetch(url, { // ... credentials: 'include', ...options }); } return resp; }
Or the equivalent in Axios interceptors. This ensures that if your token expired on the server side, you refresh automatically and the user doesn’t get forced to re-login manually.
SIMPLE_JWT
SettingsYou have:
pythonSIMPLE_JWT = { 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), ... }
That’s standard: an access token valid for 1 hour, refresh token for 7 days. Just confirm other relevant options like ROTATE_REFRESH_TOKENS
if you want a new refresh token each time (it’s optional).
maxAge: 60 * 60
on the cookie only tells the browser “drop this cookie after 1 hour.” But your token might expire earlier or later if the system time or user’s clock is weird. Typically it aligns pretty closely, but the real “authority” is the token’s exp
claim validated by Django.sameSite: 'none'
(and secure: true
once you use HTTPS).With these changes, you should see stable 1-hour access tokens and a 7-day refresh window that doesn’t force your users to re-login every hour.
ok, notify I'm using next.js as frontend and django as frontend, what's the best way to do this jwt validation? save token to cookies? or other better way? am I'm using the newest and best way?
There’s not a single one-size-fits-all “best” solution for handling JWT in Next.js + Django, but there are well-established best practices you can follow. Generally, the two big questions you need to clarify are:
Below is a short, opinionated summary.
Pros:
httpOnly
, JavaScript can’t read or manipulate it. Even if your site has an XSS vulnerability, attackers typically can’t steal the token directly from the cookie.sameSite: "none"
+ secure: true
on HTTPS).Cons:
sameSite
, and secure
attributes if the client and server are on different domains/ports.localhost:3000
, Django on localhost:8000
), you must enable credentials: 'include'
or the equivalent in your fetch/axios calls to ensure the browser actually sends the cookies.Pros:
sameSite
.Cons:
Authorization
headers or query params.Because of the XSS concern, most security professionals recommend using HTTP-only cookies for storing refresh tokens (and typically access tokens as well). If you need to pass the token in an Authorization: Bearer ...
header from the client, it becomes more complicated to read them from an httpOnly cookie, so some teams store the short-lived access token in memory and keep the refresh token in an httpOnly cookie.
Most JWT setups use short-lived access tokens (e.g., 1 hour) combined with long-lived refresh tokens (e.g., 7 days or 30 days). If your user is active, you refresh the access token silently in the background. The two main approaches:
Check for 401 → Attempt Refresh
401 Unauthorized
(because the access token is expired or invalid), you automatically call your “refresh” endpoint (with the refresh token in the HTTP-only cookie) to get a new access token. Then you retry the original request.Proactive Refresh (Token Expiry Check)
In practice, the 401 approach tends to be simpler to implement. Many people use an Axios (or fetch) interceptor that, on any 401 response, tries to refresh once and then retries the request. If that also fails (e.g., the refresh token is invalid or expired), then you log the user out.
Short answer: Storing the JWTs in an httpOnly cookie and implementing a refresh flow (401-based or proactive) is currently the recommended pattern for many production apps that use Next.js + Django. Just be sure to handle:
sameSite
, secure
, httpOnly
) correctly for your environment.credentials: 'include'
in fetch calls.axios
, it’s easy to add an interceptor. If using fetch
, you can write a small wrapper function.With that, you’ll have a robust, modern, and secure approach to Next.js + Django + JWT authentication.
nice, help me change my codes then, and I'm using fetch now
"use server";
import { cookies } from "next/headers";
/**
const refreshToken = await getRefreshToken();
if (!refreshToken) {
// If there's no refresh token, just reset cookies and return
await resetAuthCookies();
return null;
}
try {
const response = await fetch("http://localhost:8000/api/auth/token/refresh/", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ refresh: refreshToken }),
});
textconst data = await response.json(); console.log("Response - Refresh:", data); if (data?.access) { // Store new access token in the cookies const cookieStore = await cookies(); cookieStore.set("session_access_token", data.access, { httpOnly: true, secure: false, maxAge: 60 * 60, // 60 minutes path: "/", }); return data.access; } else { // If refresh fails or no "access" in response, reset everything await resetAuthCookies(); return null; }
} catch (error) {
console.error("Refresh error:", error);
await resetAuthCookies();
return null;
}
}
/**
cookieStore.set("session_userid", userId, {
httpOnly: true,
secure: false,
maxAge: 60 * 60 * 24 * 7, // One week
path: "/",
});
cookieStore.set("session_access_token", accessToken, {
httpOnly: true,
secure: false,
maxAge: 60 * 60, // 60 minutes
path: "/",
});
cookieStore.set("session_refresh_token", refreshToken, {
httpOnly: true,
secure: false,
maxAge: 60 * 60 * 24 * 7, // One week
path: "/",
});
}
/**
cookieStore.set("session_userid", "", { path: "/" });
cookieStore.set("session_access_token", "", { path: "/" });
cookieStore.set("session_refresh_token", "", { path: "/" });
}
/**
/**
if (!accessToken) {
accessToken = await handleRefresh();
}
return accessToken;
}
/**
here's my codes right now, help me change it
Below is a revised version of your server-side code with some added best-practice adjustments. In addition, I’ll show you how to set up a fetch wrapper for use on the client that automatically attempts a token refresh if it sees a 401
response. This “401 interceptor” approach ensures you don’t force the user to log in again if their access token has expired but their refresh token is still valid.
actions.ts
)Note: If you’re calling your Django API purely from Next.js server actions (on the server side), then you can catch 401s server-side too. However, most people eventually make client-side fetch calls, e.g., from React components. That’s where a 401 interceptor is easiest to implement.
Key changes in the snippet below:
response.ok
in handleRefresh()
. If the refresh endpoint returns a 401 or 403, we reset cookies and return null
.sameSite: "none"
in the cookie options if you’re truly cross-site (e.g., Next.js on localhost:3000
, Django on localhost:8000
).secure: false
for local development. In production with HTTPS, set it to true
.ts"use server"; import { cookies } from "next/headers"; /** * Refresh the access token using the stored refresh token. */ export async function handleRefresh() { console.log("handleRefresh"); const refreshToken = await getRefreshToken(); if (!refreshToken) { console.log("No refresh token found, resetting auth cookies."); await resetAuthCookies(); return null; } try { const response = await fetch("http://localhost:8000/api/auth/token/refresh/", { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, // Typically, if your Next.js app is on a different port (3000), // you'd need credentials: 'include' for cross-site fetches. // But "server actions" can read/write cookies directly with `cookies()`, // so you might not need that here. body: JSON.stringify({ refresh: refreshToken }), }); if (!response.ok) { // Possibly 401 or 403 if the refresh token is invalid or expired console.error("Refresh failed with status:", response.status); await resetAuthCookies(); return null; } const data = await response.json(); console.log("Response - Refresh:", data); if (data?.access) { const cookieStore = cookies(); cookieStore.set("session_access_token", data.access, { httpOnly: true, // NOTE: For local dev only: secure: false, // If truly cross-site, you typically want sameSite: "none" here: // sameSite: "none", maxAge: 60 * 60, // 1 hour in seconds path: "/", }); return data.access; } else { // If refresh fails or "access" is not in the response console.log("No 'access' field in refresh response, resetting cookies."); await resetAuthCookies(); return null; } } catch (error) { console.error("Refresh error:", error); await resetAuthCookies(); return null; } } /** * Handle the login process by storing userId/accessToken/refreshToken as cookies. */ export async function handleLogin( userId: string, accessToken: string, refreshToken: string ) { const cookieStore = cookies(); cookieStore.set("session_userid", userId, { httpOnly: true, secure: false, // sameSite: "none", // if cross-site maxAge: 60 * 60 * 24 * 7, // 7 days path: "/", }); cookieStore.set("session_access_token", accessToken, { httpOnly: true, secure: false, // sameSite: "none", // if cross-site maxAge: 60 * 60, // 1 hour path: "/", }); cookieStore.set("session_refresh_token", refreshToken, { httpOnly: true, secure: false, // sameSite: "none", // if cross-site maxAge: 60 * 60 * 24 * 7, // 7 days path: "/", }); } /** * Resets (clears) all authentication-related cookies. */ export async function resetAuthCookies() { const cookieStore = cookies(); // Clearing or overwriting with empty values cookieStore.set("session_userid", "", { path: "/" }); cookieStore.set("session_access_token", "", { path: "/" }); cookieStore.set("session_refresh_token", "", { path: "/" }); } /** * Retrieves the "session_userid" cookie (if any). */ export async function getUserId() { const cookieStore = cookies(); const userId = cookieStore.get("session_userid")?.value; return userId || null; } /** * Retrieves the "session_access_token" cookie. * If it's missing, tries to refresh it once. */ export async function getAccessToken() { const cookieStore = cookies(); let accessToken = cookieStore.get("session_access_token")?.value; // This only attempts a refresh if the cookie doesn't exist at all. // If the token is expired (but still in the cookie), you won't refresh here. // That's why on the client, you often do a 401-based refresh approach. if (!accessToken) { accessToken = await handleRefresh(); } return accessToken; } /** * Retrieves the "session_refresh_token" cookie (if any). */ export async function getRefreshToken() { const cookieStore = cookies(); const refreshToken = cookieStore.get("session_refresh_token")?.value; return refreshToken || null; }
If you make client-side fetch calls in Next.js (e.g., inside your React components), you normally need to handle 401 responses. The simplest approach is to write a small utility function like this:
ts// utils/clientFetch.ts export async function clientFetch( url: string, options: RequestInit = {} ): Promise<Response> { // 1. Always send cookies with cross-site requests // so the server can read 'session_access_token', etc. // 2. We set credentials: 'include' to ensure cookies are included // if your Next and Django are on different domains/ports const mergedOptions = { ...options, credentials: "include" as RequestCredentials, headers: { "Content-Type": "application/json", Accept: "application/json", ...(options.headers || {}), }, }; let response = await fetch(url, mergedOptions); if (response.status === 401) { // Attempt to refresh the token on the server // This calls your Next.js server action which // calls "handleRefresh" behind the scenes: await fetch("/api/refresh", { method: "POST" }); // Then retry the original request response = await fetch(url, mergedOptions); } return response; }
Create a Next.js API Route (/api/refresh
) that calls your server action’s handleRefresh()
. For example:
ts// app/api/refresh/route.ts import { NextResponse } from "next/server"; import { handleRefresh } from "@/utils/actions"; export async function POST() { await handleRefresh(); return NextResponse.json({ ok: true }); }
handleRefresh()
directly if your Next.js version allows server actions from the client. But typically, you can’t just call a server action from a plain fetch. You need an endpoint or special form.Use clientFetch
anywhere in your client code (React components, etc.) instead of the default fetch
. For example:
tsimport { clientFetch } from "@/utils/clientFetch"; export default function SomeComponent() { async function loadData() { const resp = await clientFetch("http://localhost:8000/api/some-protected-route", { method: "GET", }); if (resp.ok) { const data = await resp.json(); console.log("Loaded data:", data); } else { // handle errors } } return <button onClick={loadData}>Load Data</button>; }
If the access_token
cookie is expired, Django will return a 401
for that “protected” route. Your clientFetch
sees the 401
, calls /api/refresh
→ triggers handleRefresh()
on the server → sets a new session_access_token
cookie → retries the request → and hopefully succeeds.
actions.ts
) – improved logging, explicit checks, correct cookie attributes (sameSite
, secure
), etc.clientFetch.ts
) – a small fetch wrapper that automatically calls /api/refresh
and retries if it sees 401
. This pattern avoids forcing your users to manually re-login if their token expires but the refresh token is still valid.localhost:3000
(Next) → localhost:8000
(Django), that’s considered cross-site. Modern browsers require:
sameSite: "none"
secure: true
(in production over HTTPS)credentials: "include"
in fetchSIMPLE_JWT
settings as you have them, with a 1-hour ACCESS_TOKEN_LIFETIME
and a 7-day REFRESH_TOKEN_LIFETIME
. That’s perfectly fine, as long as you handle refresh logic properly.With these changes in place, you’ll have a robust token-based auth setup that automatically refreshes the user’s access token on the client side—and uses HTTP-only cookies to protect the tokens against XSS.
import { getAccessToken } from "../lib/actions";
async function request(
method: string,
url: string,
data: any = null,
withToken: boolean = true
): Promise<any> {
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
};
if (withToken) {
const token = await getAccessToken();
headers["Authorization"] = Bearer ${token}
;
}
const response = await fetch(${process.env.NEXT_PUBLIC_API_HOST}${url}
, {
method,
headers,
body: data ? JSON.stringify(data) : null,
});
let responseData: any;
try {
responseData = await response.json();
} catch (jsonError) {
const text = await response.text();
console.error("Response not valid JSON:", text);
throw new Error(
Could not parse JSON. Status: ${response.status}, Body: ${text}
);
}
if (!response.ok) {
console.error("HTTP Error:", response.status, responseData);
throw new Error(HTTP ${response.status}: ${JSON.stringify(responseData)}
);
}
return responseData;
}
const apiService = {
get: async (url: string): Promise<any> => {
console.log("GET", url);
return request("GET", url);
},
post: async (url: string, data: any): Promise<any> => {
console.log("POST", url, data);
return request("POST", url, data, true);
},
postWithoutToken: async (url: string, data: any): Promise<any> => {
console.log("POST WITHOUT TOKEN", url, data);
return request("POST", url, data, false);
},
put: async (url: string, data: any): Promise<any> => {
console.log("PUT", url, data);
return request("PUT", url, data, true);
},
patch: async (url: string, data: any): Promise<any> => {
console.log("PATCH", url, data);
return request("PATCH", url, data, true);
},
delete: async (url: string): Promise<any> => {
console.log("DELETE", url);
return request("DELETE", url, true);
},
};
export default apiService;
I also have this apiservice file
// utils/clientFetch.ts
export async function clientFetch(
url: string,
options: RequestInit = {}
): Promise<Response> {
// 1. Always send cookies with cross-site requests
// so the server can read 'session_access_token', etc.
// 2. We set credentials: 'include' to ensure cookies are included
// if your Next and Django are on different domains/ports
const mergedOptions = {
...options,
credentials: "include" as RequestCredentials,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...(options.headers || {}),
},
};
let response = await fetch(url, mergedOptions);
if (response.status === 401) {
// Attempt to refresh the token on the server
// This calls your Next.js server action which
// calls "handleRefresh" behind the scenes:
await fetch("/api/refresh", { method: "POST" });
text// Then retry the original request response = await fetch(url, mergedOptions);
}
return response;
}
does your codes the same way as my apiservice codes? Is this to much redundent, should I change my ways to axios?
Right now, you have two different approaches for making requests:
apiService
(using a custom request
function) that calls getAccessToken()
server logic directly and attaches Authorization
headers.clientFetch
(a wrapper around fetch
) that automatically attempts a refresh if it gets a 401.They’re each trying to solve the same core problem—ensuring valid tokens and making requests to the Django backend—but in slightly different ways. That inevitably creates some redundancy and potential confusion.
You typically want one clear place in your client code that handles:
Below are three patterns in ascending complexity:
/api/refresh
) or a server action to refresh the token, then retry once.getAccessToken()
from a server action in your client code, because that server action can’t simply pass the token out to the client as an httpOnly cookie. Instead, the refresh has to happen in an API route or server action, which sets the new cookie.sameSite
, secure
, etc.).Decide Where You Want the Refresh Logic:
getAccessToken()
in your request function. Instead, you could rely on something like your clientFetch
approach.Pick a Single Approach:
clientFetch
method. For every request from the browser, call clientFetch("/some/endpoint", { method: "GET" })
, and let it handle tokens (including refresh on 401).apiService
that does get/post/put/delete
can internally just call clientFetch
.apiService.get(...)
, you can put the 401 refresh logic in that same code. But then you might not even need clientFetch.ts
if they both do the same job.Do You Need Authorization: Bearer ...
?
Authorization
header manually. The browser will send the cookie automatically.Authorization
header. If so, you need a non-httpOnly storage to read it from the client—unless you do server proxies.)Fetch vs. Axios
Here’s a unified approach using your apiService
style, but adding 401 refresh logic right there (no separate clientFetch
needed).
ts// app/api/refresh/route.ts import { NextResponse } from "next/server"; import { handleRefresh } from "@/lib/actions"; export async function POST() { const newAccess = await handleRefresh(); return NextResponse.json({ ok: !!newAccess }); }
apiService
to do a 401-check and call /api/refresh
:tsimport { cookies } from "next/headers"; // or for the client, you might not have next/headers. // you'd do a fetch to /api/refresh. async function request( method: string, url: string, data: any = null, withToken: boolean = true ): Promise<any> { // Always do credentials: 'include' so that cookies get sent cross-site. // If your Django is at a different origin, you need that. const fetchOptions: RequestInit = { method, headers: { Accept: "application/json", "Content-Type": "application/json", }, body: data ? JSON.stringify(data) : null, credentials: "include", }; let response = await fetch(`${process.env.NEXT_PUBLIC_API_HOST}${url}`, fetchOptions); // If we got a 401, let's attempt to refresh once if (response.status === 401 && withToken) { console.log("Got 401, attempting refresh..."); const refreshResp = await fetch("/api/refresh", { method: "POST", credentials: "include", }); const refreshData = await refreshResp.json(); console.log("Refresh result:", refreshData); // If refresh was successful, let's retry the original request once if (refreshResp.ok && refreshData.ok) { response = await fetch(`${process.env.NEXT_PUBLIC_API_HOST}${url}`, fetchOptions); } } let responseData: any; try { responseData = await response.json(); } catch (jsonError) { const text = await response.text(); throw new Error(`Could not parse JSON. Status: ${response.status}, Body: ${text}`); } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${JSON.stringify(responseData)}`); } return responseData; } const apiService = { get: async (url: string): Promise<any> => { console.log("GET", url); return request("GET", url); }, post: async (url: string, data: any): Promise<any> => { console.log("POST", url, data); return request("POST", url, data, true); }, postWithoutToken: async (url: string, data: any): Promise<any> => { console.log("POST WITHOUT TOKEN", url, data); return request("POST", url, data, false); }, put: async (url: string, data: any): Promise<any> => { console.log("PUT", url, data); return request("PUT", url, data, true); }, patch: async (url: string, data: any): Promise<any> => { console.log("PATCH", url, data); return request("PATCH", url, data, true); }, delete: async (url: string): Promise<any> => { console.log("DELETE", url); return request("DELETE", url, null, true); }, }; export default apiService;
In this approach:
getAccessToken()
in the client. The token is in an httpOnly cookie, so you can’t read it in JS anyway. The browser automatically sends that cookie if you do credentials: "include"
./api/refresh
route, which triggers your server-side handleRefresh()
(and sets a new session_access_token
in an httpOnly cookie). Then we retry exactly once.This single file handles your GET/POST/PUT/PATCH/DELETE logic, attaches cookies automatically, and does the 401 refresh.
apiService
and clientFetch
is a bit redundant if they’re both trying to do 401-based refresh. You can unify them into one approach.fetch
wrapper is perfectly fine. Axios has interceptors that can do the 401 refresh flow more elegantly, but functionally it’s the same. If you or your team are more comfortable with Axios, go for it. Otherwise, your custom fetch
approach is totally valid.Bottom line: Pick a single approach and use it everywhere. That will simplify your code and help avoid confusion about which function is actually doing the refresh logic.