Next.js Discord

Discord Forum

Update cookies after fetch

Answered
Evanion posted this in #help-forum
Open in Discord
I have a project that consist of two parts a Next.js (14) frontend and a separate backend.
When the user authenticates, the backend returns an object containing both the accessToken and refreshToken
I need to store these in the users cookies, and then include them in each request to the backend in the authorization header. If I could use cookies on the backend, I could have just done credentials: "include". But the backend is serving other platforms as well, and we want a uniform auth flow on the backend.

Since the accessToken is short lived (TTL 1h) I need to repeatedly refresh the tokens. So I have a a server action (getTokens) that calls cookies(), checks that the accessToken is valid for at least 1 min, and if not, it performs a fetch to the backends [POST] /authorization/refresh-tokens endpoint.
Once the new tokens are returned, I need to update the stored cookies, and then return the new accessToken.
But when I try to write to the store, I get an error saying Cookies can only be modified in a Server Action or Route Handler.

I'm still in the same getTokens function, I'm even using the same cookieStore instance that I was using in the beginning of the function, I have also tried just using a new cookies() call for setting each new token.

I then use the getToken action in my other server actions that requires authentication to get the token and include it in the fetch request.

I'm using both 'use server'; and server-only So if the function wasn't run server side, I would expect other errors.

get-token.ts:
'use server';
import { cookies } from 'next/headers';

import 'server-only';

import { Tokens } from '../types';
import { TokenPayload } from '../types/token-payloads';

const SECOND_IN_MS = 1000;
const MINUTE_IN_MS = 60 * SECOND;

/**
 * Returns the access token to use for API requests against the backend
 * If the token is expired, it will be refreshed
 * If it's unable to refresh the token, it will throw an error
 * @returns accessToken
 */
export async function getToken(): Promise<string | undefined> {
  const cookieStore = cookies();
  const token = cookieStore.get('token');
  const refreshToken = cookieStore.get('refreshToken');

  if (token?.value) {
    // check if token is expiring
    const {exp}: TokenPayload = JSON.parse(atob(token.value.split('.')[1]));
    const now = Date.now();
    // if token have more than 1 minute left, return it
    if (now - exp > MINUTE_IN_MS) return token.value;
  }

  // if no refresh token, we can't refresh the token
  if (!refreshToken?.value) throw new Error('NO_TOKEN');

  const result = await fetch(
    `${process.env.API_URL}/authentication/refresh-tokens`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ refreshToken: refreshToken.value }),
    }
  );

  if (!result.ok) throw new Error('NO_TOKEN');
  const tokens: Tokens = await result.json();
  // if unable to refresh token, return undefined
  if (!tokens) throw new Error('NO_TOKEN');

  // set the new tokens
  // Have also tried `cookies().set`
  cookieStore.set('token', tokens.accessToken, {
    httpOnly: true,
    maxAge: parseInt(process.env.JWT_ACCESS_TOKEN_TTL!, 10),
  });

  cookieStore.set('refreshToken', tokens.refreshToken, {
    httpOnly: true,
    maxAge: parseInt(process.env.JWT_REFRESH_TOKEN_TTL!, 10),
  });

  cookieStore.set('user', JSON.stringify(tokens.user), {
    httpOnly: false,
  });

  return tokens.accessToken;
}
Answered by Evanion
ok, well then I have a path forward, writing a middleware that checks and refreshes the token before next starts rendering the page 👍 Thanks
View full answer

33 Replies

@Evanion I have a project that consist of two parts a Next.js (14) frontend and a separate backend. When the user authenticates, the backend returns an object containing both the `accessToken` and `refreshToken` I need to store these in the users cookies, and then include them in each request to the backend in the `authorization` header. If I could use cookies on the backend, I could have just done `credentials: "include"`. But the backend is serving other platforms as well, and we want a uniform auth flow on the backend. Since the `accessToken` is short lived (TTL 1h) I need to repeatedly refresh the tokens. So I have a a server action (`getTokens`) that calls `cookies()`, checks that the `accessToken` is valid for at least 1 min, and if not, it performs a `fetch` to the backends `[POST] /authorization/refresh-tokens` endpoint. Once the new tokens are returned, I need to update the stored cookies, and then return the new `accessToken`. But when I try to write to the store, I get an error saying `Cookies can only be modified in a Server Action or Route Handler.` I'm still in the same `getTokens` function, I'm even using the same `cookieStore` instance that I was using in the beginning of the function, I have also tried just using a new `cookies()` call for setting each new token. I then use the `getToken` action in my other server actions that requires authentication to get the token and include it in the fetch request. I'm using both `'use server';` and `server-only` So if the function wasn't run server side, I would expect other errors. get-token.ts: ts 'use server'; import { cookies } from 'next/headers'; import 'server-only'; import { Tokens } from '../types'; import { TokenPayload } from '../types/token-payloads'; const SECOND_IN_MS = 1000; const MINUTE_IN_MS = 60 * SECOND; /** * Returns the access token to use for API requests against the backend * If the token is expired, it will be refreshed * If it's unable to refresh the token, it will throw an error * @returns accessToken */ export async function getToken(): Promise<string | undefined> { const cookieStore = cookies(); const token = cookieStore.get('token'); const refreshToken = cookieStore.get('refreshToken'); if (token?.value) { // check if token is expiring const {exp}: TokenPayload = JSON.parse(atob(token.value.split('.')[1])); const now = Date.now(); // if token have more than 1 minute left, return it if (now - exp > MINUTE_IN_MS) return token.value; } // if no refresh token, we can't refresh the token if (!refreshToken?.value) throw new Error('NO_TOKEN'); const result = await fetch( `${process.env.API_URL}/authentication/refresh-tokens`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ refreshToken: refreshToken.value }), } ); if (!result.ok) throw new Error('NO_TOKEN'); const tokens: Tokens = await result.json(); // if unable to refresh token, return undefined if (!tokens) throw new Error('NO_TOKEN'); // set the new tokens // Have also tried `cookies().set` cookieStore.set('token', tokens.accessToken, { httpOnly: true, maxAge: parseInt(process.env.JWT_ACCESS_TOKEN_TTL!, 10), }); cookieStore.set('refreshToken', tokens.refreshToken, { httpOnly: true, maxAge: parseInt(process.env.JWT_REFRESH_TOKEN_TTL!, 10), }); cookieStore.set('user', JSON.stringify(tokens.user), { httpOnly: false, }); return tokens.accessToken; }
Where did you call getToken()
I have tried it in a number of places, but for instance in another server action like getCurrentUser
'use server';
import { plainToInstance } from 'class-transformer';
import { User } from '@codesmith/entities';
import 'server-only';
import { getToken } from './get-token';

export async function getCurrentUser() {
  const token = await getToken();
  if (!token) return;
  const user = await fetch(`${process.env.API_URL}/users/me`, {
    headers: {
      Authorization: `Bearer ${token}`,
    },
  }).then((res) => res.json());

  return plainToInstance(User, user);
}

that then get's called in /app/profile/page.tsx:
import { getCurrentUser } from '@codesmith/actions/server';

export default async function ProfilePage() {
  const user = await getCurrentUser();
  console.log('user', user);

  return (
    <div>
      <div className="wrapper">
        <pre>{JSON.stringify(user, null, 2)}</pre>
      </div>
    </div>
  );
}
sure, but you're suppose to be able to do that
Not in server component
because by the time it reaches there, page.js already starts returning Response to the client as part of the Suspense feature
and you can't change the SetCookie if its already sending the response :/
"use server" file that is called inside a server component before the return part is not a Server Action, its just a regular function. Thats why the error is confusing
ok, so how can I solve the basic issue of including a token when fetching the current user, and if that token needs updating before doing the request, perform the update before that?

Would I need to do the update in a middleware, and then just read the token when performing the request?
export default async function ProfilePage(){
  const token = await getToken() // not a server action
  return (
    <form action={getToken}> // server action
      <button>submit</button>
    </form>
  ) 
}
Would I need to do the update in a middleware, and then just read the token when performing the request?

Yes, its one of the solution right now
or update the cookie before redirecting to a page
well since the user can land directly on the page, the only place that I know to do that would be in a middleware
yep
cookies are stil sent to the Next.js server for every fetch/get request so that shouldnt be a problem
ok, well then I have a path forward, writing a middleware that checks and refreshes the token before next starts rendering the page 👍 Thanks
Answer
Yes im learning how to do that too :))
yea the docs around async actions/functions/use server, and cookies are a bit slim.. they are basically the same thing, but not really.. and you can basically use the same code in both
yep, make sure you still protect "server action" with auth though
yea in this case I do that in the parent layout.tsx
my way of understanding it is
"use server" is a client-to-server boundaries.
They create instant endpoint for every function that is created in that file
that is not what im talking about
oh, yea I get waht you mean
well actions that you put on a form are basically transformed in to post routes, so you need to do the check in them
instant endpoint means that that function is open to the public, like a regular route handler.
so even if you only use it in page.js, doesnt mean that its function is private
im saying that you dont know what the bundler does to that function. Worst case scenario it opens a public route
treat it like a regular POST route
yea it basically just mounts it as a route handler on an API route
but i see you already do that here so ignore me haha
so just like any route handler, you need to check the auth in the function.
in my case, the cookies are basically JWT tokens, so I also have a auth() function that validates the accessToken and checks the exp claim on the token (token expiration), and if no accessToken is included, or it's expired (the cookie also has a TTL) the user isn't authenticated.
The backend also performs it's own validation of the token
Well middleware was a Bust .. it's only available in edge runtimes, and the default in next is nodejs runtime.