diff --git a/README.md b/README.md
index efe7aded..e0af567a 100644
--- a/README.md
+++ b/README.md
@@ -115,6 +115,26 @@ async function Profile() {
}
```
+**For large session data (>4KB), enable cookie chunking:**
+
+```ts
+import { getIronSession } from 'iron-session';
+
+export async function POST(req, res) {
+ const session = await getIronSession(req, res, {
+ password: "...",
+ cookieName: "...",
+ chunking: {
+ enabled: true,
+ chunkSize: 3500 // optional
+ }
+ });
+
+ session.largeData = { /* your large session data */ };
+ await session.save(); // Automatically splits into multiple cookies if needed
+}
+```
+
## Examples
We have many different patterns and examples on the online demo, have a look: https://get-iron-session.vercel.app/.
@@ -130,6 +150,22 @@ Two options are required: `password` and `cookieName`. Everything else is automa
- `password`, **required**: Private key used to encrypt the cookie. It has to be at least 32 characters long. Use to generate strong passwords. `password` can be either a `string` or an `object` with incrementing keys like this: `{2: "...", 1: "..."}` to allow for password rotation. iron-session will use the highest numbered key for new cookies.
- `cookieName`, **required**: Name of the cookie to be stored
- `ttl`, _optional_: In seconds. Default to the equivalent of 14 days. You can set this to `0` and iron-session will compute the maximum allowed value by cookies.
+- `chunking`, _optional_: Enable cookie chunking for large session data that exceeds browser cookie size limits (~4KB). When enabled, the session cookie is automatically split into multiple smaller cookies. Default to `undefined` (disabled). Options:
+ - `enabled`: Boolean to enable/disable chunking
+ - `chunkSize`: Maximum size of each chunk in bytes. Default to `3500`
+
+ Example:
+ ```js
+ {
+ chunking: {
+ enabled: true,
+ chunkSize: 3500 // optional, defaults to 3500
+ }
+ }
+ ```
+
+ When chunking is enabled and the session data exceeds the chunk size, cookies are stored as `{cookieName}.0`, `{cookieName}.1`, etc. The reconstruction happens automatically on read. Old chunks are cleaned up when the session is saved or destroyed.
+
- `cookieOptions`, _optional_: Any option available from [jshttp/cookie#serialize](https://github.com/jshttp/cookie#cookieserializename-value-options) except for `encode` which is not a Set-Cookie Attribute. See [Mozilla Set-Cookie Attributes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes) and [Chrome Cookie Fields](https://developer.chrome.com/docs/devtools/application/cookies/#fields). Default to:
```js
@@ -220,6 +256,14 @@ Not so much:
Depending on your own needs and preferences, `iron-session` may or may not fit you.
+### What if my session data exceeds cookie size limits?
+
+Browsers typically limit cookies to around 4KB. If your session data is larger, you have two options:
+
+1. **Enable cookie chunking** (recommended for moderate sizes): Set `chunking: { enabled: true }` in your session options. This will automatically split large cookies into multiple smaller cookies (e.g., `session.0`, `session.1`, etc.) and reconstruct them transparently when reading.
+
+2. **Store large data elsewhere**: For very large session data, consider storing it in a database or cache (like Redis) and only keep a session ID in the cookie.
+
## Credits
- [Eran Hammer and hapi.js contributors](https://github.com/hapijs/iron/graphs/contributors)
diff --git a/src/core.ts b/src/core.ts
index 91902ec5..142cb77a 100644
--- a/src/core.ts
+++ b/src/core.ts
@@ -96,6 +96,33 @@ export interface SessionOptions {
* @see https://github.com/jshttp/cookie#options-1
*/
cookieOptions?: CookieOptions;
+
+ /**
+ * Configure cookie chunking for large sessions that exceed browser cookie size limits.
+ *
+ * When enabled, iron-session will automatically split large cookies into multiple
+ * smaller chunks (named `{cookieName}.0`, `{cookieName}.1`, etc.) and reassemble
+ * them when reading. This allows sessions larger than the typical 4096-byte browser
+ * limit.
+ *
+ * @example { enabled: true, chunkSize: 3500 }
+ */
+ chunking?: {
+ /**
+ * Enable cookie chunking. When `false` or `undefined`, the traditional 4096-byte
+ * limit check will be enforced.
+ *
+ * @default false
+ */
+ enabled: boolean;
+ /**
+ * Maximum size in bytes for each cookie chunk. Should be less than 4096 to allow
+ * room for cookie name and attributes.
+ *
+ * @default 3500
+ */
+ chunkSize?: number;
+ };
}
export type IronSession = T & {
@@ -199,6 +226,89 @@ function setCookie(res: ResponseType, cookieValue: string): void {
]);
}
+/**
+ * Splits a large cookie value into multiple chunks for browsers that enforce
+ * size limits. Each chunk is named `{baseName}.{index}`.
+ */
+function splitCookieIntoChunks(
+ value: string,
+ chunkSize: number,
+): string[] {
+ const chunks: string[] = [];
+ for (let i = 0; i < value.length; i += chunkSize) {
+ chunks.push(value.slice(i, i + chunkSize));
+ }
+ return chunks;
+}
+
+/**
+ * Reconstructs a cookie value from chunks. Tries direct cookie first,
+ * then attempts to reassemble from indexed chunks if not found.
+ * Works transparently for both chunked and non-chunked cookies.
+ */
+function reconstructCookie(
+ req: RequestType,
+ cookieName: string,
+): string {
+ // Try direct cookie first (for non-chunked or old sessions)
+ const directCookie = getCookie(req, cookieName);
+ if (directCookie) {
+ return directCookie;
+ }
+
+ // Try to reconstruct from chunks
+ const chunks: string[] = [];
+ let chunkIndex = 0;
+
+ while (true) {
+ const chunkName = `${cookieName}.${chunkIndex}`;
+ const chunk = getCookie(req, chunkName);
+
+ if (!chunk) {
+ break;
+ }
+
+ chunks.push(chunk);
+ chunkIndex++;
+ }
+
+ return chunks.join("");
+}
+
+/**
+ * Reconstructs a cookie value from chunks using CookieStore.
+ * For use with Next.js cookies() and similar APIs.
+ * Works transparently for both chunked and non-chunked cookies.
+ */
+function reconstructCookieWithStore(
+ cookieHandler: CookieStore,
+ cookieName: string,
+): string {
+ // Try direct cookie first
+ const directCookie = getServerActionCookie(cookieName, cookieHandler);
+ if (directCookie) {
+ return directCookie;
+ }
+
+ // Try to reconstruct from chunks
+ const chunks: string[] = [];
+ let chunkIndex = 0;
+
+ while (true) {
+ const chunkName = `${cookieName}.${chunkIndex}`;
+ const chunk = getServerActionCookie(chunkName, cookieHandler);
+
+ if (!chunk) {
+ break;
+ }
+
+ chunks.push(chunk);
+ chunkIndex++;
+ }
+
+ return chunks.join("");
+}
+
export function createSealData(_crypto: Crypto) {
return async function sealData(
data: unknown,
@@ -271,7 +381,7 @@ export function createUnsealData(_crypto: Crypto) {
function getSessionConfig(
sessionOptions: SessionOptions,
-): Required {
+): Required> & Pick {
const options = {
...defaultOptions,
...sessionOptions,
@@ -361,7 +471,7 @@ export function createGetIronSession(
let sessionConfig = getSessionConfig(sessionOptions);
- const sealFromCookies = getCookie(req, sessionConfig.cookieName);
+ const sealFromCookies = reconstructCookie(req, sessionConfig.cookieName);
const session = sealFromCookies
? await unsealData(sealFromCookies, {
password: passwordsMap,
@@ -387,19 +497,72 @@ export function createGetIronSession(
password: passwordsMap,
ttl: sessionConfig.ttl,
});
- const cookieValue = serialize(
- sessionConfig.cookieName,
- seal,
- sessionConfig.cookieOptions,
- );
- if (cookieValue.length > 4096) {
- throw new Error(
- `iron-session: Cookie length is too big (${cookieValue.length} bytes), browsers will refuse it. Try to remove some data.`,
+ // Check if chunking is enabled
+ if (sessionConfig.chunking?.enabled) {
+ const chunkSize = sessionConfig.chunking.chunkSize ?? 3500;
+
+ // Clean up old chunks first
+ let oldChunkIndex = 0;
+ while (true) {
+ const oldChunkName = `${sessionConfig.cookieName}.${oldChunkIndex}`;
+ const oldChunk = getCookie(req, oldChunkName);
+ if (!oldChunk) break;
+
+ const cleanupCookie = serialize(oldChunkName, "", {
+ ...sessionConfig.cookieOptions,
+ maxAge: 0,
+ });
+ setCookie(res, cleanupCookie);
+ oldChunkIndex++;
+ }
+
+ // If seal is small enough, use single cookie
+ const testCookieValue = serialize(
+ sessionConfig.cookieName,
+ seal,
+ sessionConfig.cookieOptions,
);
- }
- setCookie(res, cookieValue);
+ if (testCookieValue.length <= 4096) {
+ setCookie(res, testCookieValue);
+ return;
+ }
+
+ // Split into chunks
+ const chunks = splitCookieIntoChunks(seal, chunkSize);
+ chunks.forEach((chunk, index) => {
+ const chunkName = `${sessionConfig.cookieName}.${index}`;
+ const chunkCookieValue = serialize(
+ chunkName,
+ chunk,
+ sessionConfig.cookieOptions,
+ );
+ setCookie(res, chunkCookieValue);
+ });
+
+ // Delete the main cookie if it exists (we're using chunks now)
+ const deleteCookie = serialize(sessionConfig.cookieName, "", {
+ ...sessionConfig.cookieOptions,
+ maxAge: 0,
+ });
+ setCookie(res, deleteCookie);
+ } else {
+ // Original behavior - no chunking
+ const cookieValue = serialize(
+ sessionConfig.cookieName,
+ seal,
+ sessionConfig.cookieOptions,
+ );
+
+ if (cookieValue.length > 4096) {
+ throw new Error(
+ `iron-session: Cookie length is too big (${cookieValue.length} bytes), browsers will refuse it. Try to remove some data.`,
+ );
+ }
+
+ setCookie(res, cookieValue);
+ }
},
},
@@ -408,12 +571,30 @@ export function createGetIronSession(
Object.keys(session).forEach((key) => {
delete (session as Record)[key];
});
+
+ // Delete main cookie
const cookieValue = serialize(sessionConfig.cookieName, "", {
...sessionConfig.cookieOptions,
maxAge: 0,
});
-
setCookie(res, cookieValue);
+
+ // Also delete chunks if chunking was enabled
+ if (sessionConfig.chunking?.enabled) {
+ let chunkIndex = 0;
+ while (true) {
+ const chunkName = `${sessionConfig.cookieName}.${chunkIndex}`;
+ const chunk = getCookie(req, chunkName);
+ if (!chunk) break;
+
+ const chunkCookieValue = serialize(chunkName, "", {
+ ...sessionConfig.cookieOptions,
+ maxAge: 0,
+ });
+ setCookie(res, chunkCookieValue);
+ chunkIndex++;
+ }
+ }
},
},
});
@@ -445,10 +626,8 @@ async function getIronSessionFromCookieStore(
}
let sessionConfig = getSessionConfig(sessionOptions);
- const sealFromCookies = getServerActionCookie(
- sessionConfig.cookieName,
- cookieStore,
- );
+
+ const sealFromCookies = reconstructCookieWithStore(cookieStore, sessionConfig.cookieName);
const session = sealFromCookies
? await unsealData(sealFromCookies, {
password: passwordsMap,
@@ -469,22 +648,70 @@ async function getIronSessionFromCookieStore(
ttl: sessionConfig.ttl,
});
- const cookieLength =
- sessionConfig.cookieName.length +
- seal.length +
- JSON.stringify(sessionConfig.cookieOptions).length;
+ // Check if chunking is enabled
+ if (sessionConfig.chunking?.enabled) {
+ const chunkSize = sessionConfig.chunking.chunkSize ?? 3500;
+
+ // Clean up old chunks first
+ let oldChunkIndex = 0;
+ while (true) {
+ const oldChunkName = `${sessionConfig.cookieName}.${oldChunkIndex}`;
+ const oldChunk = getServerActionCookie(oldChunkName, cookieStore);
+ if (!oldChunk) break;
+
+ cookieStore.set(oldChunkName, "", {
+ ...sessionConfig.cookieOptions,
+ maxAge: 0,
+ });
+ oldChunkIndex++;
+ }
- if (cookieLength > 4096) {
- throw new Error(
- `iron-session: Cookie length is too big (${cookieLength} bytes), browsers will refuse it. Try to remove some data.`,
+ // If seal is small enough, use single cookie
+ const cookieLength =
+ sessionConfig.cookieName.length +
+ seal.length +
+ JSON.stringify(sessionConfig.cookieOptions).length;
+
+ if (cookieLength <= 4096) {
+ cookieStore.set(
+ sessionConfig.cookieName,
+ seal,
+ sessionConfig.cookieOptions,
+ );
+ return;
+ }
+
+ // Split into chunks
+ const chunks = splitCookieIntoChunks(seal, chunkSize);
+ chunks.forEach((chunk, index) => {
+ const chunkName = `${sessionConfig.cookieName}.${index}`;
+ cookieStore.set(chunkName, chunk, sessionConfig.cookieOptions);
+ });
+
+ // Delete the main cookie if it exists (we're using chunks now)
+ cookieStore.set(sessionConfig.cookieName, "", {
+ ...sessionConfig.cookieOptions,
+ maxAge: 0,
+ });
+ } else {
+ // Original behavior - no chunking
+ const cookieLength =
+ sessionConfig.cookieName.length +
+ seal.length +
+ JSON.stringify(sessionConfig.cookieOptions).length;
+
+ if (cookieLength > 4096) {
+ throw new Error(
+ `iron-session: Cookie length is too big (${cookieLength} bytes), browsers will refuse it. Try to remove some data.`,
+ );
+ }
+
+ cookieStore.set(
+ sessionConfig.cookieName,
+ seal,
+ sessionConfig.cookieOptions,
);
}
-
- cookieStore.set(
- sessionConfig.cookieName,
- seal,
- sessionConfig.cookieOptions,
- );
},
},
@@ -494,8 +721,22 @@ async function getIronSessionFromCookieStore(
delete (session as Record)[key];
});
+ // Delete main cookie
const cookieOptions = { ...sessionConfig.cookieOptions, maxAge: 0 };
cookieStore.set(sessionConfig.cookieName, "", cookieOptions);
+
+ // Also delete chunks if chunking was enabled
+ if (sessionConfig.chunking?.enabled) {
+ let chunkIndex = 0;
+ while (true) {
+ const chunkName = `${sessionConfig.cookieName}.${chunkIndex}`;
+ const chunk = getServerActionCookie(chunkName, cookieStore);
+ if (!chunk) break;
+
+ cookieStore.set(chunkName, "", cookieOptions);
+ chunkIndex++;
+ }
+ }
},
},
});
diff --git a/src/index.test.ts b/src/index.test.ts
index a91baccd..7795f45f 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -500,3 +500,224 @@ await test("should work with standard web Request/Response APIs", async () => {
session = await getSession(req, res, { cookieName, password });
deepEqual(session, { user: { id: 1 } });
});
+
+const collectAllCookies = (res: { setHeader: { mock: { calls: Array<{ arguments: [string, string[]] }> } } }) => {
+ const allCookies: string[] = [];
+ for (const call of res.setHeader.mock.calls) {
+ const [, cookies] = call.arguments;
+ if (Array.isArray(cookies)) {
+ allCookies.push(...cookies);
+ }
+ }
+ return allCookies;
+};
+
+await test("should enable chunking when configured", async () => {
+ const res = { getHeader: mock.fn(() => []), setHeader: mock.fn() };
+
+ // Create a large session (> 4096 bytes) to trigger chunking
+ const largeData = "x".repeat(5000);
+ const session = await getSession(
+ { headers: {} } as Request,
+ res as unknown as ServerResponse,
+ {
+ cookieName,
+ password,
+ chunking: { enabled: true },
+ },
+ );
+ session.user = { id: 1, meta: largeData };
+ await session.save();
+
+ const allCookies = collectAllCookies(res as any);
+
+ // Should create multiple chunked cookies (at least 2 chunks + 1 delete for main cookie)
+ equal(allCookies.length >= 2, true);
+ // Check chunk naming pattern
+ const chunkCookies = allCookies.filter(c => /^test\.\d+=/.test(c));
+ equal(chunkCookies.length >= 2, true);
+ match(chunkCookies[0]!, /^test\.0=/);
+ match(chunkCookies[1]!, /^test\.1=/);
+
+ mock.reset();
+});
+
+await test("should reconstruct session from chunked cookies", async () => {
+ const res = { getHeader: mock.fn(() => []), setHeader: mock.fn() };
+ const largeData = "y".repeat(5000);
+ let session = await getSession(
+ { headers: {} } as Request,
+ res as unknown as ServerResponse,
+ {
+ cookieName,
+ password,
+ chunking: { enabled: true },
+ },
+ );
+ session.user = { id: 2, meta: largeData };
+ await session.save();
+
+ const allCookies = collectAllCookies(res as any);
+ const chunkCookies = allCookies.filter(c => /^test\.\d+=/.test(c));
+
+ // Build cookie header with all chunks
+ const cookieHeader = chunkCookies.map((c: string) => c.split(";")[0]!).join("; ");
+
+ // Read session back - should reconstruct from chunks
+ const req = { headers: { cookie: cookieHeader } } as IncomingMessage;
+ session = await getSession(req, res as unknown as ServerResponse, {
+ cookieName,
+ password,
+ chunking: { enabled: true },
+ });
+
+ equal(session.user?.id, 2);
+ equal(session.user?.meta, largeData);
+
+ mock.reset();
+});
+
+await test("should use single cookie when data is small with chunking enabled", async () => {
+ const res = { getHeader: mock.fn(() => []), setHeader: mock.fn() };
+
+ const session = await getSession(
+ { headers: {} } as Request,
+ res as unknown as ServerResponse,
+ {
+ cookieName,
+ password,
+ chunking: { enabled: true },
+ },
+ );
+ session.user = { id: 3 }; // Small data
+ await session.save();
+
+ const allCookies = collectAllCookies(res as any);
+ // Should use single cookie for small data
+ equal(allCookies.length, 1);
+ match(allCookies[0]!, /^test=/); // Not chunked
+ doesNotMatch(allCookies[0]!, /^test\.0=/); // No chunk suffix
+
+ mock.reset();
+});
+
+await test("should clean up old chunks when updating session", async () => {
+ let res = { getHeader: mock.fn(() => []), setHeader: mock.fn() };
+
+ // First save with large data (creates chunks)
+ const largeData = "z".repeat(5000);
+ let session = await getSession(
+ { headers: {} } as Request,
+ res as unknown as ServerResponse,
+ {
+ cookieName,
+ password,
+ chunking: { enabled: true },
+ },
+ );
+ session.user = { id: 4, meta: largeData };
+ await session.save();
+
+ const firstCookies = collectAllCookies(res as any);
+ const firstChunkCookies = firstCookies.filter(c => /^test\.\d+=/.test(c));
+ const firstChunkCount = firstChunkCookies.length;
+
+ // Build cookie header
+ const cookieHeader = firstChunkCookies.map((c: string) => c.split(";")[0]!).join("; ");
+
+ // Reset mock and update getHeader to return existing cookies
+ res = { getHeader: mock.fn(() => []), setHeader: mock.fn() };
+
+ // Update with smaller data
+ const req = { headers: { cookie: cookieHeader } } as IncomingMessage;
+ session = await getSession(req, res as unknown as ServerResponse, {
+ cookieName,
+ password,
+ chunking: { enabled: true },
+ });
+ session.user = { id: 5 }; // Small data now
+ await session.save();
+
+ const secondCookies = collectAllCookies(res as any);
+
+ // Should have cleanup cookies (maxAge=0) for old chunks plus the new single cookie
+ equal(secondCookies.length, firstChunkCount + 1);
+ // Old chunks should be deleted (maxAge=0)
+ for (let i = 0; i < firstChunkCount; i++) {
+ match(secondCookies[i]!, /Max-Age=0/);
+ }
+
+ mock.reset();
+});
+
+await test("should destroy all chunks on session destroy", async () => {
+ let res = { getHeader: mock.fn(() => []), setHeader: mock.fn() };
+
+ // Create chunked session
+ const largeData = "a".repeat(5000);
+ let session = await getSession(
+ { headers: {} } as Request,
+ res as unknown as ServerResponse,
+ {
+ cookieName,
+ password,
+ chunking: { enabled: true },
+ },
+ );
+ session.user = { id: 6, meta: largeData };
+ await session.save();
+
+ const saveCookies = collectAllCookies(res as any);
+ const saveChunkCookies = saveCookies.filter(c => /^test\.\d+=/.test(c));
+ const chunkCount = saveChunkCookies.length;
+
+ // Build cookie header
+ const cookieHeader = saveChunkCookies.map((c: string) => c.split(";")[0]!).join("; ");
+
+ // Reset mock
+ res = { getHeader: mock.fn(() => []), setHeader: mock.fn() };
+
+ // Destroy session
+ const req = { headers: { cookie: cookieHeader } } as IncomingMessage;
+ session = await getSession(req, res as unknown as ServerResponse, {
+ cookieName,
+ password,
+ chunking: { enabled: true },
+ });
+ session.destroy();
+
+ const destroyCookies = collectAllCookies(res as any);
+
+ // Should delete main cookie + all chunks
+ equal(destroyCookies.length, chunkCount + 1);
+ // All should have maxAge=0
+ for (const cookie of destroyCookies) {
+ match(cookie!, /Max-Age=0/);
+ }
+
+ mock.reset();
+});
+
+await test("should respect custom chunk size", async () => {
+ const res = { getHeader: mock.fn(() => []), setHeader: mock.fn() };
+
+ const mediumData = "b".repeat(3000);
+ const session = await getSession(
+ { headers: {} } as Request,
+ res as unknown as ServerResponse,
+ {
+ cookieName,
+ password,
+ chunking: { enabled: true, chunkSize: 2000 }, // Custom smaller chunk size
+ },
+ );
+ session.user = { id: 7, meta: mediumData };
+ await session.save();
+
+ const allCookies = collectAllCookies(res as any);
+ const chunkCookies = allCookies.filter(c => /^test\.\d+=/.test(c));
+ // With 2000 byte chunks, 3000 bytes of data should create multiple chunks
+ equal(chunkCookies.length > 1, true);
+
+ mock.reset();
+});