Search code examples
expressnext.jscookiesjwtfetch-api

Trouble setting cookies in Express backend for Next.js frontend use


I have been struggling with setting and utilizing cookies in a Next.js (v 14.2.3) and Express (v 4.19.2) full stack app I'm working on.

Specifically, I'm attempting to implement a JWT auth flow. Right now, I'm logging in through a server action on the frontend called login shown below:

actions.ts

export async function login(formData: FormData) {
  const response = await fetch(baseUrl + '/auth/login', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ 
      user: formData.get('emailUsername'),
      password: formData.get('password')
    }),
    credentials: 'include'
  })

  const data = await response.json() 
  if (data.error) return console.log(data.error)
  return data

When I console.log(response.headers) in the code above, I do in fact see my cookies, but it is unclear to me how I can access them from here (and potentially set them using cookies().set() in Next).

In the Express backend, the request is sent to the login function in the auth controller shown below:

authController.ts

const login = async (req: Request, res: Response) => {
  try {
    const body = await req.body
    const { user, password } = body
    
    const foundUser = await User.findOne({
      $or: [
        { email: user },
        { username: user }
      ]
    })
    if (!foundUser) return res.send({ error: 'Username or email not found.' })

    const passDoesMatch = await bcrypt.compare(password, foundUser.password)
    if (!passDoesMatch) return res.send({ error: 'Password is incorrect.' })

    const accessToken = generateToken(foundUser, 'access')
    const refreshToken = generateToken(foundUser, 'refresh')
    const cookieOptions: CookieOptions = {
      httpOnly: true, 
      secure: false, 
      sameSite: 'none', 
      maxAge: 1000 * 60
    }

    return res
      .cookie('accessToken', accessToken, cookieOptions)
      .cookie('refreshToken', refreshToken, cookieOptions)
      .status(200)
      .send({ user: { username: foundUser.username, _id: foundUser._id } })
  } catch (error) {
    console.log(error)
  }
}

(For reference, the generateToken function)

export const generateToken = (user: User, type: 'access' | 'refresh') => {
  const { username, _id } = user
  const payload: JwtPayload = {
    username,
    _id
  }

  let options = { expiresIn: 60 * 30 }
  if (type === 'refresh') options.expiresIn = 60 * 60 * 24

  const token = jwt.sign(payload, process.env.JWT_SECRET as string, options)
  return token
}

The issue is that I'm not seeing the cookies set in my browser when I check in dev tools. The network tab in my dev tools shows no Set-Cookie header in the response for either chrome or firefox (haven't tested other browsers yet).

I have tested the login endpoint with Postman, and I do indeed see the Set-Cookie headers in the response along with properly set cookies.

I have been attempting to adjust the options passed into the cookies. This is the current set of options, though I have tried several combinations to no avail:

const cookieOptions: CookieOptions = {
  httpOnly: true, 
  secure: true, 
  sameSite: 'none', 
  maxAge: 1000 * 60
}

I have also attempted to adjust my CORS configuration with no results. Here is the current config:

const corsConfig = {
  origin: 'http://localhost:3000',
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  credentials: true
}

I was expecting that res.cookie() in my login function in authController.ts would set the cookies for the browser and I would be able to send them in future requests to check for authorization. Instead, I have been unable to set cookies at all.

Am I thinking about implementing this flow correctly? I have seen many questions similar to this one, but not exactly the same. After reading through the documentation, I am not much clearer on how to successfully utilize JWT's through cookies in a Next/Express app. Anyone out there see an obvious mistake or a misunderstanding of the technology I'm attempting to utilize?


Solution

  • I'm updating my answer. It is in fact because you are trying to set the cookies from inside a server action. As far as I understand, the response is received by the server, so the client does not receive the 'set-cookies' header. You could either refactor your fetch request to run in the client component, or explicitly set the cookies using the next/headers cookies module. Here is a dirty implementation:

    export async function login(formData: FormData) {
      const response = await fetch(baseUrl + '/auth/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ 
          user: formData.get('emailUsername'),
          password: formData.get('password')
        }),
        credentials: 'include'
      })
    const cookieKey = 'the_name_of_your_cookie';
    // here we get the set-cookie header from the response headers
          const setCookie = response.headers.getSetCookie();
    // there is probably a better way to do this, i'm just splitting the key value pair here
          const authCookie = setCookie
            .find((cookie) => cookie.includes(cookieKey))
            ?.split("=")[1];
          if (authCookie) {
            // set the cookie with the next/headers cookies module
            cookies().set(cookieKey, authCookie);
            // take an action here to redirect your client
          }
    }
    

    That should do it! Don't forget to also secure your other pages after login :)

    -------- previous answer ----

    I don't use express in my backend here, but you can see the working custom getSession function and Session wrapper I built on the next.js side below. I also wonder if this is due to using a server action - does your action.ts files include 'use server' at the top? Maybe try removing that, and then the request will run in the browser, where the window actually exists?

    I have a suspicion that using a server action could be the issue. Here is a working login function in a next.js app I am working on. This function is executed on the client - it's not so different from yours

    
        try {
          let res = await fetch(url, {
            method: "POST",
            mode: "cors",
            headers: {
              "Content-Type": "application/json",
              "Access-Control-Allow-Credentials": "true",
              "Access-Control-Allow-Origin": `http:${url}`,
            },
            credentials: "include",
            body: JSON.stringify({
              email: email,
              password: password,
            }),
          });
          if (res.ok) {
            router.push("/dashboard");
          } else {
            console.log({ res });
          }
        } catch (err) {
          console.log(`Error: ${e}`);
        }
      };
    

    I then also have a custom session wrapper, this is in a server component - you can see the use of the cookies module to retrieve the auth cookie:

    import { cookies } from "next/headers";
    import { redirect } from "next/navigation";
    
    export const getSession = async () => {
      const url = `${BASE_URL}/auth`;
      const request = new Request(url);
      const cookieStore = cookies();
      try {
        const cookie = cookieStore.get("time_bandit_auth_token_v1");
        let res = await fetch(request, {
          method: "GET",
          mode: "cors",
          credentials: "include",
          headers: {
            "Access-Control-Allow-Credentials": "true",
            Cookie: `time_bandit_auth_token_v1=${cookie?.value}`,
          },
        });
        if (res.ok) {
          return res.json();
        } else {
          const error = new Error("UNAUTHORIZED");
          throw error;
        }
      } catch (e) {
        let res = JSON.stringify(e);
        console.error(e);
        return res;
      }
    };
    
    const Session = async ({ children }: { children: React.ReactNode }) => {
      const session = await getSession();
      console.log({ session });
      if (!session) {
        // session is undefined if there is an error
        redirect("/");
      }
    
      return <div>{children}</div>;
    };
    
    export default Session;