javascriptreactjsexpresscookiesvercel

How to set cookies from the backend on production?


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:

  1. user clicks login with discord
  2. redirected to discord's oauth page
  3. discord sends the data to my backend api
  4. user account is created/updated on my database
  5. redirects back to my authCallback page
  6. sends another request to users/sessions to store the user's data in cookies
  7. "/sessions" route sets the cookies

Both 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:

šŸ”µ 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.


Solution

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