I am setting up login and refresh logic for my react app and running into a refresh token issue. I know that the overall refresh logic is sound because on postman it is working just fine. It's within my application where the problem is.
I have my main server.js on PORT
8080 and authServer.js on PORT
8081. My front end application is using VITE and on http://localhost:5173
.
My issue is req.cookies
is always an empty object {}
and it fails with status
401.
As I said, on postman everything works great. But after logging into my application and refreshing the page, /refresh
has the following response
:
bodyUsed: false
headers: Headers {}
ok: false
redirected: false
status: 401
statusText: "Unauthorized"
type: "cors"
url: "http://localhost:8081/refresh
Here is the code:
authServer.js
import express from "express";
import cors from "cors";
import jwt from "jsonwebtoken";
import "dotenv/config";
import bcrypt from "bcrypt";
import cookieParser from "cookie-parser";
import { api } from "./db/queries.js";
const app = express();
const PORT = 8081;
const corsOptions = {
origin: "http://localhost:5173",
credentials: true,
};
app.use(cors(corsOptions));
app.use(express.json());
app.use(cookieParser());
// Define routes after middleware
const generateTokens = (user) => {
// eslint-disable-next-line no-undef
const accessToken = jwt.sign(user, process.env.VITE_ACCESS_TOKEN, {
expiresIn: "15s",
});
// eslint-disable-next-line no-undef
const refreshToken = jwt.sign(user, process.env.VITE_REFRESH_TOKEN, {
expiresIn: "1d",
});
return { accessToken, refreshToken };
};
app.post("/refresh", (req, res) => {
const token = req.cookies.refresh_token;
if (token == null) return res.sendStatus(401);
// eslint-disable-next-line no-undef
jwt.verify(token, process.env.VITE_REFRESH_TOKEN, (err, user) => {
if (err) return res.sendStatus(403);
const { accessToken, refreshToken } = generateTokens({ name: user.name });
res.cookie("refresh_token", refreshToken, {
httpOnly: true,
sameSite: "None",
});
res.json({ accessToken: accessToken });
});
});
AuthContext.jsx
import { useContext, createContext, useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
const AuthContext = createContext();
const AuthProvider = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [status, setStatus] = useState("");
const navigate = useNavigate();
const refreshToken = async () => {
const url = "http://localhost:8081/refresh";
console.log("Attempting to refresh token...");
try {
const response = await fetch(url, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
});
console.log("Received response from /refresh:", response);
if (!response.ok) {
console.error(
`Failed to refresh token. Status code: ${response.status}`
);
setStatus(401);
return;
}
const token = await response.json();
console.log("Received token data:", token);
const { accessToken } = token;
console.log("Access token:", accessToken);
setIsAuthenticated(!!accessToken);
console.log("Authentication status set to:", !!accessToken);
} catch (error) {
console.error("Error occurred during token refresh:", error);
}
};
return (
<AuthContext.Provider
value={{
isAuthenticated,
refreshToken,
status,
setStatus,
}}
>
{children}
</AuthContext.Provider>
);
};
export default AuthProvider;
export const useAuth = () => {
return useContext(AuthContext);
};
ProtectedRoutes.jsx
import { useEffect, useState } from "react";
import { useAuth } from "../context/AuthContext";
import { Navigate } from "react-router-dom";
export const ProtectedRoutes = ({ children }) => {
const { isAuthenticated, refreshToken } = useAuth();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const refresh = async () => {
try {
await refreshToken();
} catch (error) {
console.error("Error during token refresh:", error);
} finally {
setIsLoading(false);
}
};
if (!isAuthenticated) {
refresh();
} else {
setIsLoading(false);
}
}, [isAuthenticated, refreshToken]);
if (isLoading) {
return <div>Loading...</div>;
}
// Once loading is done, check authentication status
return isAuthenticated ? children : <Navigate to="/login" />;
};
EDIT:
The solution was to add credentials: "include"
to /users/login
By default, a cross-origin CORS request is made without credentials. So, no cookies, no client certs, no automatic Authorization header, and Set-Cookie on the response is ignored. However, same-origin requests include credentials.
Since the login request is a cross-origin CORS request and it did NOT have credentials explicitly set, though the server did it part by setting the header Set-Cookie, the client had ignored it. This was the reason for the failure. And the solution as you found, to include credentials.
This post - How to win at CORS, talks about this in detail.