next-auth vulnerable to possible user mocking that bypasses basic authentication
Description
NextAuth.js provides authentication for Next.js. next-auth applications prior to version 4.24.5 that rely on the default Middleware authorization are affected by a vulnerability. A bad actor could create an empty/mock user, by getting hold of a NextAuth.js-issued JWT from an interrupted OAuth sign-in flow (state, PKCE or nonce). Manually overriding the next-auth.session-token cookie value with this non-related JWT would let the user simulate a logged in user, albeit having no user information associated with it. (The only property on this user is an opaque randomly generated string). This vulnerability does not give access to other users' data, neither to resources that require proper authorization via scopes or other means. The created mock user has no information associated with it (ie. no name, email, access_token, etc.) This vulnerability can be exploited by bad actors to peek at logged in user states (e.g. dashboard layout). next-auth v4.24.5 contains a patch for the vulnerability. As a workaround, using a custom authorization callback for Middleware, developers can manually do a basic authentication.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
next-authnpm | < 4.24.5 | 4.24.5 |
Affected products
1- Range: < 4.24.5
Patches
1d237059b6d0cfix: differentiate between issued JWTs
3 files changed · +52 −17
packages/next-auth/src/core/lib/oauth/checks.ts+25 −7 modified@@ -21,11 +21,17 @@ export async function signCookie( logger.debug(`CREATE_${type.toUpperCase()}`, { value, maxAge }) + const { name } = cookies[type] const expires = new Date() expires.setTime(expires.getTime() + maxAge * 1000) return { - name: cookies[type].name, - value: await jwt.encode({ ...options.jwt, maxAge, token: { value } }), + name, + value: await jwt.encode({ + ...options.jwt, + maxAge, + token: { value }, + salt: name, + }), options: { ...cookies[type].options, expires }, } } @@ -71,16 +77,18 @@ export const pkce = { if (!codeVerifier) throw new TypeError("PKCE code_verifier cookie was missing.") + const { name } = options.cookies.pkceCodeVerifier const value = (await jwt.decode({ ...options.jwt, token: codeVerifier, + salt: name, })) as any if (!value?.value) throw new TypeError("PKCE code_verifier value could not be parsed.") resCookies.push({ - name: options.cookies.pkceCodeVerifier.name, + name, value: "", options: { ...options.cookies.pkceCodeVerifier.options, maxAge: 0 }, }) @@ -121,12 +129,17 @@ export const state = { if (!state) throw new TypeError("State cookie was missing.") - const value = (await jwt.decode({ ...options.jwt, token: state })) as any + const { name } = options.cookies.state + const value = (await jwt.decode({ + ...options.jwt, + token: state, + salt: name, + })) as any if (!value?.value) throw new TypeError("State value could not be parsed.") resCookies.push({ - name: options.cookies.state.name, + name, value: "", options: { ...options.cookies.state.options, maxAge: 0 }, }) @@ -166,12 +179,17 @@ export const nonce = { const nonce = cookies?.[options.cookies.nonce.name] if (!nonce) throw new TypeError("Nonce cookie was missing.") - const value = (await jwt.decode({ ...options.jwt, token: nonce })) as any + const { name } = options.cookies.nonce + const value = (await jwt.decode({ + ...options.jwt, + token: nonce, + salt: name, + })) as any if (!value?.value) throw new TypeError("Nonce value could not be parsed.") resCookies.push({ - name: options.cookies.nonce.name, + name, value: "", options: { ...options.cookies.nonce.options, maxAge: 0 }, })
packages/next-auth/src/jwt/index.ts+13 −8 modified@@ -15,8 +15,9 @@ const now = () => (Date.now() / 1000) | 0 /** Issues a JWT. By default, the JWT is encrypted using "A256GCM". */ export async function encode(params: JWTEncodeParams) { - const { token = {}, secret, maxAge = DEFAULT_MAX_AGE } = params - const encryptionSecret = await getDerivedEncryptionKey(secret) + /** @note empty `salt` means a session token. See {@link JWTEncodeParams.salt}. */ + const { token = {}, secret, maxAge = DEFAULT_MAX_AGE, salt = "" } = params + const encryptionSecret = await getDerivedEncryptionKey(secret, salt) return await new EncryptJWT(token) .setProtectedHeader({ alg: "dir", enc: "A256GCM" }) .setIssuedAt() @@ -27,9 +28,10 @@ export async function encode(params: JWTEncodeParams) { /** Decodes a NextAuth.js issued JWT. */ export async function decode(params: JWTDecodeParams): Promise<JWT | null> { - const { token, secret } = params + /** @note empty `salt` means a session token. See {@link JWTDecodeParams.salt}. */ + const { token, secret, salt = "" } = params if (!token) return null - const encryptionSecret = await getDerivedEncryptionKey(secret) + const encryptionSecret = await getDerivedEncryptionKey(secret, salt) const { payload } = await jwtDecrypt(token, encryptionSecret, { clockTolerance: 15, }) @@ -116,12 +118,15 @@ export async function getToken<R extends boolean = false>( } } -async function getDerivedEncryptionKey(secret: string | Buffer) { +async function getDerivedEncryptionKey( + keyMaterial: string | Buffer, + salt: string +) { return await hkdf( "sha256", - secret, - "", - "NextAuth.js Generated Encryption Key", + keyMaterial, + salt, + `NextAuth.js Generated Encryption Key${salt ? ` (${salt})` : ""}`, 32 ) }
packages/next-auth/src/jwt/types.ts+14 −2 modified@@ -17,7 +17,13 @@ export interface JWT extends Record<string, unknown>, DefaultJWT {} export interface JWTEncodeParams { /** The JWT payload. */ token?: JWT - /** The secret used to encode the NextAuth.js issued JWT. */ + /** + * Used in combination with `secret` when deriving the encryption secret for the various NextAuth.js-issued JWTs. + * @note When no `salt` is passed, we assume this is a session token. + * This is for backwards-compatibility with currently active sessions, so they won't be invalidated when upgrading the package. + */ + salt?: string + /** The key material used to encode the NextAuth.js issued JWTs. Defaults to `NEXTAUTH_SECRET`. */ secret: string | Buffer /** * The maximum age of the NextAuth.js issued JWT in seconds. @@ -29,7 +35,13 @@ export interface JWTEncodeParams { export interface JWTDecodeParams { /** The NextAuth.js issued JWT to be decoded */ token?: string - /** The secret used to decode the NextAuth.js issued JWT. */ + /** + * Used in combination with `secret` when deriving the encryption secret for the various NextAuth.js-issued JWTs. + * @note When no `salt` is passed, we assume this is a session token. + * This is for backwards-compatibility with currently active sessions, so they won't be invalidated when upgrading the package. + */ + salt?: string + /** The key material used to decode the NextAuth.js issued JWTs. Defaults to `NEXTAUTH_SECRET`. */ secret: string | Buffer }
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
7- github.com/advisories/GHSA-v64w-49xw-qq89ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-48309ghsaADVISORY
- authjs.dev/guides/basics/role-based-access-controlghsax_refsource_MISCWEB
- github.com/nextauthjs/next-auth/commit/d237059b6d0cb868c041ba18b698e0cee20a2f10ghsax_refsource_MISCWEB
- github.com/nextauthjs/next-auth/security/advisories/GHSA-v64w-49xw-qq89ghsax_refsource_CONFIRMWEB
- next-auth.js.org/configuration/nextjsghsax_refsource_MISCWEB
- next-auth.js.org/configuration/nextjsghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.