I am trying to set cookies from the backend but the frontend (browser) keeps auto deleting them. This only happens on production not on localhost.
The cookie flow is this:
"/sessions" route sets the cookiesBoth the backend and frontend are hosted on Vercel (monorepo) the only route that calls the cookie function is users/sessions
Here's the necessary code:
I have 2 vercel.json, 1 for the frontend and one for the backend (in case someone needs to know)
vercel.json (frontend):
{
"rewrites":
[
{
"source": "/(.*)",
"destination": "/"
}
]
}
vercel.json (backend):
{
"builds": [
{
"src": "/index.js",
"use": "@vercel/node"
}
],
"routes": [
{
"src": "/(.*)",
"dest": "/index.js",
"methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
}
]
}
index.js (backend - CORS stuff):
const allowedOrigins = [
// Front End
"http://localhost:5173",
prodDomain,
// Back End
"http://localhost:4000",
prodDomainAPI,
];
app.set("trust proxy", 1);
app.use(
cors({
credentials: true,
origin: (origin, callback) => {
if (allowedOrigins.indexOf(origin) !== -1 || !origin) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
},
})
);
app.use(cookieParser());
app.use(json());
app.use(urlencoded({ extended: true }));
cookieUtils.js (backend):
export const setAuthCookies = (res, userData, token) => {
const commonOptions = {
path: "/",
sameSite: "None",
secure: true,
maxAge: 30 * 24 * 60 * 60 * 1000,
};
res.cookie("authUser", JSON.stringify(userData), {
...commonOptions,
httpOnly: false,
});
res.cookie("authToken", token, {
...commonOptions,
httpOnly: true,
});
return res;
};
export const clearAuthCookies = (res) => {
const clearOptions = {
path: "/",
sameSite: "None",
secure: true,
httpOnly: true,
};
res.clearCookie("authToken", clearOptions);
return res;
};
usersControllers.js (backend - /sessions route only):
users.post("/sessions", requireAuth(), async (req, res) => {
const decodedUserData = req.user.decodedUser;
try {
const existingUser = await getUserByID(decodedUserData.id);
if (!existingUser) {
return res.status(404).send("User not found");
}
const userRole = await getUserRole(decodedUserData.id);
const userData = {
id: existingUser.id,
discord_id: existingUser.discord_id,
username: existingUser.username,
avatar_url: existingUser.avatar_url,
banner_color: existingUser.banner_color,
banner_url: existingUser.banner_url,
role: userRole,
created_at: existingUser.created_at,
};
setAuthCookies(res, userData, req.user.token);
res.status(200).json({
success: true,
message: "Session established successfully",
});
} catch (error) {
console.error("users.POST /sessions", { error });
res.status(500).send("Internal Server Error");
}
});
Note: requireAuth only deals with JWT's it doesn't deal with setting up cookies and thus is not necessary to include
AuthCallback.jsx (frontend):
export const AuthCallback = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
useEffect(() => {
const run = async () => {
const reason = searchParams.get("reason");
const token = searchParams.get("token");
if (reason) {
let errorMessage = null;
switch (reason) {
case "rate_limited":
errorMessage =
"Discord is rate limiting login attempts. Please wait a few minutes and try again.";
break;
case "auth_failed":
errorMessage = "Authentication failed. Please try again.";
break;
default:
errorMessage = "An error occurred during authentication.";
}
toast.error(errorMessage, {
containerId: "notify-failure",
});
return setTimeout(() => {
navigate("/");
}, 1000);
}
try {
// No token means backend error
if (!token) {
toast.error("Authentication failed. Please try again.", {
containerId: "notify-failure",
});
return setTimeout(() => {
navigate("/");
}, 1000);
}
await axios
.post(
`${API}/users/sessions`,
{},
{
headers: {
Authorization: `Bearer ${token}`,
},
withCredentials: true,
},
)
.then((res) => {
toast.success("Successfully logged in!", {
containerId: "notify-success",
});
})
.catch((err) => {
toast.error("Failed to load user data. Please try again.", {
containerId: "notify-failure",
});
});
} catch (err) {
console.error("OAuth callback error:", err);
toast.error("Failed to load user data. Please try again.", {
containerId: "notify-failure",
});
} finally {
// Only reload if we have a token (success case)
if (token) {
console.log("token received");
setTimeout(() => window.location.reload(), 1000);
}
}
};
run();
}, [navigate, searchParams]);
return (
<div className="auth-callback">
<h2>Authenticating...</h2>
<p>Please wait</p>
</div>
);
};
Things I've already tried that didn't work:
Adding domain to the commonOptions
const commonOptions = {
path: "/",
sameSite: "None",
secure: true,
maxAge: 30 * 24 * 60 * 60 * 1000,
domain: isProd ? prodDomain : "localhost"
};
Using sameSite Lax, Strict and None, as well as using lowercase for all 3 (lax, strict, none)
console logging the headers which showed (i set this console.log right after setAuthCookies in /sessions):
šµ Response headers after setting cookies
[Object: null prototype] {
'x-powered-by': 'Express',
'access-control-allow-origin':
'<my domain>',
vary: 'Origin',
'access-control-allow-credentials': 'true',
'set-cookie': [
'authUser=<data for the authUser>;
Max-Age=2592000;
Path=/;
Expires=Sun,
04 Jan 2026 20:21:40 GMT;
Secure;
SameSite=None',
'authToken=<data for the authToken>';
Max-Age=2592000;
Path=/;
Expires=Sun,
04 Jan 2026 20:21:40 GMT;
HttpOnly;
Secure;
SameSite=None'
]
}
I have looked up similar questions and people say it's because they didn't set the domain or they weren't using the correct sameSite but their solutions just don't work for me.
I've also noticed that on production right before page refresh in cookies there are 4 cookies, 2 sets of each (authUser, authToken) one of them is my actual domain and the other is my domain with a dot in front of it. I assume that's something the browser is doing automatically since I'm not setting a domain.
On localhost all this works just fine, it sets the cookies and I can login just fine.
On production it does set the cookies but once the page refreshes both cookies the backend set are auto deleted.
I can get the data from "/sessions" and just have the frontend setup the cookies directly with js-cookies but I think that'd be less secure since it can't set httpOnly.
I went with browsermator's answer of routing it to /api and that fixed cookies not being set on production for vercel.
The exact thing i did is inside of vercel.json (frontend) just edit the rewrites to re-route to /api
{
"source": "/api/:path*",
"destination": "<backend domain>/:path*"
},
then in .env.production (also on the frontend) change the value for your URL to /api (i call mine VITE_PUBLIC_API_BASE) so for example:
VITE_PUBLIC_API_BASE="/api"
then in my cookieUtils.js (backend) i switched sameSite to Lax, (including a domain wasn't necessary). This fixed my cookie issue.
Once again I want to thank browsermator for the answer. I'll also thank user30248991 for the detailed explanation.