diff --git a/docs/pages/guides/extending-the-session.mdx b/docs/pages/guides/extending-the-session.mdx
index 73786fc858..321372c318 100644
--- a/docs/pages/guides/extending-the-session.mdx
+++ b/docs/pages/guides/extending-the-session.mdx
@@ -92,6 +92,87 @@ const providers: Provider[] = [
]
```
+## Dynamic Session Duration
+
+You can now dynamically control session duration based on user preferences or roles. The `maxAge` option accepts a static value, `"session"` for browser session cookies, or a function that returns either dynamically.
+
+### Remember Me Feature
+
+```ts filename="auth.ts"
+export default {
+ session: {
+ maxAge: async ({ token }) => {
+ // Check if user selected "Remember Me" during login
+ if (token?.rememberMe) {
+ return 30 * 24 * 60 * 60 // 30 days
+ }
+ return "session" // Browser session cookie
+ }
+ },
+ callbacks: {
+ jwt({ token, user, trigger }) {
+ if (trigger === "signIn" && user) {
+ // Save rememberMe preference in JWT
+ token.rememberMe = user.rememberMe
+ }
+ return token
+ }
+ }
+}
+```
+
+### Role-Based Session Duration
+
+```ts filename="auth.ts"
+export default {
+ session: {
+ maxAge: async ({ token }) => {
+ // Different session durations based on user role
+ switch (token?.role) {
+ case "admin":
+ return 4 * 60 * 60 // 4 hours for admins
+ case "user":
+ return 24 * 60 * 60 // 24 hours for regular users
+ case "guest":
+ return "session" // Session cookie for guests
+ default:
+ return 30 * 24 * 60 * 60 // Default 30 days
+ }
+ }
+ }
+}
+```
+
+### Device-Based Sessions
+
+```ts filename="auth.ts"
+export default {
+ session: {
+ maxAge: async ({ token, trigger }) => {
+ // Shorter sessions on public/shared devices
+ if (token?.deviceType === "public") {
+ return 60 * 60 // 1 hour
+ }
+
+ // Session cookies for sensitive operations
+ if (trigger === "update" && token?.isSensitiveOperation) {
+ return "session"
+ }
+
+ return 7 * 24 * 60 * 60 // Default 7 days
+ }
+ }
+}
+```
+
+
+ When using `"session"` as the maxAge value, the cookie will expire when the browser is closed. This is useful for public computers or when users don't want their session to persist.
+
+
+
+ For database sessions, `"session"` cookies still require a database expiry. The database record will use the default maxAge for cleanup purposes, but the cookie itself will be a session cookie.
+
+
## Resources
- [Concepts. Session strategies](/concepts/session-strategies)
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index aef7a3b6ca..e778af35e1 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -253,11 +253,29 @@ export interface AuthConfig {
*/
strategy?: "jwt" | "database"
/**
- * Relative time from now in seconds when to expire the session
+ * Relative time from now in seconds when to expire the session,
+ * or "session" to create a session cookie that expires when the browser closes.
+ * Can also be a function that returns the value dynamically.
*
* @default 2592000 // 30 days
+ * @example
+ * ```ts
+ * // Static session cookie (browser session)
+ * maxAge: "session"
+ *
+ * // Dynamic based on user preferences
+ * maxAge: async ({ token }) => {
+ * return token?.rememberMe ? 30 * 24 * 60 * 60 : "session"
+ * }
+ * ```
*/
- maxAge?: number
+ maxAge?: number | "session" | ((params: {
+ user?: import("./types.js").User
+ token?: import("./jwt.js").JWT
+ trigger?: "signIn" | "signUp" | "update"
+ isNewUser?: boolean
+ session?: any
+ }) => number | "session" | PromiseLike)
/**
* How often the session should be updated in seconds.
* If set to `0`, session is updated every time.
diff --git a/packages/core/src/lib/actions/callback/index.ts b/packages/core/src/lib/actions/callback/index.ts
index d11048f7cb..de509b8f68 100644
--- a/packages/core/src/lib/actions/callback/index.ts
+++ b/packages/core/src/lib/actions/callback/index.ts
@@ -23,12 +23,33 @@ import type {
User,
} from "../../../types.js"
import type { Cookie, SessionStore } from "../../utils/cookie.js"
+import type { JWT } from "../../../jwt.js"
import {
assertInternalOptionsWebAuthn,
verifyAuthenticate,
verifyRegister,
} from "../../utils/webauthn-utils.js"
+/**
+ * Resolve the maxAge value for session cookies.
+ * Handles static values, "session" for session cookies, and dynamic functions.
+ */
+async function resolveMaxAge(
+ maxAge: InternalOptions["session"]["maxAge"],
+ params: {
+ user?: User
+ token?: JWT
+ trigger?: "signIn" | "signUp" | "update"
+ isNewUser?: boolean
+ session?: any
+ }
+): Promise {
+ if (typeof maxAge === "function") {
+ return await maxAge(params)
+ }
+ return maxAge
+}
+
/** Handle callbacks from login services */
export async function callback(
request: RequestInternal,
@@ -161,13 +182,23 @@ export async function callback(
// Encode token
const newToken = await jwt.encode({ ...jwt, token, salt })
- // Set cookie expiry date
- const cookieExpires = new Date()
- cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
-
- const sessionCookies = sessionStore.chunk(newToken, {
- expires: cookieExpires,
+ // Resolve maxAge dynamically
+ const resolvedMaxAge = await resolveMaxAge(sessionMaxAge, {
+ user,
+ token,
+ trigger: isNewUser ? "signUp" : "signIn",
+ isNewUser,
})
+
+ // Set cookie expiry date
+ const cookieOptions: { expires?: Date } = {}
+ if (resolvedMaxAge !== "session") {
+ const cookieExpires = new Date()
+ cookieExpires.setTime(cookieExpires.getTime() + resolvedMaxAge * 1000)
+ cookieOptions.expires = cookieExpires
+ }
+
+ const sessionCookies = sessionStore.chunk(newToken, cookieOptions)
cookies.push(...sessionCookies)
}
} else {
@@ -285,13 +316,23 @@ export async function callback(
// Encode token
const newToken = await jwt.encode({ ...jwt, token, salt })
- // Set cookie expiry date
- const cookieExpires = new Date()
- cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
-
- const sessionCookies = sessionStore.chunk(newToken, {
- expires: cookieExpires,
+ // Resolve maxAge dynamically
+ const resolvedMaxAge = await resolveMaxAge(sessionMaxAge, {
+ user,
+ token,
+ trigger: isNewUser ? "signUp" : "signIn",
+ isNewUser,
})
+
+ // Set cookie expiry date
+ const cookieOptions: { expires?: Date } = {}
+ if (resolvedMaxAge !== "session") {
+ const cookieExpires = new Date()
+ cookieExpires.setTime(cookieExpires.getTime() + resolvedMaxAge * 1000)
+ cookieOptions.expires = cookieExpires
+ }
+
+ const sessionCookies = sessionStore.chunk(newToken, cookieOptions)
cookies.push(...sessionCookies)
}
} else {
@@ -374,13 +415,23 @@ export async function callback(
// Encode token
const newToken = await jwt.encode({ ...jwt, token, salt })
+ // Resolve maxAge dynamically
+ const resolvedMaxAge = await resolveMaxAge(sessionMaxAge, {
+ user: loggedInUser,
+ token,
+ trigger: isNewUser ? "signUp" : "signIn",
+ isNewUser,
+ })
+
// Set cookie expiry date
- const cookieExpires = new Date()
- cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
+ const cookieOptions: { expires?: Date } = {}
+ if (resolvedMaxAge !== "session") {
+ const cookieExpires = new Date()
+ cookieExpires.setTime(cookieExpires.getTime() + resolvedMaxAge * 1000)
+ cookieOptions.expires = cookieExpires
+ }
- const sessionCookies = sessionStore.chunk(newToken, {
- expires: cookieExpires,
- })
+ const sessionCookies = sessionStore.chunk(newToken, cookieOptions)
cookies.push(...sessionCookies)
}
@@ -482,13 +533,23 @@ export async function callback(
// Encode token
const newToken = await jwt.encode({ ...jwt, token, salt })
- // Set cookie expiry date
- const cookieExpires = new Date()
- cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
-
- const sessionCookies = sessionStore.chunk(newToken, {
- expires: cookieExpires,
+ // Resolve maxAge dynamically
+ const resolvedMaxAge = await resolveMaxAge(sessionMaxAge, {
+ user,
+ token,
+ trigger: isNewUser ? "signUp" : "signIn",
+ isNewUser,
})
+
+ // Set cookie expiry date
+ const cookieOptions: { expires?: Date } = {}
+ if (resolvedMaxAge !== "session") {
+ const cookieExpires = new Date()
+ cookieExpires.setTime(cookieExpires.getTime() + resolvedMaxAge * 1000)
+ cookieOptions.expires = cookieExpires
+ }
+
+ const sessionCookies = sessionStore.chunk(newToken, cookieOptions)
cookies.push(...sessionCookies)
}
} else {
diff --git a/packages/core/src/lib/actions/session.ts b/packages/core/src/lib/actions/session.ts
index cd2d815cde..574a9c2ae3 100644
--- a/packages/core/src/lib/actions/session.ts
+++ b/packages/core/src/lib/actions/session.ts
@@ -2,8 +2,29 @@ import { JWTSessionError, SessionTokenError } from "../../errors.js"
import { fromDate } from "../utils/date.js"
import type { Adapter } from "../../adapters.js"
-import type { InternalOptions, ResponseInternal, Session } from "../../types.js"
+import type { InternalOptions, ResponseInternal, Session, User } from "../../types.js"
import type { Cookie, SessionStore } from "../utils/cookie.js"
+import type { JWT } from "../../jwt.js"
+
+/**
+ * Resolve the maxAge value for session cookies.
+ * Handles static values, "session" for session cookies, and dynamic functions.
+ */
+async function resolveMaxAge(
+ maxAge: InternalOptions["session"]["maxAge"],
+ params: {
+ user?: User
+ token?: JWT
+ trigger?: "signIn" | "signUp" | "update"
+ isNewUser?: boolean
+ session?: any
+ }
+): Promise {
+ if (typeof maxAge === "function") {
+ return await maxAge(params)
+ }
+ return maxAge
+}
/** Return a session object filtered via `callbacks.session` */
export async function session(
@@ -53,7 +74,15 @@ export async function session(
session: newSession,
})
- const newExpires = fromDate(sessionMaxAge)
+ const resolvedMaxAge = await resolveMaxAge(sessionMaxAge, {
+ token,
+ trigger: isUpdate ? "update" : undefined,
+ session: newSession,
+ })
+
+ const newExpires = resolvedMaxAge === "session"
+ ? fromDate(30 * 24 * 60 * 60) // Use a default for internal calculations
+ : fromDate(resolvedMaxAge)
if (token !== null) {
// By default, only exposes a limited subset of information to the client
@@ -72,9 +101,12 @@ export async function session(
const newToken = await jwt.encode({ ...jwt, token, salt })
// Set cookie, to also update expiry date on cookie
- const sessionCookies = sessionStore.chunk(newToken, {
- expires: newExpires,
- })
+ const cookieOptions: { expires?: Date } = {}
+ if (resolvedMaxAge !== "session") {
+ cookieOptions.expires = newExpires
+ }
+
+ const sessionCookies = sessionStore.chunk(newToken, cookieOptions)
response.cookies?.push(...sessionCookies)
@@ -110,15 +142,26 @@ export async function session(
const { user, session } = userAndSession
const sessionUpdateAge = options.session.updateAge
+
+ // Resolve maxAge for database sessions
+ const resolvedMaxAge = await resolveMaxAge(sessionMaxAge, {
+ user,
+ trigger: isUpdate ? "update" : undefined,
+ session: newSession,
+ })
+
+ // For database sessions, we need a numeric value
+ const numericMaxAge = resolvedMaxAge === "session" ? sessionMaxAge : resolvedMaxAge
+
// Calculate last updated date to throttle write updates to database
// Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge
// e.g. ({expiry date} - 30 days) + 1 hour
const sessionIsDueToBeUpdatedDate =
session.expires.valueOf() -
- sessionMaxAge * 1000 +
+ numericMaxAge * 1000 +
sessionUpdateAge * 1000
- const newExpires = fromDate(sessionMaxAge)
+ const newExpires = fromDate(numericMaxAge)
// Trigger update of session expiry date and write to database, only
// if the session was last updated more than {sessionUpdateAge} ago
if (sessionIsDueToBeUpdatedDate <= Date.now()) {
@@ -143,13 +186,15 @@ export async function session(
response.body = sessionPayload
// Set cookie again to update expiry
+ const cookieOptions = { ...options.cookies.sessionToken.options }
+ if (resolvedMaxAge !== "session") {
+ cookieOptions.expires = newExpires
+ }
+
response.cookies?.push({
name: options.cookies.sessionToken.name,
value: sessionToken,
- options: {
- ...options.cookies.sessionToken.options,
- expires: newExpires,
- },
+ options: cookieOptions,
})
// @ts-expect-error
diff --git a/packages/core/src/lib/init.ts b/packages/core/src/lib/init.ts
index 9e8ca122f6..8625b482d7 100644
--- a/packages/core/src/lib/init.ts
+++ b/packages/core/src/lib/init.ts
@@ -123,7 +123,7 @@ export async function init({
// JWT options
jwt: {
secret: config.secret!, // Asserted in assert.ts
- maxAge: config.session?.maxAge ?? maxAge, // default to same as `session.maxAge`
+ maxAge: config.session?.maxAge ?? maxAge, // default to same as `session.maxAge` or function
encode: jwt.encode,
decode: jwt.decode,
...config.jwt,
diff --git a/packages/core/test/actions/session.test.ts b/packages/core/test/actions/session.test.ts
index 314d948116..3f1bc923e4 100644
--- a/packages/core/test/actions/session.test.ts
+++ b/packages/core/test/actions/session.test.ts
@@ -274,4 +274,156 @@ describe("assert GET session action", () => {
assertNoCacheResponseHeaders(response)
})
})
+
+ describe("Dynamic session maxAge", () => {
+ it("should create a session cookie when maxAge is 'session'", async () => {
+ const authConfig = testConfig({
+ session: {
+ maxAge: "session" as const,
+ },
+ })
+
+ const expectedUser = {
+ name: "test",
+ email: "test@test.com",
+ picture: "https://test.com/test.png",
+ }
+
+ const expectedToken = {
+ ...expectedUser,
+ exp: expect.any(Number),
+ iat: expect.any(Number),
+ jti: expect.any(String),
+ sub: expect.any(String),
+ }
+
+ const salt = SESSION_COOKIE_NAME
+ const encodedToken = await encode({
+ salt,
+ secret: AUTH_SECRET,
+ token: expectedToken,
+ })
+
+ const { response } = await makeAuthRequest({
+ action: "session",
+ cookies: { [SESSION_COOKIE_NAME]: encodedToken },
+ config: authConfig,
+ })
+
+ const cookies = response.headers.getSetCookie()
+ const sessionCookie = cookies.find((c) =>
+ c.includes(SESSION_COOKIE_NAME)
+ )
+
+ // Session cookie should not have Max-Age or Expires
+ expect(sessionCookie).toBeDefined()
+ expect(sessionCookie).not.toContain("Max-Age")
+ expect(sessionCookie).not.toContain("Expires")
+ })
+
+ it("should handle dynamic maxAge based on token data", async () => {
+ const dynamicMaxAge = vi.fn(async ({ token }) => {
+ return token?.rememberMe ? 30 * 24 * 60 * 60 : "session"
+ })
+
+ const authConfig = testConfig({
+ session: {
+ maxAge: dynamicMaxAge,
+ },
+ })
+
+ const expectedUser = {
+ name: "test",
+ email: "test@test.com",
+ picture: "https://test.com/test.png",
+ }
+
+ const expectedToken = {
+ ...expectedUser,
+ rememberMe: false,
+ exp: expect.any(Number),
+ iat: expect.any(Number),
+ jti: expect.any(String),
+ sub: expect.any(String),
+ }
+
+ const salt = SESSION_COOKIE_NAME
+ const encodedToken = await encode({
+ salt,
+ secret: AUTH_SECRET,
+ token: expectedToken,
+ })
+
+ const { response } = await makeAuthRequest({
+ action: "session",
+ cookies: { [SESSION_COOKIE_NAME]: encodedToken },
+ config: authConfig,
+ })
+
+ const cookies = response.headers.getSetCookie()
+ const sessionCookie = cookies.find((c) =>
+ c.includes(SESSION_COOKIE_NAME)
+ )
+
+ // Should create session cookie when rememberMe is false
+ expect(dynamicMaxAge).toHaveBeenCalledWith({
+ token: expect.objectContaining({ rememberMe: false }),
+ trigger: undefined,
+ session: undefined,
+ })
+ expect(sessionCookie).toBeDefined()
+ expect(sessionCookie).not.toContain("Max-Age")
+ expect(sessionCookie).not.toContain("Expires")
+ })
+
+ it("should handle dynamic maxAge with persistent cookie", async () => {
+ const dynamicMaxAge = vi.fn(async ({ token }) => {
+ return token?.rememberMe ? 30 * 24 * 60 * 60 : "session"
+ })
+
+ const authConfig = testConfig({
+ session: {
+ maxAge: dynamicMaxAge,
+ },
+ })
+
+ const expectedToken = {
+ name: "test",
+ email: "test@test.com",
+ picture: "https://test.com/test.png",
+ rememberMe: true,
+ exp: expect.any(Number),
+ iat: expect.any(Number),
+ jti: expect.any(String),
+ sub: expect.any(String),
+ }
+
+ const salt = SESSION_COOKIE_NAME
+ const encodedToken = await encode({
+ salt,
+ secret: AUTH_SECRET,
+ token: expectedToken,
+ })
+
+ const { response } = await makeAuthRequest({
+ action: "session",
+ cookies: { [SESSION_COOKIE_NAME]: encodedToken },
+ config: authConfig,
+ })
+
+ const cookies = response.headers.getSetCookie()
+ const sessionCookie = cookies.find((c) =>
+ c.includes(SESSION_COOKIE_NAME)
+ )
+
+ // Should create persistent cookie when rememberMe is true
+ expect(dynamicMaxAge).toHaveBeenCalledWith({
+ token: expect.objectContaining({ rememberMe: true }),
+ trigger: undefined,
+ session: undefined,
+ })
+ expect(sessionCookie).toBeDefined()
+ expect(sessionCookie).toContain("Max-Age=2592000") // 30 days
+ })
+ })
})