javascriptreactjsexpresscookies

React: POST /refresh not setting refresh_token cookie


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


Solution

  • 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.