Technical Documentation: vvo/iron-session
โน๏ธ Provenance: Multi-Tier Ingestion ยท CodeWiki Reference
Overview (`README.md`)
iron-session [](https://github.com/vvo/iron-session/blob/master/LICENSE) [](https://www.npmjs.com/package/iron-session)
iron-session is a secure, stateless, and cookie-based session library for JavaScript.
[!IMPORTANT]
Coming from v8? Read Upgrading to v9 for the two changes
most apps need, or MIGRATION.md for the full guide. v9 needs
Node 22.13+ and is ESM-only.
The session data is stored in signed and encrypted cookies which are decoded by your server code in a stateless fashion (= no network involved). This is the same technique used by frameworks like
Ruby On Rails.
Table of Contents
- Table of Contents
- Installation
- Upgrading to v9
- Usage
- Examples
- Runtimes
- Session size
- Watching for unreadable cookies
- Validating session data
- Project status
- Session options
- API
getIronSession<T>(req, res, sessionOptions): Promise>getIronSession<T>(cookieStore, sessionOptions): Promise>nodeCookies,webCookies,nextProxyCookiessession.save(): Promise<void>session.destroy(): voidsession.updateConfig(sessionOptions: SessionOptions): voidsealData(data: unknown, { password, ttl }): Promise<string>unsealData<T>(seal: string, { password, ttl }): Promise<T>
- FAQ
- Credits
- Good Reads
Installation
pnpm add iron-sessionv9 needs Node 22.13 or later and is ESM-only. require() still works on
Node 22.13+, which supports require() of an ES module. If you are stuck on an
older Node, stay on v8: pnpm add iron-session@8.
Upgrading to v9
Most apps change two things. Both are things v8 got wrong quietly.
1. Store timestamps, not Date objects.
- session.lastSeen = new Date();
+ session.lastSeen = Date.now();v8 turned a Date into a string when sealing, so the type you wrote was not the
type you read back. v9 throws and names the field.
2. Handle a session that does not exist yet.
- const userId = session.user.id;
+ const userId = session.user?.id;Reads are typed as Partial<T> now. A first visit, an expired cookie and adestroy() all leave you an empty object, so the old type let this compile and
then throw at runtime.
Nothing else is required. getIronSession(req, res, options) andgetIronSession(await cookies(), options) both still work, v9 reads v8 cookies
and v8 reads v9 cookies, so you can roll a deploy back without signing everyone
out. If you had as any on await cookies(), delete it.
Worth adopting while you are here:
nextProxyCookiesif you ever tried to save a session in Next.js
middleware and it did not stick.onUnsealErrorto see why cookies get
rejected instead of guessing.chunk: trueif your session outgrew one cookie.
The full guide, including the removed APIs and the security fix that signs pre-v8
cookies out once, is in MIGRATION.md.
Usage
We have extensive examples here too: https://get-iron-session.vercel.app/.
To get a session, there's a single method to know: getIronSession.
// Next.js API Routes and Node.js/Express/Connect.
import { getIronSession } from "iron-session";
export async function get(req, res) {
const session = await getIronSession(req, res, { password: "...", cookieName: "..." });
return session;
}
export async function post(req, res) {
const session = await getIronSession(req, res, { password: "...", cookieName: "..." });
session.username = "Alison";
await session.save();
}// Next.js Route Handlers (App Router)
import { cookies } from "next/headers";
import { getIronSession } from "iron-session";
export async function GET() {
const session = await getIronSession(await cookies(), { password: "...", cookieName: "..." });
return session;
}
export async function POST() {
const session = await getIronSession(await cookies(), { password: "...", cookieName: "..." });
session.username = "Alison";
await session.save();
}// Next.js Server Components and Server Actions (App Router)
import { cookies } from "next/headers";
import { getIronSession } from "iron-session";
async function getIronSessionData() {
const session = await getIronSession(await cookies(), { password: "...", cookieName: "..." });
return session;
}
async function Profile() {
const session = await getIronSessionData();
return <div>{session.username}</div>;
}// Next.js proxy.ts (middleware.ts before Next 16)
import { NextResponse, type NextRequest } from "next/server";
import { getIronSession, nextProxyCookies } from "iron-session";
export async function proxy(request: NextRequest) {
const response = NextResponse.next();
const session = await getIronSession(nextProxyCookies(request, response), options);
session.lastSeen = Date.now();
await session.save();
return response;
}Middleware needs the adapter because Next only merges a cookie into the current
render when it goes through response.cookies.set(). Writing a raw Set-Cookie
header there looks like it works and then has no effect.
Examples
Runnable examples for every pattern: https://get-iron-session.vercel.app/. Two
of them are where to start, and they follow the
Next.js authentication guide:
- Server Components and Server Actions
(source).
A form posts to a Server Action, the action writes the session, the page reads
it on the server. This is the default. - Cache Components and Partial Prerendering
(source), for Next.js
16 withcacheComponentson. Also coversuseActionStatefor form errors and
session rotation inproxy.ts.
Three rules once cacheComponents is on:
- A session read is dynamic, because it reads a cookie. Put it inside a
<Suspense>boundary and the rest of the page still prerenders. - Never read a session inside
use cache. Runtime APIs are rejected there, and
whatever it renders is shared between visitors. export const dynamic = "force-dynamic"is no longer allowed, and no longer
needed. The<Suspense>boundary is what marks the dynamic part.
The session belongs next to the data it protects: read it in the Server
Component, Server Action or Route Handler that needs it. A layout does not
protect the pages under it, and neither does a redirect in proxy.ts.
Runtimes
getIronSession(req, res, options) covers Node, Express, Connect and Next.js
API routes, and getIronSession(await cookies(), options) covers the Next.js App
Router. When you want to be explicit, or when your framework hands you something
else, pass an adapter instead:
| Adapter | For |
|---|---|
nodeCookies(req, res) |
Node http, Express, Connect, Next.js API routes |
webCookies(request, responseOrHeaders) |
Anything web-standard: Hono, Bun, Deno, Cloudflare Workers, Route Handlers |
nextProxyCookies(request, response) |
Next.js Proxy (middleware), proxy.ts |
Anything with get(name) and set(name, value, options), like Next'scookies(), can be passed directly. If your framework has neither, a cookie jar
is two functions:
const session = await getIronSession(
{
read: (name) => myFramework.getCookie(name),
write: (name, value, options) => myFramework.setCookie(name, value, options),
},
options,
);Session size
A browser refuses a cookie over 4096 bytes, and iron-session throws rather than
letting one be silently dropped. Encryption adds overhead, so plan for roughly
3KB of actual data.
If you need more, chunk: true splits the session across several cookies. Before
you reach for it, know what the real limit is: every cookie is sent on every
request, and proxies cap the whole Cookie header well below what a few
chunks produce. nginx allows 8KB by default and a CDN in front of it may allow
less. Going over returns a 400 or 431 at the edge, before your code runs.
iron-session refuses more than 4 chunks for that reason.
The scalable answer is to keep an id in the session and the data in your
database:
session.userId = user.id; // small, stateless
const user = await db.user.findUnique({ where: { id: session.userId } });Watching for unreadable cookies
When a cookie cannot be read, iron-session starts a new empty session instead of
throwing. It has to: it cannot tell a tampered cookie from a password you
rotated out or a seal that simply expired, and a 500 on every request would be
worse. That makes real problems invisible, so log them:
const options = {
cookieName: "session",
password: process.env.SESSION_PASSWORD,
onUnsealError: (reason, error) => {
// "expired" is normal, that is how sessions end.
if (reason !== "expired") {
logger.warn({ reason, error }, "session cookie rejected");
}
},
};A burst of "unknown-password" usually means a password rotation went wrong. A
burst of "invalid" can mean someone is probing your cookies.
Validating session data
There is no validate option, on purpose. If you change the shape of your
session, old cookies still decrypt into the old shape, and the place to handle
that is the wrapper you already have:
// lib/session.ts
export async function getSession() {
const session = await getIronSession<Session>(await cookies(), options);
if (session.user && !SessionSchema.safeParse({ ...session }).success) {
session.destroy();
}
return session;
}Project status
โ Production ready and maintained.
Session options
Two options are required: password and cookieName. Everything else is automatically computed and usually doesn't need to be changed.
password, required: Private key used to encrypt the cookie. It has to be at least 32 characters long. Use https://1password.com/password-generator/ to generate strong passwords.passwordcan be either astringor anobjectwith 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 storedttl, optional: In seconds. Default to the equivalent of 14 days. Setting it to0means the seal never expires, which also means it can never be revoked: do not use0for authentication.chunk, optional: Split a session that does not fit in one cookie across several cookies. Defaults tofalse. See Session size before turning it on.onUnsealError, optional: Called when an existing cookie could not be read, with a reason of"expired","invalid"or"unknown-password". The session is reset to empty either way, so this is for logging. See Watching for unreadable cookies.cookieOptions, optional: Any Set-Cookie attribute supported by jshttp/cookie. Default to:js{ httpOnly: true, secure: true, // set this to false in local (non-HTTPS) development sameSite: "lax",// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite#lax maxAge: (ttl === 0 ? 2147483647 : ttl) - 60, // Expire cookie before the session expires. A ttl of 60 or less keeps its full value. path: "/", }
API
`getIronSession(req, res, sessionOptions): Promise>`
type SessionData = {
// Your data
};
const session = await getIronSession<SessionData>(req, res, sessionOptions);`getIronSession(cookieStore, sessionOptions): Promise>`
type SessionData = {
// Your data
};
const session = await getIronSession<SessionData>(await cookies(), sessionOptions);Reads are typed as Partial<T>, because a session that does not exist yet is an
empty object. Use optional chaining, or narrow once and pass the result around.
`nodeCookies(req, res)`, `webCookies(request, responseOrHeaders)`, `nextProxyCookies(request, response)`
Cookie jars you pass in place of req, res, for when the shorthand cannot tell
what your framework wants. See Runtimes.
import { getIronSession, nextProxyCookies } from "iron-session";
const session = await getIronSession(nextProxyCookies(request, response), sessionOptions);`session.save(): Promise`
Saves the session. This is an asynchronous operation. It must be done and awaited before headers are sent to the client.
await session.save();`session.destroy(): void`
Destroys the session. This is a synchronous operation as it only removes the cookie. It must be done before headers are sent to the client.
session.destroy();destroy() is terminal. A save() after it is ignored, so a logout handler that calls both still signs the user out. Writing fields back into the session and then saving throws, because the last Set-Cookie would win and leave the user signed in.
`session.updateConfig(sessionOptions: SessionOptions): void`
Updates the configuration of the session with new session options. You still need to call save() if you want them to be applied.
It rebuilds the whole configuration, including the password, so this is what you use to rotate a password mid-request. In v8 a new password passed here was ignored.
`sealData(data: unknown, { password, ttl }): Promise`
This is the underlying method and seal mechanism that powers iron-session. You can use it to seal any data you want and pass it around. One usecase are magic links: you generate a seal that contains a user id to login and send it to a route on your website (like /magic-login). Once received, you can safely decode the seal with unsealData and log the user in.
`unsealData(seal: string, { password, ttl }): Promise`
This is the opposite of sealData and allow you to decode a seal to get the original data back.
FAQ
Why use pure cookies for sessions?
This makes your sessions stateless: since the data is passed around in cookies, you do not need any server or service to store session data.
More information can also be found on the Ruby On Rails website which uses the same technique.
How to invalidate sessions?
Sessions cannot be instantly invalidated (or "disconnect this customer") as there is typically no state stored about sessions on the server by default. However, in most applications, the first step upon receiving an authenticated request is to validate the user and their permissions in the database. So, to easily disconnect customers (or invalidate sessions), you can add an `isBlocked`` state in the database and create a UI to block customers.
Then, every time a request is received that involves reading or altering sensitive data, make sure to check this flag.
Can I use something else than cookies?
Yes, we expose sealData and unsealData which are not tied to cookies. This way you can seal and unseal any object in your application and move seals around to login users.
How is this different from [JWT](https://jwt.io/)?
Not so much:
- JWT is a standard, it stores metadata in the JWT token themselves to ensure communication between different systems is flawless.
- JWT tokens are not encrypted, the payload is visible by customers if they manage to inspect the seal. You would have to use JWE to achieve the same.
- @hapi/iron mechanism is not a standard, it's a way to sign and encrypt data into seals
Depending on your own needs and preferences, iron-session may or may not fit you.
Used by
Generated with usedby.dev
Credits
- Eran Hammer and hapi.js contributors
for creating the underlying cryptography library@hapi/iron. - Divyansh Singh for reimplementing
@hapi/ironasiron-webcryptousing standard
web APIs. - Hoang Vo for advice and guidance while building
this module. Hoang builtnext-connectandnext-session. - All the
contributors for
making this project better.
Good Reads
- https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
Overview (`examples/next/README.md`)
iron-session examples
The app behind get-iron-session.vercel.app.
Running it locally
Copy the passwords first, or every session route throws:
cp .env.example .env.localThen, from the repository root:
pnpm devThat builds the library, watches it, and serves this app on
https://iron-session.localhost through
portless: a real certificate, no port number, and no--experimental-https certificates to manage.
The https matters here. Our cookies default to secure: true, and a browser
will not store a Secure cookie on an insecure origin. Serving the examples
over plain http means Safari drops the session while Chrome keeps it, which is
exactly the confusion in
#870. With portless the
examples run the same defaults locally that they run in production.
The first run asks for your password once: portless generates a local CA, adds
it to the system trust store, and binds port 443.
Adding an example
Each example is a folder under src/app with its own lib.ts holding itssessionOptions and its own cookie name, so one example's session is never
readable by another. Add a card to src/app/page.tsx and a test toe2e/examples.spec.ts, which runs against the deployment on every PR.
--- METRICS ---
- Files Extracted: 2
- Estimated Token Budget: ~5049 tokens
- Canonical Reference: https://codewiki.google/github.com/vvo/iron-session