authenticationnext.jscookiesauthorizationnextjs14

NextJS authentication with separate backend (How to make subsequent http-requests to token-protected routes)


I've googled enough. Almost every topic-related guide on the Internet ends after showing example of storing token into cookies and maybe session refresh example. But for me, the most interesting (and hard) part comes after - when I want to make http-requests to token-protected routes.

In the context of making http-requests in my project, the main agents are:

Desired auth flow:

  1. User fills a form with email and password, request is sent from nextjs client to nextjs server via server action;
  2. NextJS server makes http request to remote backend, and, if status ok, sets a http-only "user_token" cookie via { cookies } from 'next/headers' and sends response back to client;
  3. From now on, I want to be able to make http-requests to backend's protected routes (token required) both from NextJS server side and NextJS client side.

So there is my list of questions:

a) I've noticed that for http-requests from NextJS server side using NextJS fetch() function, credentials: "include" does not include my previously set cookie. So I guess before every request I need to get token with cookies().get("user_token")?.value. Is that right or there is a way to make credentials: "include" work?

b) If (in production) NextJS server and Backend server will run on the same domain, would my NextJS client-side requests automatically include "user_token" cookie?

c) While in dev mode, is there anything I can do in order to set "user_token" cookie both for localhost:3000 (nextJS server) and backend domain? Or I should just manually set it every time while I am developing? (Currently in dev mode "user_token" sets for localhost:3000 and therefore cookie is not sent when making request directly from client-side to backend).

Notes:

I will include some code listings, but context of my question can be understood without looking at them.

login server action (simplified code):

export const loginAction = async (state: unknown, formData: unknown): Promise<LoginFormState> => {
  if (!(formData instanceof FormData)) 
    return {
      message: "Invalid payload",
      ok: false
    };

  // Validate form fields
  const validatedFields = LoginFormSchema.safeParse({
    email: formData.get('email'),
    password: formData.get('password'),
  })
 
  // If any form fields are invalid, return early
  if (!validatedFields.success) {
    console.log('validation error');
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    } as LoginFormState
  }

  const loginResponse = await loginPost({
    ...validatedFields.data
  });

  if (!classicNextApiResponseValidator(loginResponse)) {
    const error = await requestErrorFromNextResponse(loginResponse);
    return {
      message: error.description,
      ok: false
    }
  }

  const responseData: T.LoginResponse = await loginResponse.json();
       
  await storeToCookies("user_token", responseData.accessToken);

  if (responseData.emailVerified) {
    await redirectAction("/dashboard", {});
  } else {
    await redirectAction("/emailVerification", {});
  }
 
  return {
    message: "successfully",
    ok: true
  }
};

storeToCookies Function

"use server";

import { cookies } from 'next/headers';
import { CookiesKey } from './types';

export const storeToCookies = async <T = any>(key: CookiesKey, value: T, options: {} = {}) => {
  if (typeof key === "string")
    cookies().set(
      key,
      typeof value === "string" ? value : JSON.stringify(value),
      {
        httpOnly: true,
        secure: false,
        sameSite: 'lax',
        path: '/',
      }
    );
};

My attempt to make request to protected route (profile) from nextJS server side (credentials: "include" does not work, but manually getting cookie and passing it as header works:

export const profileGet = async () => { 
  return fetch(`${CONFIG.BASE_URL}/profile`, { 
    method: "GET",
    credentials: "include",
    headers: DEFAULT_HEADERS,
  });
};

My apisauce instance for client-side requests:

const sauce = create({
  withCredentials: true,
  baseURL: CONFIG.BASE_URL,
  headers: {
    Accept: 'application/json',
  }
});

Solution

  • a) Seems like there is no way to make credentials: "include" work like intended, but cookies().get("user_token")?.value is pretty much ok because on server-side it just looks into cookies of incoming request from client-side and gets the cookie; Plus http-only works fine here, so no restrictions.

    b) Yes, client-side would automatically include a cookie when sending requests both to server-side and remote BE in production if cookie's domain is configured and Next.js server-side and remote BE share same domain (being different subdomains). Here is example of helper function that I use in order to set cookie's domain property both for dev mode and production mode:

    /**
     * @returns {string | undefined} Suitable cookie domain option based on the current environment.
     */
    export const getCookieDomain = (): string | undefined => {
      return process.env.NEXT_PUBLIC_IS_DEPLOYED === 'true' ? CONFIG.DOMAIN : undefined;
    };
    

    CONFIG.DOMAIN there is just "your-site.com". Then if Next.js server runs on "your-site.com" and BE runs on "api.your-site.com", everything would be fine.

    c) I ended up asking BE team to add support for both cookies auth and "Authorization" header auth types, then (in dev only) I use axios interceptor that manually gets "user_token" from cookies and sets it as "Authorization" header. It is possible even with "http-only" cookie if we're reading cookie through server action. Just make sure this trick is only allowed in dev mode:

    /**
     * Retrieves the value of a cookie by its key. Be careful when using with HTTP-only cookies, as wont be returned.
     * 
     * @param {CookiesKey} key - The key of the cookie to retrieve.
     * @returns {Promise<string | undefined>} - A promise that resolves to the value of the cookie, or undefined if the key is not a string, 
     *                                          the cookie is HTTP-only and the environment is not development, or the cookie does not exist.
     */
    export const getFromCookies = async (key: CookiesKey) => {
      if (typeof key !== "string") 
        return undefined;
    
      if (HTTP_ONLY_COOKIES.has(key) && process.env.NEXT_PUBLIC_IS_DEPLOYED === 'true') // If the cookie is HTTP-only, we should not allow access to it from the client side
        return undefined;
    
      return cookies().get(key)?.value;
    };