Loading...
In the world of web development, personalizing the user experience or managing session data is one of the most crucial aspects of modern web applications. Due to the "stateless" nature of the HTTP protocol, communication between the server and the client doesn't maintain any context (state), each request is independent and doesn't retain any connection between the server and client. This makes it challenging to manage session information, user preferences, and similar data. To address this issue, cookies were first introduced by Netscape in 1994, and since then, they have become a fundamental part of the internet, used in many web applications to store small pieces of data such as user information, preferences, or session details.
Cookies can contain data that must be managed with extreme caution due to security and privacy concerns. These small pieces of data, which must be securely managed on the server side, are crucial for protecting user information and preventing potential security vulnerabilities. Next.js, with its serverless API Routes, Middleware, and Node.js development environment, provides a robust way to securely manage cookies and HTTP requests.
In this article, I'll explore how to manage cookies against security threats by leveraging Next.js's built-in features, such as API routing and server actions, and I'll explain why Next.js is a powerful framework within the React ecosystem that simplifies the development process. Happy reading!
The Core Ingredients of Cookies
Cookies play a critical role in the exchange of data between a web server and a client (usually a web browser). Stored on the client side (in the browser), cookies are transmitted to the server using the HTTP protocol. This process is particularly important for managing user sessions and providing personalized content based on user preferences. A cookie consists of the following attributes:
Attribute | Description | |
---|---|---|
Name | : | The identifier of the cookie (required). |
Value | : | The data stored by the cookie (required). |
Domain | : | The domain to which the cookie belongs. |
Path | : | The URL path where the cookie is valid. |
Expiration Date | : | The date and time when the cookie will expire; if not specified, the cookie is valid for the duration of the browser session. |
Max Age | : | The maximum time (in seconds) the cookie will be stored; if Expiration Date is not set, defaults to the session duration. |
Security Attributes | : | Features like Secure and HttpOnly that ensure the cookie is sent only over secure connections. |
The process of creating a cookie and storing it in the user's browser typically occurs as a result of an HTTP request to the server, followed by a response from the server. However, JavaScript also allows for the creation and management of cookies on the client side, without the need to connect to the server. In HTTP requests between the server and the client, cookies are pieces of information transmitted in the header and stored in the browser. These cookies are then sent to the server with every HTTP request made by the client to the specified path.
The limits for cookies were first defined in 1997 by the IETF with the publication of the 'HTTP State Management Mechanism' and these limitations are still in effect today. As stated in the latest version,
- The total size of a cookie (including its name, value, and attributes) can be up to 4096 bytes.
- A maximum of 50 cookies can be stored per domain.
- The total number of cookies that can be stored across all domains (in the browser) is limited to 3000.
Exceeding these limits can lead to network bandwidth and performance issues, and such cookies will not be accepted by browsers.
Baking w/ Caution: Security and Privacy
In modern applications, it is quite common to store user preferences, session identifiers, or information such as location, name, surname, and email address in cookies. However, storing such personal and sensitive data as cookies in the header without using the Secure flag (which ensures transmission only over HTTPS) can make this data easily accessible on the network or vulnerable to Man-in-the-Middle (MITM) attacks. If the HttpOnly flag is not used, cookies can be accessed by JS, which can lead to security vulnerabilities like XSS (Cross-Site Scripting), where unwanted or malicious JavaScript code is injected. In the case of SameSite set to "None" it opens up the risk of CSRF (Cross-Site Request Forgery), allowing cookies to be sent and manipulated in requests initiated by another site without authorization. In short, these vulnerabilities highlight the importance of using these flags correctly.
HTTP-only
Ensures that the cookie is accessible only through HTTP requests and prevents it from being accessed, read, or modified by client-side scripts like JavaScript. This provides a security layer against XSS attacks.
- The HttpOnly flag is not enabled by default; therefore, you need to set it explicitly.
Secure
Ensures that the cookie is only sent over HTTPS connections. This prevents the cookie from being transmitted over an insecure connection, protecting against MITM attacks.
- The Secure flag is not enabled by default. You need to set this flag manually. Another important point is that if the domain does not provide an HTTPS connection using an SSL/TSL certificate, enabling this flag will prevent the cookie from reaching the server.
SameSite
Controls whether the cookie is sent only with requests originating from the same site. This flag is crucial for preventing security vulnerabilities such as XSRF (Cross-Site Request Forgery). There are three different modes:
- Strict: The cookie is only sent with requests within the same site. It is never sent with requests from third-party sites. This is preferred in scenarios where sensitive data, such as user sessions, need to be protected.
- Lax: The cookie can be sent with secure navigation requests from third-party sites (e.g., clicking on a link) but is restricted in other cases. It is a safe and flexible option for most situations and is suitable for common scenarios like session management.
- None: The cookie is sent with requests from outside the site. However, when using this mode, the Secure flag must be enabled. It is suitable for applications that integrate with third-party content or service providers, but using None carries security risks.
- The default setting is SameSite=Lax. This allows cookies to be managed in a partially secure way while enabling them to be used across different pages without negatively impacting the user experience.
Domain
Specifies the domains under which the cookie will be valid. If the domain is not specified, the cookie will only be valid for the domain where it was set (e.g., sample.com), and subdomains (e.g., sub.sample.com) will not have access to this cookie. However, if the domain is specified with a leading dot (e.g., .sample.com), the cookie will be valid for the main domain and all subdomains.
- If the domain is left blank, cookies will by default only be valid for the domain where they were created. To allow subdomains to access the cookie, the domain flag must be set manually.
Path
Specifies the URL paths where the cookie will be valid. If not specified, the cookie will only be valid on the path where it was created. The '/' value is used for all paths, while specific paths should be indicated for individual pages or API endpoints. For example, a cookie sent on the first visit will, by default, cover all paths with '/', or a cookie set in response to a request to an API endpoint will only be included in the headers for requests made to that API endpoint.
- The default Path is '/', meaning the cookie is valid for all paths.
Client-Baked Cookies
Client-side cookie management can be done solely with the document object, which is a part of the DOM. Although there are various libraries available for cookie management, these libraries essentially rely on the document.cookie feature; thus, they do not offer a different approach or additional security. For instance, you can see this by examining the source code of libraries like Cookies-Next or React-Cookie.
In JS, storing a cookie with the document object is done by assigning the components such as name and value as a single string to the document.cookie property.
Javascript
const theme = “dark”;
document.cookie = `theme=${theme}; path=/; expires=Fri, 1 Dec 2024 23:59:59 GMT; Secure; SameSite=Strict;`;
If turn this into a function;
setCookie()
function setCookie(name, value, days, path = '/', secure = false, sameSite = 'Lax') {
let expires = "";
if (days) {
const date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
expires = "; expires=" + date.toUTCString();
}
let cookie = `${name}=${value}${expires}; path=${path}; SameSite=${sameSite}`;
if (secure) {
cookie += "; Secure";
}
document.cookie = cookie;
}
When the cookie property of the document object is called, it returns all the cookies. To extract a specific cookie, you need to use string class methods like find, split, or match:
getCookie()
function getCookie(name) {
const match = document.cookie
.split("; ") // Splits all cookies into individual elements.
.find((row) => row.startsWith(`${name}=`))
?.split("=")[1];
// The callback function in find locates the row that starts with the cookie name,
// splits it, and retrieves the value.
if (match) {
return match; // If the cookie is found, it returns the value.
}
return null; // If the cookie is not found, it returns null.
}
console.log(getCookie('theme')); //dark
It is also possible to check with strict value matching before using cookies. However, since this check is performed on the client side, the code can be easily manipulated in the browser, making it insufficient from a security standpoint.
In conclusion, we have seen how these functions (hook version) access cookies via the document object and how libraries that claim to manage cookies "on both the server and the client" actually operate. While these libraries may simplify usage, client-side cookie management occurs in code blocks that are exposed to the client, which introduces significant security risks. Due to the nature of cookies, their automatic inclusion in the header of requests sent to the server can lead to the transmission of harmful values or code to the server.
Especially for cookies containing critical information, such as session data or personal information, they should be managed on the server-side and set as HTTP-only. This prevents cookies from being accessed by JavaScript, thereby minimizing security risks. Setting cookies only on the server-side also provides protection against attacks like XSS and CSRF, provided they are configured and managed correctly, which we will discuss in subsequent sections.
HTTP-only with Fetch POST?
The Fetch API manages HTTP requests in a much safer and more convenient way compared to XMLHTTPRequest. However, on the client side, when using a fetch POST request, the data intended to be stored as a cookie can only be added to the request body and sent to a specific API endpoint. The Set-Cookie directive cannot be added to headers or issued to the browser via JS on the client side; browsers prevent JavaScript from accessing the Set-Cookie header. In other words, this directive cannot be issued to the browser via the HTTP network. For more information on this topic, you can refer to the "Forbidden Response Header Name" section in the Fetch Standard document published by WHATWG.
But can't we cook with React's 'use server'?
In React, the 'use server' directive is used to mark RSC (React Server Components) and ensures that these components run only on the server side. However, HTTP requests cannot be managed or secure cookie configurations made with the 'use server' directive. For such operations, a backend HTTP framework like Node.js-Express or frameworks that combine both Node.js and React, such as Next.js or Remix, are required.
Additionally, React is just a front-end library designed for building user interfaces or managing client-side operations. The ability to make post requests with fetch or the 'use server' directive for performing server-side operations does not make it a full-stack development environment or a complete HTTP framework.
What About Local Storage?
Local Storage is not automatically included in requests made to the server; you need to manually retrieve the information from local storage and send it to the server via a POST request. Additionally, personal preferences or data cannot be communicated to the server before the page content reaches the client, as there might be no JS bundle or HTML structure available on the client side yet. Therefore, storing data in local storage that needs to be used for preparing pages with SSR or for query operations based on user preferences will not be sufficient. Local storage might be more useful than cookies for client-side use only or for temporary data. Additionally, Local Storage offers no security layers—data is stored directly in the browser.
Feature | Cookie | Local Storage |
---|---|---|
Automatic Transmission | Automatically included with every HTTP request. | Must be manually retrieved and sent. |
Data Storage and Size | Stores up to 4 KB of data. | Can store 5-10 MB of data. |
Scope | Can be set for a specific domain and path, can expire. | Accessible by all pages within the same origin, persistent. |
Use Case | Suitable for small data, user authentication, session management. | Suitable for large data, client-side functions. |
Next.js Cookie Recipe
After discussing how client-side cookie management with standalone JavaScript or React can introduce certain security risks and is insufficient for creating secure cookies or managing sessions, let's explore how Next.js, with its Node.js-based infrastructure, offers solutions and operates in the background:
- Server-Side Rendering (SSR): Next.js uses a Node.js-based server to handle each request. This allows you to securely perform operations that require security, such as managing Http-only cookies, on the server side.
- API Routes: Next.js allows you to define API routes that run Node.js code on the server side, effectively functioning as a backend. This provides a secure and authorized server environment.
- Environment Variables and Security: Leveraging the power of Node.js, Next.js offers the ability to access secure environment variables, manage HTTP headers, and perform other security operations on the server side.
In summary, Next.js, by utilizing Node.js under the hood, enables you to use powerful Node.js libraries like Express or jsonwebtoken, create API endpoints, and securely and easily manage cookies or HTTP requests with built-in functions like Next/Headers and NextResponse.
Prepping the Kitchen: Setting Up Next.js
System requirements:
- Node.js 18 or higher.
Terminal
npx create-next-app@latest
In this process, we will be using the App Router introduced in Next.js 13, which offers much more secure and user-friendly features.
The preferred options during the setup are as follows. Other than the App Router, different options will not affect the examples and methods discussed later in this article.
Terminal
What is your project named? cookies-samples
Would you like to use TypeScript? - Yes
Would you like to use ESLint? - Yes
Would you like to use Tailwind CSS? - Yes
Would you like your code inside a `src/` directory? - Yes
Would you like to use App Router? (recommended) - Yes
Would you like to use Turbopack for `next dev`? - No
next/headers Cookies()
The "next/headers" module is one of the innovative features introduced with Next.js 13, alongside modern web development tools like the App Router and RSC. This module is specifically designed to facilitate easy access to and management of HTTP request headers in server-side operations.
The cookies() API within this module provides built-in methods such as get, getAll, has, set, and delete, making it much easier to manage cookies in the headers of HTTP requests. However, like other next/headers functions, this API only works server-side and cannot be used directly on the client-side. To store data from the client as a cookie using the cookies() API, a server-side process is required; this is typically done through a POST request to a server action or an API endpoint.
Tips & Tricks
- The cookies() API cannot be used in the Pages router; it is a feature specifically designed for managing cookies directly within the App rooter.
- For Pages router, similar features and methods can be accessed through NextRequest and NextResponse.
cookies() Features and Methods:
- get(name: string): Retrieves the cookie with the specified name. Returns undefined if the cookie does not exist.
- getAll(name?: string): Retrieves all cookies as an array with the specified name. If no name is provided, it returns all cookies.
- has(name: string): Checks if a cookie with the specified name exists. Returns true if it exists, false if it doesn't.
- set(name: string, value: string, options?: CookieOptions): Sets a cookie with the specified name and value. Additional options like httpOnly, secure, maxAge, path, etc., can be specified.
- delete(name: string | string[]): Deletes the cookie(s) with the specified name(s). Multiple cookie names can be provided as an array.
- clear(): Clears all cookies.
- size: Returns the number of incoming cookies. Shows how many cookies were sent by the client.
Example of setting a cookie using the Next.js Headers/cookie function as follows:
cookies().set()
import { cookies } from "next/headers";
cookies().set("cookieName", value, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 30,
secure: true,
});
The flags added to the cookies, such as the mandatory “cookieName” (key) and “value” string values, are appended as an object. In the example, a month is specified for maxAge, the secure and httpOnly properties are set to true, and since path and domain are not specified, the cookie is configured to be valid across all paths and only within the domain where it was used.
For the get method, the usage is as follows:
cookies().get()
import { cookies } from "next/headers";
const cookie = cookies().get("cookieName");
//or
const allCookies = cookies().getAll();
The basic usage of the cookies API, as shown above, is quite straightforward. For example, you can delete a cookie by name using the delete method, and check if a cookie exists using the has method. In the following sections, I will demonstrate how to use this structure with Server Actions and Middleware through a simple example, while also highlighting potential security risks.
Server Actions
Server Actions in Next.js eliminate the need to manually create API endpoints, enabling more efficient server-side operations such as data mutation, data fetching, and cookie management. In other words, instead of manually setting up an API endpoint, sending a POST request with various parameters in the request body, and then fetching to trigger a function, you can directly use a Server Action that allows the function to be invoked on the client side.
By leveraging React’s 'use server' directive, Server Actions enforce that certain functions only run on the server. This feature provides Next.js with a Node.js environment for executing server-side operations. For each Server Action, Next.js automatically generates an API endpoint. When the function is called from the client side, it triggers an HTTP POST request. The parameters passed to the function are included in the body of this POST request, and they are sent to the server-side function (Server Action) for processing.
Tips & Tricks
- Server Actions in Next.js act as public endpoints, somewhat similar to API routes. Under the hood, Next.js handles these actions by creating internal API endpoints.
- A Server Action can be defined within a closure. When this function is used in a server-side rendered (SSR) page, it can be transformed into a Server Action using the 'use server' directive. However, the key detail is that the variables captured within the closure are not sent to the client in the bundle. Instead, these variables are kept server-side and are only sent to the client when the action is invoked. To ensure the security of these variables when they are sent to the client and then back to the server, Next.js automatically encrypts them.
- Even with encryption, closures are not secure enough for handling sensitive data or session management.
- There is no mandatory file location or naming convention for Next.js Server Actions. When the 'use server' directive is applied at the top of a file, it designates the entire file as a Server Action. However, when not used within a closure, the function is treated as server-side code and is not sent to the client, meaning no encryption is necessary.
- The 'use server' directive requires the export of an async function that returns a promise. It is important to note that the directive will throw an error if you attempt to export anything other than functions or TypeScript types/interfaces.
⚠️ Caution!
Functions or structures that run on the server side in Next.js (SSR, Route Handlers, Server Actions, etc.), as well as RSC, cannot use hooks (useState, useEffect, etc.). Hooks can only be used within React components that run on the client side. On the server side, methods like async/await, lifecycle management, and similar techniques should be used instead.
Server Actions & Cookie
Let's use a simple example to demonstrate Server Actions and cookie configuration by storing the user's language preference as a cookie and capturing this cookie with middleware to ensure the preference is available with every request.
First, let's create a menu component on the client side to get the user's language preference:
./src/components/LangMenu.tsx
"use client";
import { useState } from "react";
export default function LangMenu() {
const [selectedLanguage, setSelectedLanguage] = useState("");
const handleLang = (event: React.ChangeEvent<HTMLSelectElement>) => {
const language = event.target.value;
setSelectedLanguage(language);
};
return (
<select
className="bg-transparent"
id="language"
value={selectedLanguage}
onChange={handleLang}
>
<option value="en">English</option>
<option value="tr">Türkçe</option>
<option value="de">Deutsch</option>
</select>
);
}
A Server Action can be created as shown below to store the user's language selection as a cookie:
./src/app/actions/languageAction.ts
"use server";
import { cookies } from "next/headers";
const languages = ["en", "de", "tr"] as const;
export type Languages = (typeof languages)[number];
export async function setLanguage(
language: Languages
): Promise<{ success: boolean }> {
try {
if (languages.includes(language)) {
cookies().set("userLanguage", language, {
httpOnly: true,
path: "/",
maxAge: 60 * 60 * 24 * 30,
secure: process.env.NODE_ENV === "production",
});
return { success: true };
} else return { success: false };
} catch (error) {
console.log("Set language error:", error);
return { success: false };
}
}
By defining the languages array as as const, we ensure that only the specified languages are accepted and type safety is maintained. I'll explain why we should check to data as like '.includes' in the 'Server Action and Security' section. After the checks, the selected language preference is stored by creating a cookie named userLanguage.
Finally, we can use this action that sets the cookie in the CSR LangMenu component as shown below:
./src/components/LangMenu.tsx
"use client";
import { setLanguage, Languages } from "@/app/actions/languageAction";
import { useState } from "react";
export default function LangMenu() {
const [selectedLanguage, setSelectedLanguage] = useState<Languages>("en");
const handleLang = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const language = event.target.value as Languages;
const res = await setLanguage(language);
//We used the server action as a regular promise-returning function with await
if (res.success) {
/**
* If the selected language input is processed by the server
* correctly, you can handle i18n language changes or
* [lang] routing operations after this check.
*/
setSelectedLanguage(language);
}
};
return (
<select
className="bg-transparent"
id="language"
value={selectedLanguage}
onChange={handleLang}
>
<option value="en">English</option>
<option value="tr">Türkçe</option>
<option value="de">Deutsch</option>
</select>
);
}
When we place the LangMenu component on the homepage (.src/app/page.tsx), we should be able to store the language preference as a cookie. Let's try it out:
Upon selecting a language, a POST request is sent (as seen in the terminal), and the server action runs to set the cookie in the browser. Additionally, if the language preference is changed again, the existing cookie is updated.
The getCookie function with Server Action would be as follows:
./src/app/actions/languageAction.ts
...
export async function getLanguage(): Promise<Languages | null> {
try {
const cookie = cookies().get("userLanguage");
if (languages.includes(cookie?.value as Languages)) {
return cookie!.value as Languages;
} else return null;
} catch (error) {
console.log("Language cookie error", error);
return null;
}
}
We return the data from the cookie only after verifying that it is one of the 'languages' values. If there's an error or the value is not in languages, we return null.
Middleware
Middleware, is a function that runs before every HTTP request is processed. It is used to perform tasks such as routing, authentication, and permission checks when a request is made. Middleware is ideal for checking the user, redirecting based on certain conditions or user preferences, or performing specific tasks (e.g., cookie or JWT validation) before the pages are loaded. This optimizes the security of the application and enhances the user experience. Additionally, it ensures seamless coordination between SSR and CSR.
Tips & Tricks
- The middleware file should be at the same level as the app folder.
- Next.js does not support multiple (nested) middleware files. You cannot define separate middleware for a specific path, but you can perform different operations for each page based on the NextRequest URL.
- Middleware configuration is done using config. The matcher configuration (regex, string, or array) defines which routes the middleware will apply to.
Continuing with the language preference cookie example, we will use Middleware to track the language preference stored as a cookie on the client (during each new visit) on the server side. To extract the cookie from the Headers of the request, we can simply use the getCookies() function (or you can directly use next/headers cookies API here);
./src/middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { getLanguage } from "./app/actions/languageAction";
export async function middleware(req: NextRequest) {
const url = req.nextUrl;
/**
* The getLanguage() function, which we defined with the 'use server' directive,
* and the Cookies() functions can be directly used in middleware outside of the server action function.
*/
const lang = await getLanguage();
/**
* If there is no language prefix in the URL
* and the getLanguage function retrieves a language from the cookie,
* the middleware will add the language prefix to the URL.
* It is better to first check if there is already a language prefix in the URL.
*/
if (lang && !url.pathname.startsWith(`/${lang}`)) {
url.pathname = `/${lang}${url.pathname}`;
return NextResponse.redirect(url);
}
/**
* If there is no cookie, set the default language preference
* or detect the language based on Accept-Language.
*/
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
The middleware function above intercepts each incoming request before it reaches the server and performs specific actions. It checks if there is a cookie in the request coming from the client and retrieves the language preferences from this cookie.
- The NextRequest object represents the incoming request. Middleware intercepts this request before it is passed to the server.
- The getLanguage() function, which we created as a server action, is used on the server side to obtain the language preference. If there is a language cookie, this function returns the language value from the cookie.
- If there is no language prefix in the URL and a language is detected from the cookie, the middleware adds this language prefix to the URL and redirects the user to the new URL using NextResponse.redirect.
- If there is no cookie, the middleware can set the default language preference or detect the language based on the Accept-Language header.
- With the matcher configuration, the middleware will be active on all routes (.*) except for next/static, next/image, and favicon.ico, performing language preference checks and redirection.
Result:
Since we are not performing any [lang] redirection/routing, it is perfectly normal for the page to need a refresh or return a 404, and the middleware capturing the cookie smoothly as like a clockwork.
This allows the language information to be accessible through params in Next.js' Page functions on both the server and client sides. This setup provides a consistent experience for language-based page routing and content delivery. Additionally, it helps prevent mismatch errors between the Server and Client in the content hydrated by React on the server. To effectively use features like SSR and ISR in React and Next.js, it's necessary to capture and utilize simple cookies, such as language preferences, on the server side. In this context, making even simple cookies like language preferences secure and HTTP-only becomes even more important.
Server Action & Security
Why did we use languages.includes() to check within the Server Action example, and why do we return success: false if it's not a valid language? I can already hear the objections, like "Wasn't server action supposed to be more secure?"
./src/app/actions/languageAction.ts
...
export async function setLanguage(
language: Languages
): Promise<{ success: boolean }> {
try {
if (languages.includes(language)) {
cookies().set("userLanguage", language, {
httpOnly: true,
path: "/",
maxAge: 60 * 60 * 24 * 30,
secure: process.env.NODE_ENV === "production",
});
return { success: true };
} else return { success: false };
} catch (error) {
console.log("Set language error:", error);
return { success: false };
}
}
...
Before diving into the languages.includes() check, let me explain why we set the secure flag conditionally based on the "production" environment. Instead of setting the secure flag directly to true, we use the process.env.NODE_ENV === "production" check to ensure that the secure flag is not enabled when your application is running in a local environment or in preview mode. The secure flag ensures that the cookie is only sent over HTTPS connections. This check activates the flag only in production, preventing issues when working in a development or preview environment.
Of course, there are services like NGROK and LocalTunnel that provide HTTPS connections for local environments, but in this case, since a third-party connection isn't necessary, there's no need for such an option.
process.env.NODE_ENV is an environment variable automatically set by Next.js.
The languages.includes() check:
The server action helped us configure an Http-only cookie securely by working behind the scenes with just an HTTP POST request. However, even though it’s not set up as an API endpoint accessible by everyone or using other HTTP methods, the parameters are still coming from the client. This means they can be tampered with and might contain malicious data. As the saying goes, "Never trust the client, not even if it's your own!" Zero Trust!
Good article about server action security.
The expected ‘Languages’ type is not sufficient here. If we hadn’t used the includes check:
After changing the value of the option element on the client side using the browser devtools, the modified value was accepted, used, and set as a cookie because it wasn’t validated within the setLanguage() function. TypeScript or enums do not provide runtime validation; they assist in development and error catching.
Imagine what could happen if this data were directly used in a query or mutation. Another point to consider is that we hardcoded the cookie name in the setLanguage() function, but if this were made configurable by the client for more global use and convenience, an attacker could potentially access and manipulate every cookies (e.g., one used for session management) through this function.
Additionally, although Server Actions are only accessible via HTTP POST and accept requests only from the same origin using the SameSite attribute, they do not use CSRF tokens, which could make them vulnerable to potential CSRF attacks. As mentioned in the article above (which I suggest), it’s essential to validate or sanitize the data and take security measures like using CSRF or session tokens and validation, especially before fetch or mutation operations.
Legal Matters
After discussing the structure and implementation of cookies, it’s essential to touch upon the legal and ethical responsibilities. While browsing the internet freely (or so we think), it’s quite frustrating to encounter numerous trackers, ads, and spam without any notice or consent. Without further digression, it’s worth mentioning some legal regulations and responsibilities. In Turkey, there’s the KVKK, in the European Union, there’s the GDPR and ePrivacy Directive, and in the U.S., various state-level laws like CCPA or federal laws like ADPPA. In short, nearly every country globally has legal regulations concerning this matter.
These regulations primarily emphasize the clear disclosure of the types of cookies used, their purposes, and the data collected by tracking or performance-related analytics services. They also stress that no personal information should be collected, shared with third parties, or processed without the user's consent. Although it is legally required to inform users about the collection and use of their personal data, the practice of tracking people's preferences, searches, or behaviors, collecting, selling, or using their information without notice is still a prevalent issue. Today, even how reputable, large e-commerce sites manage this remains a topic of ongoing debate. The advertising bots, cookies, and trackers that trample on the internet are, however, a topic for another article...
In the context of not neglecting your ethical and legal obligations, at the very least, be sure to provide an explanation of the cookies used and obtain explicit consent from users for the collection of personal data or for services aimed at performance measurement. For more detailed information on the subject, please refer to the legal regulations mentioned above or consult with authorized legal professionals.
Conclusion
In this article, we explored the structure of cookies and their security risks, examining how easily cookies can be accessed on the client side using the document object and how vulnerable this access can be without proper security measures. We also saw that external libraries used on the client side do not provide sufficient security layers for cookie management. It is an undeniable fact that structures used for secure session management or for mutation or fetch operations performed on the server, when stored as cookies without HTTP-only and Secure flags, are prone to tampering and can lead to significant security vulnerabilities.
To minimize these security risks, we explored how to implement secure cookie management, how Next.js and Server Actions can make these processes more secure, and how to perform cookie validation with Middleware for server-side operations or CSR - SSR consistency.
I aimed to highlight through examples that using HTTP-only and Secure cookies or Server Actions that can only be accessed via POST requests is not entirely secure on its own, as unvalidated data can still lead to security vulnerabilities. Ultimately, the security measures discussed are just a few visible layers of the iceberg. There is no single or ultimate security measure, and cookie management is not limited to what we've covered. In the upcoming article on 'Next.js Session Management', I will address session cookie management topics such as JWT tokens, comparing the use of Route Handlers (API Endpoints) and Server Actions.
Please don't hesitate to reach out if you find any parts that are incorrect or open to criticism...
References
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies
- https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/
- https://owasp.org/www-community/HttpOnly
- https://portswigger.net/web-security
- https://fetch.spec.whatwg.org/#forbidden-response-header-name
- https://nextjs.org/docs
- https://nextjs.org/blog/security-nextjs-server-components-actions