Medium severityNVD Advisory· Published Aug 29, 2025· Updated Apr 15, 2026
CVE-2025-4643
CVE-2025-4643
Description
Payload uses JSON Web Tokens (JWT) for authentication. After log out JWT is not invalidated, which allows an attacker who has stolen or intercepted token to freely reuse it until expiration date (which is by default set to 2 hours, but can be changed).
This issue has been fixed in version 3.44.0 of Payload.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
payloadnpm | < 3.44.0 | 3.44.0 |
@payloadcms/nextnpm | < 3.44.0 | 3.44.0 |
@payloadcms/graphqlnpm | < 3.44.0 | 3.44.0 |
Affected products
1Patches
126d709dda6e5feat: auth sessions (#12483)
29 files changed · +610 −81
docs/authentication/operations.mdx+13 −6 modified@@ -180,19 +180,22 @@ As Payload sets HTTP-only cookies, logging out cannot be done by just removing a **Example REST API logout**: ```ts -const res = await fetch('http://localhost:3000/api/[collection-slug]/logout', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', +const res = await fetch( + 'http://localhost:3000/api/[collection-slug]/logout?allSessions=false', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, }, -}) +) ``` **Example GraphQL Mutation**: ``` mutation { - logout[collection-singular-label] + logoutUser(allSessions: false) } ``` @@ -203,6 +206,10 @@ mutation { docs](../local-api/server-functions#reusable-payload-server-functions). </Banner> +#### Logging out with sessions enabled + +By default, logging out will only end the session pertaining to the JWT that was used to log out with. However, you can pass `allSessions: true` to the logout operation in order to end all sessions for the user logging out. + ## Refresh Allows for "refreshing" JWTs. If your user has a token that is about to expire, but the user is still active and using the app, you might want to use the `refresh` operation to receive a new token by executing this operation via the authenticated user.
docs/authentication/overview.mdx+1 −0 modified@@ -91,6 +91,7 @@ The following options are available: | **`strategies`** | Advanced - an array of custom authentication strategies to extend this collection's authentication with. [More details](./custom-strategies). | | **`tokenExpiration`** | How long (in seconds) to keep the user logged in. JWTs and HTTP-only cookies will both expire at the same time. | | **`useAPIKey`** | Payload Authentication provides for API keys to be set on each user within an Authentication-enabled Collection. [More details](./api-keys). | +| **`useSessions`** | True by default. Set to `false` to use stateless JWTs for authentication instead of sessions. | | **`verify`** | Set to `true` or pass an object with verification options to require users to verify by email before they are allowed to log into your app. [More details](./email#email-verification). | ### Login With Username
docs/local-api/server-functions.mdx+4 −5 modified@@ -393,15 +393,15 @@ export default function LoginForm() { ### Logout -Logs out the current user by clearing the authentication cookie. +Logs out the current user by clearing the authentication cookie and current sessions. #### Importing the `logout` function ```ts import { logout } from '@payloadcms/next/auth' ``` -Similar to the login function, you now need to pass your Payload config to this function and this cannot be done in a client component. Use a helper server function as shown below. +Similar to the login function, you now need to pass your Payload config to this function and this cannot be done in a client component. Use a helper server function as shown below. To ensure all sessions are cleared, set `allSessions: true` in the options, if you wish to logout but keep current sessions active, you can set this to `false` or leave it `undefined`. ```ts 'use server' @@ -411,7 +411,7 @@ import config from '@payload-config' export async function logoutAction() { try { - return await logout({ config }) + return await logout({ allSessions: true, config }) } catch (error) { throw new Error( `Logout failed: ${error instanceof Error ? error.message : 'Unknown error'}`, @@ -434,7 +434,7 @@ export default function LogoutButton() { ### Refresh -Refreshes the authentication token for the logged-in user. +Refreshes the authentication token and current session for the logged-in user. #### Importing the `refresh` function @@ -453,7 +453,6 @@ import config from '@payload-config' export async function refreshAction() { try { return await refresh({ - collection: 'users', // pass your collection slug config, }) } catch (error) {
docs/plugins/sentry.mdx+2 −6 modified@@ -74,9 +74,7 @@ import * as Sentry from '@sentry/nextjs' const config = buildConfig({ collections: [Pages, Media], - plugins: [ - sentryPlugin({ Sentry }) - ], + plugins: [sentryPlugin({ Sentry })], }) export default config @@ -98,9 +96,7 @@ export default buildConfig({ pool: { connectionString: process.env.DATABASE_URL }, pg, // Inject the patched pg driver for Sentry instrumentation }), - plugins: [ - sentryPlugin({ Sentry }) - ], + plugins: [sentryPlugin({ Sentry })], }) ```
packages/graphql/src/resolvers/auth/logout.ts+1 −0 modified@@ -7,6 +7,7 @@ import type { Context } from '../types.js' export function logout(collection: Collection): any { async function resolver(_, args, context: Context) { const options = { + allSessions: args.allSessions, collection, req: isolateObjectProperty(context.req, 'transactionID'), }
packages/graphql/src/schema/initCollections.ts+3 −0 modified@@ -487,6 +487,9 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ graphqlResult.Mutation.fields[`logout${singularName}`] = { type: GraphQLString, + args: { + allSessions: { type: GraphQLBoolean }, + }, resolve: logout(collection), }
packages/next/src/auth/logout.ts+28 −6 modified@@ -1,24 +1,46 @@ 'use server' +import type { SanitizedConfig } from 'payload' + import { cookies as getCookies, headers as nextHeaders } from 'next/headers.js' -import { getPayload } from 'payload' +import { createLocalReq, getPayload, logoutOperation } from 'payload' import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js' -export async function logout({ config }: { config: any }) { +export async function logout({ + allSessions = false, + config, +}: { + allSessions?: boolean + config: Promise<SanitizedConfig> | SanitizedConfig +}) { const payload = await getPayload({ config }) const headers = await nextHeaders() - const result = await payload.auth({ headers }) + const authResult = await payload.auth({ headers }) - if (!result.user) { + if (!authResult.user) { return { message: 'User already logged out', success: true } } - const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix) + const { user } = authResult + const req = await createLocalReq({ user }, payload) + const collection = payload.collections[user.collection] + const logoutResult = await logoutOperation({ + allSessions, + collection, + req, + }) + + if (!logoutResult) { + return { message: 'Logout failed', success: false } + } + + const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix) if (existingCookie) { const cookies = await getCookies() cookies.delete(existingCookie.name) - return { message: 'User logged out successfully', success: true } } + + return { message: 'User logged out successfully', success: true } }
packages/next/src/auth/refresh.ts+22 −10 modified@@ -3,33 +3,45 @@ import type { CollectionSlug } from 'payload' import { headers as nextHeaders } from 'next/headers.js' -import { getPayload } from 'payload' +import { createLocalReq, getPayload, refreshOperation } from 'payload' import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js' import { setPayloadAuthCookie } from '../utilities/setPayloadAuthCookie.js' -export async function refresh({ collection, config }: { collection: CollectionSlug; config: any }) { +export async function refresh({ config }: { config: any }) { const payload = await getPayload({ config }) - const authConfig = payload.collections[collection]?.config.auth + const headers = await nextHeaders() + const result = await payload.auth({ headers }) - if (!authConfig) { + if (!result.user) { + throw new Error('Cannot refresh token: user not authenticated') + } + + const collection: CollectionSlug | undefined = result.user.collection + const collectionConfig = payload.collections[collection] + + if (!collectionConfig?.config.auth) { throw new Error(`No auth config found for collection: ${collection}`) } - const { user } = await payload.auth({ headers: await nextHeaders() }) + const req = await createLocalReq({ user: result.user }, payload) - if (!user) { - throw new Error('User not authenticated') + const refreshResult = await refreshOperation({ + collection: collectionConfig, + req, + }) + + if (!refreshResult) { + return { message: 'Token refresh failed', success: false } } const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix) - if (!existingCookie) { - return { message: 'No valid token found', success: false } + return { message: 'No valid token found to refresh', success: false } } await setPayloadAuthCookie({ - authConfig, + authConfig: collectionConfig.config.auth, cookiePrefix: payload.config.cookiePrefix, token: existingCookie.value, })
packages/payload/src/auth/baseFields/sessions.ts+32 −0 added@@ -0,0 +1,32 @@ +import type { ArrayField } from '../../fields/config/types.js' + +export const sessionsFieldConfig: ArrayField = { + name: 'sessions', + type: 'array', + access: { + read: ({ doc, req: { user } }) => { + return user?.id === doc?.id + }, + update: () => false, + }, + admin: { + disabled: true, + }, + fields: [ + { + name: 'id', + type: 'text', + required: true, + }, + { + name: 'createdAt', + type: 'date', + defaultValue: () => new Date(), + }, + { + name: 'expiresAt', + type: 'date', + required: true, + }, + ], +}
packages/payload/src/auth/endpoints/logout.ts+3 −1 modified@@ -9,8 +9,10 @@ import { logoutOperation } from '../operations/logout.js' export const logoutHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) - const { t } = req + const { searchParams, t } = req + const result = await logoutOperation({ + allSessions: searchParams.get('allSessions') === 'true', collection, req, })
packages/payload/src/auth/getAuthFields.ts+5 −0 modified@@ -5,6 +5,7 @@ import { accountLockFields } from './baseFields/accountLock.js' import { apiKeyFields } from './baseFields/apiKey.js' import { baseAuthFields } from './baseFields/auth.js' import { emailFieldConfig } from './baseFields/email.js' +import { sessionsFieldConfig } from './baseFields/sessions.js' import { usernameFieldConfig } from './baseFields/username.js' import { verificationFields } from './baseFields/verification.js' @@ -52,6 +53,10 @@ export const getBaseAuthFields = (authConfig: IncomingAuthType): Field[] => { if (authConfig?.maxLoginAttempts && authConfig.maxLoginAttempts > 0) { authFields.push(...accountLockFields) } + + if (authConfig.useSessions) { + authFields.push(sessionsFieldConfig) + } } return authFields
packages/payload/src/auth/getFieldsToSign.ts+6 −1 modified@@ -114,16 +114,21 @@ const traverseFields = ({ export const getFieldsToSign = (args: { collectionConfig: CollectionConfig email: string + sid?: string user: PayloadRequest['user'] }): Record<string, unknown> => { - const { collectionConfig, email, user } = args + const { collectionConfig, email, sid, user } = args const result: Record<string, unknown> = { id: user?.id, collection: collectionConfig.slug, email, } + if (sid) { + result.sid = sid + } + traverseFields({ data: user!, fields: collectionConfig.fields,
packages/payload/src/auth/operations/login.ts+39 −8 modified@@ -1,3 +1,5 @@ +import { v4 as uuid } from 'uuid' + import type { AuthOperationsFromCollectionSlug, Collection, @@ -23,6 +25,7 @@ import { getFieldsToSign } from '../getFieldsToSign.js' import { getLoginOptions } from '../getLoginOptions.js' import { isUserLocked } from '../isUserLocked.js' import { jwtSign } from '../jwt.js' +import { removeExpiredSessions } from '../removeExpiredSessions.js' import { authenticateLocalStrategy } from '../strategies/local/authenticate.js' import { incrementLoginAttempts } from '../strategies/local/incrementLoginAttempts.js' import { resetLoginAttempts } from '../strategies/local/resetLoginAttempts.js' @@ -114,7 +117,6 @@ export const loginOperation = async <TSlug extends CollectionSlug>( // Login // ///////////////////////////////////// - let user const { email: unsanitizedEmail, password } = data const loginWithUsername = collectionConfig.auth.loginWithUsername @@ -204,7 +206,7 @@ export const loginOperation = async <TSlug extends CollectionSlug>( whereConstraint = usernameConstraint } - user = await payload.db.findOne<any>({ + let user = await payload.db.findOne<any>({ collection: collectionConfig.slug, req, where: whereConstraint, @@ -239,6 +241,41 @@ export const loginOperation = async <TSlug extends CollectionSlug>( throw new AuthenticationError(req.t) } + const fieldsToSignArgs: Parameters<typeof getFieldsToSign>[0] = { + collectionConfig, + email: sanitizedEmail!, + user, + } + + if (collectionConfig.auth.useSessions) { + // Add session to user + const newSessionID = uuid() + const now = new Date() + const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000 + const expiresAt = new Date(now.getTime() + tokenExpInMs) + + const session = { id: newSessionID, createdAt: now, expiresAt } + + if (!user.sessions?.length) { + user.sessions = [session] + } else { + user.sessions = removeExpiredSessions(user.sessions) + user.sessions.push(session) + } + + await payload.db.updateOne({ + id: user.id, + collection: collectionConfig.slug, + data: user, + req, + returning: false, + }) + + fieldsToSignArgs.sid = newSessionID + } + + const fieldsToSign = getFieldsToSign(fieldsToSignArgs) + if (maxLoginAttemptsEnabled) { await resetLoginAttempts({ collection: collectionConfig, @@ -248,12 +285,6 @@ export const loginOperation = async <TSlug extends CollectionSlug>( }) } - const fieldsToSign = getFieldsToSign({ - collectionConfig, - email: sanitizedEmail!, - user, - }) - // ///////////////////////////////////// // beforeLogin - Collection // /////////////////////////////////////
packages/payload/src/auth/operations/logout.ts+38 −0 modified@@ -6,13 +6,15 @@ import type { PayloadRequest } from '../../types/index.js' import { APIError } from '../../errors/index.js' export type Arguments = { + allSessions?: boolean collection: Collection req: PayloadRequest } export const logoutOperation = async (incomingArgs: Arguments): Promise<boolean> => { let args = incomingArgs const { + allSessions, collection: { config: collectionConfig }, req: { user }, req, @@ -36,5 +38,41 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise<boolean> } } + if (collectionConfig.auth.disableLocalStrategy !== true && collectionConfig.auth.useSessions) { + const userWithSessions = await req.payload.db.findOne<{ + id: number | string + sessions: { id: string }[] + }>({ + collection: collectionConfig.slug, + req, + where: { + id: { + equals: user.id, + }, + }, + }) + + if (!userWithSessions) { + throw new APIError('No User', httpStatus.BAD_REQUEST) + } + + if (allSessions) { + userWithSessions.sessions = [] + } else { + const sessionsAfterLogout = (userWithSessions?.sessions || []).filter( + (s) => s.id !== req?.user?._sid, + ) + + userWithSessions.sessions = sessionsAfterLogout + } + + await req.payload.db.updateOne({ + id: user.id, + collection: collectionConfig.slug, + data: userWithSessions, + returning: false, + }) + } + return true }
packages/payload/src/auth/operations/refresh.ts+28 −0 modified@@ -1,4 +1,5 @@ import url from 'url' +import { v4 as uuid } from 'uuid' import type { Collection } from '../../collections/config/types.js' import type { Document, PayloadRequest } from '../../types/index.js' @@ -10,6 +11,7 @@ import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' import { getFieldsToSign } from '../getFieldsToSign.js' import { jwtSign } from '../jwt.js' +import { removeExpiredSessions } from '../removeExpiredSessions.js' export type Result = { exp: number @@ -79,6 +81,31 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result> req: args.req, }) + const sid = args.req.user._sid + + if (collectionConfig.auth.useSessions && !collectionConfig.auth.disableLocalStrategy) { + if (!Array.isArray(user.sessions) || !sid) { + throw new Forbidden(args.req.t) + } + + const existingSession = user.sessions.find(({ id }) => id === sid) + + const now = new Date() + const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000 + existingSession.expiresAt = new Date(now.getTime() + tokenExpInMs) + + await req.payload.db.updateOne({ + id: user.id, + collection: collectionConfig.slug, + data: { + ...user, + sessions: removeExpiredSessions(user.sessions), + }, + req, + returning: false, + }) + } + if (user) { user.collection = args.req.user.collection user._strategy = args.req.user._strategy @@ -103,6 +130,7 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result> const fieldsToSign = getFieldsToSign({ collectionConfig, email: user?.email as string, + sid, user: args?.req?.user, })
packages/payload/src/auth/removeExpiredSessions.ts+10 −0 added@@ -0,0 +1,10 @@ +import type { UserSession } from './types.js' + +export const removeExpiredSessions = (sessions: UserSession[]) => { + const now = new Date() + + return sessions.filter(({ expiresAt }) => { + const expiry = expiresAt instanceof Date ? expiresAt : new Date(expiresAt) + return expiry > now + }) +}
packages/payload/src/auth/strategies/jwt.ts+13 −0 modified@@ -8,6 +8,7 @@ import { extractJWT } from '../extractJWT.js' type JWTToken = { collection: string id: string + sid?: string } async function autoLogin({ @@ -100,6 +101,18 @@ export const JWTAuthentication: AuthStrategyFunction = async ({ })) as AuthStrategyResult['user'] if (user && (!collection!.config.auth.verify || user._verified)) { + if (collection!.config.auth.useSessions) { + const existingSession = (user.sessions || []).find(({ id }) => id === decodedPayload.sid) + + if (!existingSession || !decodedPayload.sid) { + return { + user: null, + } + } + + user._sid = decodedPayload.sid + } + user.collection = collection!.config.slug user._strategy = strategyName return {
packages/payload/src/auth/types.ts+9 −0 modified@@ -118,6 +118,7 @@ type BaseUser = { collection: string email?: string id: number | string + sessions?: Array<UserSession> username?: string } @@ -133,6 +134,7 @@ export type ClientUser = { [key: string]: any } & BaseUser +export type UserSession = { createdAt: Date | string; expiresAt: Date | string; id: string } type GenerateVerifyEmailHTML<TUser = any> = (args: { req: PayloadRequest token: string @@ -277,6 +279,13 @@ export interface IncomingAuthType { * @link https://payloadcms.com/docs/authentication/api-keys */ useAPIKey?: boolean + + /** + * Use sessions for authentication. Enabled by default. + * @default true + */ + useSessions?: boolean + /** * Set to true or pass an object with verification options to require users to verify by email before they are allowed to log into your app. * @link https://payloadcms.com/docs/authentication/email#email-verification
packages/payload/src/collections/config/defaults.ts+2 −0 modified@@ -127,6 +127,7 @@ export const authDefaults: IncomingAuthType = { loginWithUsername: false, maxLoginAttempts: 5, tokenExpiration: 7200, + useSessions: true, verify: false, } @@ -142,6 +143,7 @@ export const addDefaultsToAuthConfig = (auth: IncomingAuthType): IncomingAuthTyp auth.loginWithUsername = auth.loginWithUsername ?? false auth.maxLoginAttempts = auth.maxLoginAttempts ?? 5 auth.tokenExpiration = auth.tokenExpiration ?? 7200 + auth.useSessions = auth.useSessions ?? true auth.verify = auth.verify ?? false auth.strategies = auth.strategies ?? []
packages/payload/src/fields/hooks/afterRead/promise.ts+2 −2 modified@@ -465,7 +465,7 @@ export const promise = async ({ }) } }) - } else { + } else if (field.hidden !== true || showHiddenFields === true) { siblingDoc[field.name] = [] } break @@ -570,7 +570,7 @@ export const promise = async ({ }) } }) - } else { + } else if (field.hidden !== true || showHiddenFields === true) { siblingDoc[field.name] = [] }
packages/payload/src/index.ts+12 −0 modified@@ -138,6 +138,18 @@ import { getLogger } from './utilities/logger.js' import { serverInit as serverInitTelemetry } from './utilities/telemetry/events/serverInit.js' import { traverseFields } from './utilities/traverseFields.js' +/** + * Export of all base fields that could potentially be + * useful as users wish to extend built-in fields with custom logic + */ +export { accountLockFields as baseAccountLockFields } from './auth/baseFields/accountLock.js' +export { apiKeyFields as baseAPIKeyFields } from './auth/baseFields/apiKey.js' +export { baseAuthFields } from './auth/baseFields/auth.js' +export { emailFieldConfig as baseEmailField } from './auth/baseFields/email.js' +export { sessionsFieldConfig as baseSessionsField } from './auth/baseFields/sessions.js' +export { usernameFieldConfig as baseUsernameField } from './auth/baseFields/username.js' + +export { verificationFields as baseVerificationFields } from './auth/baseFields/verification.js' export { executeAccess } from './auth/executeAccess.js' export { executeAuthStrategies } from './auth/executeAuthStrategies.js' export { extractAccessFromPermission } from './auth/extractAccessFromPermission.js'
templates/website/package.json+8 −8 modified@@ -58,27 +58,27 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", + "@playwright/test": "1.50.0", "@tailwindcss/typography": "^0.5.13", + "@testing-library/react": "16.3.0", "@types/escape-html": "^1.0.2", "@types/node": "22.5.4", "@types/react": "19.1.0", "@types/react-dom": "19.1.2", + "@vitejs/plugin-react": "4.5.2", "autoprefixer": "^10.4.19", "copyfiles": "^2.4.1", "eslint": "^9.16.0", "eslint-config-next": "15.3.0", - "postcss": "^8.4.38", - "prettier": "^3.4.2", - "tailwindcss": "^3.4.3", - "@playwright/test": "1.50.0", "jsdom": "26.1.0", - "@testing-library/react": "16.3.0", - "@vitejs/plugin-react": "4.5.2", "playwright": "1.50.0", "playwright-core": "1.50.0", + "postcss": "^8.4.38", + "prettier": "^3.4.2", + "tailwindcss": "^3.4.3", + "typescript": "5.7.3", "vite-tsconfig-paths": "5.1.4", - "vitest": "3.2.3", - "typescript": "5.7.3" + "vitest": "3.2.3" }, "engines": { "node": "^18.20.2 || >=20.9.0",
templates/website/src/app/(payload)/admin/importMap.js+43 −25 modified@@ -25,29 +25,47 @@ import { default as default_1a7510af427896d367a49dbf838d2de6 } from '@/component import { default as default_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin' export const importMap = { - "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, - "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, - "@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, - "@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/plugin-seo/client#OverviewComponent": OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860, - "@payloadcms/plugin-seo/client#MetaTitleComponent": MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860, - "@payloadcms/plugin-seo/client#MetaImageComponent": MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860, - "@payloadcms/plugin-seo/client#MetaDescriptionComponent": MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, - "@payloadcms/plugin-seo/client#PreviewComponent": PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860, - "@/fields/slug/SlugComponent#SlugComponent": SlugComponent_92cc057d0a2abb4f6cf0307edf59f986, - "@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/plugin-search/client#LinkToDoc": LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634, - "@payloadcms/plugin-search/client#ReindexButton": ReindexButton_aead06e4cbf6b2620c5c51c9ab283634, - "@/Header/RowLabel#RowLabel": RowLabel_ec255a65fa6fa8d1faeb09cf35284224, - "@/Footer/RowLabel#RowLabel": RowLabel_1f6ff6ff633e3695d348f4f3c58f1466, - "@/components/BeforeDashboard#default": default_1a7510af427896d367a49dbf838d2de6, - "@/components/BeforeLogin#default": default_8a7ab0eb7ab5c511aba12e68480bfe5e + '@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell': + RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, + '@payloadcms/richtext-lexical/rsc#RscEntryLexicalField': + RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, + '@payloadcms/richtext-lexical/rsc#LexicalDiffComponent': + LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, + '@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient': + InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient': + FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#HeadingFeatureClient': + HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#ParagraphFeatureClient': + ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#UnderlineFeatureClient': + UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#BoldFeatureClient': + BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#ItalicFeatureClient': + ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#LinkFeatureClient': + LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/plugin-seo/client#OverviewComponent': + OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/plugin-seo/client#MetaTitleComponent': + MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/plugin-seo/client#MetaImageComponent': + MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/plugin-seo/client#MetaDescriptionComponent': + MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/plugin-seo/client#PreviewComponent': + PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@/fields/slug/SlugComponent#SlugComponent': SlugComponent_92cc057d0a2abb4f6cf0307edf59f986, + '@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient': + HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#BlocksFeatureClient': + BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/plugin-search/client#LinkToDoc': LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634, + '@payloadcms/plugin-search/client#ReindexButton': ReindexButton_aead06e4cbf6b2620c5c51c9ab283634, + '@/Header/RowLabel#RowLabel': RowLabel_ec255a65fa6fa8d1faeb09cf35284224, + '@/Footer/RowLabel#RowLabel': RowLabel_1f6ff6ff633e3695d348f4f3c58f1466, + '@/components/BeforeDashboard#default': default_1a7510af427896d367a49dbf838d2de6, + '@/components/BeforeLogin#default': default_8a7ab0eb7ab5c511aba12e68480bfe5e, }
test/access-control/payload-types.ts+33 −0 modified@@ -95,6 +95,7 @@ export interface Config { 'auth-collection': AuthCollection; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; + 'payload-sessions': PayloadSession; 'payload-migrations': PayloadMigration; }; collectionsJoins: {}; @@ -125,6 +126,7 @@ export interface Config { 'auth-collection': AuthCollectionSelect<false> | AuthCollectionSelect<true>; 'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>; 'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>; + 'payload-sessions': PayloadSessionsSelect<false> | PayloadSessionsSelect<true>; 'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>; }; db: { @@ -891,6 +893,26 @@ export interface PayloadPreference { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-sessions". + */ +export interface PayloadSession { + id: string; + session: string; + expiration: string; + user: + | { + relationTo: 'users'; + value: string | User; + } + | { + relationTo: 'public-users'; + value: string | PublicUser; + }; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-migrations". @@ -1295,6 +1317,17 @@ export interface PayloadPreferencesSelect<T extends boolean = true> { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-sessions_select". + */ +export interface PayloadSessionsSelect<T extends boolean = true> { + session?: T; + expiration?: T; + user?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-migrations_select".
test/auth/int.spec.ts+214 −1 modified@@ -1,3 +1,4 @@ +/* eslint-disable jest/no-conditional-in-test */ import type { BasePayload, EmailFieldValidation, @@ -737,7 +738,7 @@ describe('Auth', () => { it('should retain fields when auth.disableLocalStrategy.enableFields is true', () => { const authFields = payload.collections[partialDisableLocalStrategiesSlug].config.fields - // eslint-disable-next-line jest/no-conditional-in-test + .filter((field) => 'name' in field && field.name) .map((field) => (field as FieldAffectingData).name) @@ -751,6 +752,7 @@ describe('Auth', () => { 'hash', 'loginAttempts', 'lockUntil', + 'sessions', ]) }) @@ -1051,4 +1053,215 @@ describe('Auth', () => { expect(emailValidation('user,name@example.com', mockContext)).toBe('validation:emailAddress') }) }) + + describe('Sessions', () => { + it('should set a session on a user', async () => { + const authenticated = await payload.login({ + collection: slug, + data: { + email: devUser.email, + password: devUser.password, + }, + }) + + expect(authenticated.token).toBeTruthy() + + const user = await payload.db.find<User>({ + collection: slug, + where: { + id: { + equals: authenticated.user.id, + }, + }, + }) + + expect(Array.isArray(user.docs[0]?.sessions)).toBeTruthy() + + const decoded = jwtDecode<{ sid: string }>(String(authenticated.token)) + + expect(decoded.sid).toBeDefined() + + const matchedSession = user.docs[0]?.sessions?.find(({ id }) => id === decoded.sid) + + expect(matchedSession).toBeDefined() + expect(matchedSession?.createdAt).toBeDefined() + expect(matchedSession?.expiresAt).toBeDefined() + }) + + it('should log out a user and delete only the session being logged out', async () => { + const authenticated = await payload.login({ + collection: slug, + data: { + email: devUser.email, + password: devUser.password, + }, + }) + + const authenticated2 = await payload.login({ + collection: slug, + data: { + email: devUser.email, + password: devUser.password, + }, + }) + + await restClient.POST(`/${slug}/logout`, { + headers: { + Authorization: `JWT ${authenticated.token}`, + }, + }) + + const user = await payload.db.find<User>({ + collection: slug, + where: { + email: { + equals: devUser.email, + }, + }, + }) + + const decoded = jwtDecode<{ sid: string }>(String(authenticated.token)) + expect(decoded.sid).toBeDefined() + + const remainingSessions = user.docs[0]?.sessions ?? [] + + const loggedOutSession = remainingSessions.find(({ id }) => id === decoded.sid) + expect(loggedOutSession).toBeUndefined() + + const decoded2 = jwtDecode<{ sid: string }>(String(authenticated2.token)) + expect(decoded2.sid).toBeDefined() + + const existingSession = remainingSessions.find(({ id }) => id === decoded2.sid) + expect(existingSession?.id).toStrictEqual(decoded2.sid) + }) + + it('should refresh an existing session', async () => { + const authenticated = await payload.login({ + collection: slug, + data: { + email: devUser.email, + password: devUser.password, + }, + }) + + const decoded = jwtDecode<{ sid: string }>(String(authenticated.token)) + + const user = await payload.db.find<User>({ + collection: slug, + where: { + email: { + equals: devUser.email, + }, + }, + }) + + const matchedSession = user.docs[0]?.sessions?.find(({ id }) => id === decoded.sid) + + const refreshed = await restClient + .POST(`/${slug}/refresh-token`, { + headers: { + Authorization: `JWT ${authenticated.token}`, + }, + }) + .then((res) => res.json()) + + const refreshedUser = await payload.db.find<User>({ + collection: slug, + where: { + email: { + equals: devUser.email, + }, + }, + }) + + const decodedRefreshed = jwtDecode<{ sid: string }>(String(refreshed.refreshedToken)) + + const matchedRefreshedSession = refreshedUser.docs[0]?.sessions?.find( + ({ id }) => id === decodedRefreshed.sid, + ) + + expect(decodedRefreshed.sid).toStrictEqual(decoded.sid) + + expect(new Date(matchedSession?.expiresAt as unknown as string).getTime()).toBeLessThan( + new Date(matchedRefreshedSession?.expiresAt as unknown as string).getTime(), + ) + }) + + it('should not authenticate a user who has a JWT but its session has been terminated', async () => { + const authenticated = await payload.login({ + collection: slug, + data: { + email: devUser.email, + password: devUser.password, + }, + }) + + await restClient.POST(`/${slug}/logout?allSessions=true`, { + headers: { + Authorization: `JWT ${authenticated.token}`, + }, + }) + + const user = await payload.db.find<User>({ + collection: slug, + where: { + email: { + equals: devUser.email, + }, + }, + }) + + const remainingSessions = user.docs[0]?.sessions + expect(remainingSessions).toHaveLength(0) + + const meQuery = await restClient + .GET(`/${slug}/me`, { + headers: { + Authorization: `JWT ${authenticated.token}`, + }, + }) + .then((res) => res.json()) + + expect(meQuery.user).toBeNull() + }) + + it('should clean up expired sessions when logging in', async () => { + const userWithExpiredSession = await payload.create({ + collection: slug, + data: { + email: `${devUser.email}.au`, + password: devUser.password, + roles: ['admin'], + sessions: [ + { + id: uuid(), + createdAt: new Date().toDateString(), + expiresAt: new Date(new Date().getTime() - 5000).toDateString(), // Set an expired session + }, + ], + }, + }) + + expect(userWithExpiredSession.sessions).toHaveLength(1) + + await payload.login({ + collection: slug, + data: { + email: devUser.email, + password: devUser.password, + }, + }) + + const user2 = await payload.db.find<User>({ + collection: slug, + where: { + email: { + equals: devUser.email, + }, + }, + }) + + expect(user2.docs[0]?.sessions).toHaveLength(1) + }) + }) })
test/auth/payload-types.ts+36 −0 modified@@ -248,6 +248,11 @@ export interface User { hash?: string | null; loginAttempts?: number | null; lockUntil?: string | null; + sessions: { + id: string; + createdAt?: string | null; + expiresAt: string; + }[]; password?: string | null; } /** @@ -265,6 +270,11 @@ export interface PartialDisableLocalStrategy { hash?: string | null; loginAttempts?: number | null; lockUntil?: string | null; + sessions: { + id: string; + createdAt?: string | null; + expiresAt: string; + }[]; password?: string | null; } /** @@ -306,6 +316,11 @@ export interface PublicUser { _verificationToken?: string | null; loginAttempts?: number | null; lockUntil?: string | null; + sessions: { + id: string; + createdAt?: string | null; + expiresAt: string; + }[]; password?: string | null; } /** @@ -471,6 +486,13 @@ export interface UsersSelect<T extends boolean = true> { hash?: T; loginAttempts?: T; lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -486,6 +508,13 @@ export interface PartialDisableLocalStrategiesSelect<T extends boolean = true> { hash?: T; loginAttempts?: T; lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -523,6 +552,13 @@ export interface PublicUsersSelect<T extends boolean = true> { _verificationToken?: T; loginAttempts?: T; lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; } /** * This interface was referenced by `Config`'s JSON-Schema
test/package.json+3 −0 modified@@ -96,5 +96,8 @@ "ts-essentials": "10.0.3", "typescript": "5.7.3", "uuid": "10.0.0" + }, + "pnpm": { + "neverBuiltDependencies": [] } }
test/server-functions/components/loginFunction.tsx+0 −1 modified@@ -16,7 +16,6 @@ export async function loginFunction({ email, password }: LoginArgs) { email, password, }) - return result } catch (error) { throw new Error(`Login failed: ${error instanceof Error ? error.message : 'Unknown error'}`) }
test/server-functions/components/refreshFunction.tsx+0 −1 modified@@ -7,7 +7,6 @@ import config from '../config.js' export async function refreshFunction() { try { return await refresh({ - collection: 'users', // update this to your collection slug config, }) } catch (error) {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5News mentions
0No linked articles in our index yet.