From a7354a0715023d7b6d4d7891156a19e2495e0652 Mon Sep 17 00:00:00 2001 From: aks96 Date: Thu, 12 Mar 2026 12:54:58 +0530 Subject: [PATCH 1/6] fix:auto-derive cookie path from baseURL --- index.d.ts | 4 +++- lib/config.js | 20 ++++++++++++++++- test/config.tests.js | 52 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/index.d.ts b/index.d.ts index ffea51b7..21a1969c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -848,7 +848,9 @@ interface CookieConfigParams { /** * Path for the cookie. - * Passed to the [Response cookie](https://expressjs.com/en/api.html#res.cookie) as `path` + * Passed to the [Response cookie](https://expressjs.com/en/api.html#res.cookie) as `path`. + * Defaults to the pathname of {@link ConfigParams.baseURL}, which helps prevent cookie collision + * when multiple apps are hosted on the same domain (e.g., `example.com/app1` and `example.com/app2`). */ path?: string; diff --git a/lib/config.js b/lib/config.js index 83c0ec49..0b8f3f85 100644 --- a/lib/config.js +++ b/lib/config.js @@ -90,7 +90,25 @@ const paramsSchema = Joi.object({ 'Cookies set with the `Secure` property wont be attached to http requests', }), }), - path: Joi.string().uri({ relativeOnly: true }).optional(), + path: Joi.string() + .uri({ relativeOnly: true }) + .optional() + .default( + Joi.ref('/baseURL', { + adjust: (baseURL) => { + if (!baseURL || typeof baseURL !== 'string') { + return '/'; + } + try { + const pathname = new URL(baseURL).pathname; + // Ensure path ends without trailing slash (except for root) + return pathname === '/' ? '/' : pathname.replace(/\/$/, ''); + } catch { + return '/'; + } + }, + }), + ), }) .default() .unknown(false), diff --git a/test/config.tests.js b/test/config.tests.js index bfaf2cd9..46cee123 100644 --- a/test/config.tests.js +++ b/test/config.tests.js @@ -143,6 +143,7 @@ describe('get config', () => { httpOnly: true, transient: false, secure: false, + path: '/', }, }); }); @@ -160,6 +161,7 @@ describe('get config', () => { httpOnly: true, transient: false, secure: true, + path: '/', }, }); }); @@ -203,6 +205,7 @@ describe('get config', () => { httpOnly: false, secure: true, sameSite: 'Strict', + path: '/', }, }, }); @@ -299,6 +302,55 @@ describe('get config', () => { }); }); + it('should auto-derive cookie path from baseURL with path', function () { + const config = getConfig({ + ...defaultConfig, + baseURL: 'https://example.com/app1', + }); + assert.equal(config.session.cookie.path, '/app1'); + }); + + it('should auto-derive cookie path from baseURL with nested path', function () { + const config = getConfig({ + ...defaultConfig, + baseURL: 'https://example.com/foo/bar/baz', + }); + assert.equal(config.session.cookie.path, '/foo/bar/baz'); + }); + + it('should auto-derive cookie path as / when baseURL has no path', function () { + const config = getConfig({ + ...defaultConfig, + baseURL: 'https://example.com', + }); + assert.equal(config.session.cookie.path, '/'); + }); + + it('should auto-derive cookie path as / when baseURL has root path', function () { + const config = getConfig({ + ...defaultConfig, + baseURL: 'https://example.com/', + }); + assert.equal(config.session.cookie.path, '/'); + }); + + it('should strip trailing slash from derived cookie path', function () { + const config = getConfig({ + ...defaultConfig, + baseURL: 'https://example.com/app1/', + }); + assert.equal(config.session.cookie.path, '/app1'); + }); + + it('should allow explicit cookie path to override auto-derived path', function () { + const config = getConfig({ + ...defaultConfig, + baseURL: 'https://example.com/app1', + session: { cookie: { path: '/custom' } }, + }); + assert.equal(config.session.cookie.path, '/custom'); + }); + it('should fail when the baseURL is http and cookie is secure', function () { assert.throws(() => { getConfig({ From 45cd5b109e8837707b2a06f54d2203f238252ae0 Mon Sep 17 00:00:00 2001 From: aks96 Date: Tue, 24 Mar 2026 15:57:02 +0530 Subject: [PATCH 2/6] comments addressed --- index.d.ts | 5 +++-- lib/config.js | 20 +------------------ test/config.tests.js | 47 ++------------------------------------------ 3 files changed, 6 insertions(+), 66 deletions(-) diff --git a/index.d.ts b/index.d.ts index 21a1969c..3c2fc0aa 100644 --- a/index.d.ts +++ b/index.d.ts @@ -849,8 +849,9 @@ interface CookieConfigParams { /** * Path for the cookie. * Passed to the [Response cookie](https://expressjs.com/en/api.html#res.cookie) as `path`. - * Defaults to the pathname of {@link ConfigParams.baseURL}, which helps prevent cookie collision - * when multiple apps are hosted on the same domain (e.g., `example.com/app1` and `example.com/app2`). + * + * To prevent cookie collision when multiple apps are hosted on the same domain + * (e.g., `example.com/app1` and `example.com/app2`), set this to your app's base path. */ path?: string; diff --git a/lib/config.js b/lib/config.js index 0b8f3f85..83c0ec49 100644 --- a/lib/config.js +++ b/lib/config.js @@ -90,25 +90,7 @@ const paramsSchema = Joi.object({ 'Cookies set with the `Secure` property wont be attached to http requests', }), }), - path: Joi.string() - .uri({ relativeOnly: true }) - .optional() - .default( - Joi.ref('/baseURL', { - adjust: (baseURL) => { - if (!baseURL || typeof baseURL !== 'string') { - return '/'; - } - try { - const pathname = new URL(baseURL).pathname; - // Ensure path ends without trailing slash (except for root) - return pathname === '/' ? '/' : pathname.replace(/\/$/, ''); - } catch { - return '/'; - } - }, - }), - ), + path: Joi.string().uri({ relativeOnly: true }).optional(), }) .default() .unknown(false), diff --git a/test/config.tests.js b/test/config.tests.js index 46cee123..8ad469ab 100644 --- a/test/config.tests.js +++ b/test/config.tests.js @@ -143,7 +143,6 @@ describe('get config', () => { httpOnly: true, transient: false, secure: false, - path: '/', }, }); }); @@ -161,7 +160,6 @@ describe('get config', () => { httpOnly: true, transient: false, secure: true, - path: '/', }, }); }); @@ -205,7 +203,6 @@ describe('get config', () => { httpOnly: false, secure: true, sameSite: 'Strict', - path: '/', }, }, }); @@ -302,55 +299,15 @@ describe('get config', () => { }); }); - it('should auto-derive cookie path from baseURL with path', function () { + it('should allow setting custom cookie path to prevent collision on same domain', function () { const config = getConfig({ ...defaultConfig, baseURL: 'https://example.com/app1', + session: { cookie: { path: '/app1' } }, }); assert.equal(config.session.cookie.path, '/app1'); }); - it('should auto-derive cookie path from baseURL with nested path', function () { - const config = getConfig({ - ...defaultConfig, - baseURL: 'https://example.com/foo/bar/baz', - }); - assert.equal(config.session.cookie.path, '/foo/bar/baz'); - }); - - it('should auto-derive cookie path as / when baseURL has no path', function () { - const config = getConfig({ - ...defaultConfig, - baseURL: 'https://example.com', - }); - assert.equal(config.session.cookie.path, '/'); - }); - - it('should auto-derive cookie path as / when baseURL has root path', function () { - const config = getConfig({ - ...defaultConfig, - baseURL: 'https://example.com/', - }); - assert.equal(config.session.cookie.path, '/'); - }); - - it('should strip trailing slash from derived cookie path', function () { - const config = getConfig({ - ...defaultConfig, - baseURL: 'https://example.com/app1/', - }); - assert.equal(config.session.cookie.path, '/app1'); - }); - - it('should allow explicit cookie path to override auto-derived path', function () { - const config = getConfig({ - ...defaultConfig, - baseURL: 'https://example.com/app1', - session: { cookie: { path: '/custom' } }, - }); - assert.equal(config.session.cookie.path, '/custom'); - }); - it('should fail when the baseURL is http and cookie is secure', function () { assert.throws(() => { getConfig({ From 47da70eb5b2c5ab9e16e310371a9fbeed2dda4b8 Mon Sep 17 00:00:00 2001 From: aks96 Date: Wed, 25 Mar 2026 14:42:37 +0530 Subject: [PATCH 3/6] added handle click backbutton --- lib/context.js | 9 +++++++++ test/callback.tests.js | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/lib/context.js b/lib/context.js index 6d01265c..1f1578d2 100644 --- a/lib/context.js +++ b/lib/context.js @@ -380,6 +380,15 @@ class ResponseContext { res, ); + // Handle stale callback (e.g., user pressed back button after successful login) + // If no auth verification cookie and user is already authenticated, redirect to baseURL + if (!authVerification && req.oidc.isAuthenticated()) { + debug( + 'stale callback detected (no auth verification cookie), user already authenticated, redirecting to baseURL', + ); + return res.redirect(config.baseURL); + } + const checks = authVerification ? JSON.parse(authVerification) : {}; req.openidState = decodeState(checks.state); diff --git a/test/callback.tests.js b/test/callback.tests.js index d560c901..bc086096 100644 --- a/test/callback.tests.js +++ b/test/callback.tests.js @@ -1325,4 +1325,38 @@ describe('callback response_mode: form_post', () => { }); assert.equal(headers.foo, 'bar'); }); + + it('should redirect to baseURL on stale callback when user is already authenticated (back button scenario)', async () => { + const jar = request.jar(); + const authOpts = { ...defaultConfig }; + const router = auth(authOpts); + server = await createServer(router); + + // First, establish a session by setting up an existing authenticated session + await request.post('/session', { + baseUrl, + jar, + json: { + id_token: makeIdToken(), + access_token: '__test_access_token__', + token_type: 'Bearer', + expires_at: Math.floor(Date.now() / 1000) + 86400, + }, + }); + + // Now simulate a stale callback (back button) - no auth_verification cookie, but user is authenticated + const response = await request.post('/callback', { + baseUrl, + jar, + json: { + state: expectedDefaultState, + code: '__test_code__', + }, + followRedirect: false, + }); + + // Should redirect to baseURL instead of throwing an error + assert.equal(response.statusCode, 302); + assert.equal(response.headers.location, 'http://example.org'); + }); }); From f03e703cb8862fa221af1f707b5290c91c0e36f7 Mon Sep 17 00:00:00 2001 From: aks96 Date: Sat, 28 Mar 2026 13:35:33 +0530 Subject: [PATCH 4/6] added early detection --- lib/context.js | 10 ++-- test/callback.tests.js | 110 ++++++++++++++++++++++++++++++----------- 2 files changed, 86 insertions(+), 34 deletions(-) diff --git a/lib/context.js b/lib/context.js index 1f1578d2..83cdc5c6 100644 --- a/lib/context.js +++ b/lib/context.js @@ -380,11 +380,13 @@ class ResponseContext { res, ); - // Handle stale callback (e.g., user pressed back button after successful login) - // If no auth verification cookie and user is already authenticated, redirect to baseURL - if (!authVerification && req.oidc.isAuthenticated()) { + // Handle stale/replayed callback (e.g., user pressed back button, or cookie was lost) + // If no auth verification cookie, redirect to baseURL instead of throwing an error. + // If user is authenticated, they'll land on the home page. + // If not authenticated, authRequired middleware will redirect to login if needed. + if (!authVerification) { debug( - 'stale callback detected (no auth verification cookie), user already authenticated, redirecting to baseURL', + 'stale/replayed callback detected (no auth verification cookie), redirecting to baseURL', ); return res.redirect(config.baseURL); } diff --git a/test/callback.tests.js b/test/callback.tests.js index bc086096..1280c628 100644 --- a/test/callback.tests.js +++ b/test/callback.tests.js @@ -154,21 +154,25 @@ describe('callback response_mode: form_post', () => { assert.equal(err.message, 'state missing from the response'); }); - it('should error when the state is missing', async () => { - const { - response: { - statusCode, - body: { err }, - }, - } = await setup({ - cookies: {}, - body: { + it('should redirect to baseURL when the state cookie is missing (stale callback)', async () => { + const jar = request.jar(); + const authOpts = { ...defaultConfig }; + const router = auth(authOpts); + server = await createServer(router); + + const response = await request.post('/callback', { + baseUrl, + jar, + json: { state: '__test_state__', id_token: '__invalid_token__', }, + followRedirect: false, }); - assert.equal(statusCode, 400); - assert.equal(err.message, 'checks.state argument is missing'); + + // Should redirect to baseURL instead of throwing an error + assert.equal(response.statusCode, 302); + assert.equal(response.headers.location, 'http://example.org'); }); it("should error when state doesn't match", async () => { @@ -274,29 +278,49 @@ describe('callback response_mode: form_post', () => { assert.match(err.message, /nonce mismatch/i); }); - it('should error when legacy samesite fallback is off', async () => { - const { - response: { - statusCode, - body: { err }, - }, - } = await setup({ - authOpts: { - // Do not check the fallback cookie value. - legacySameSiteCookie: false, - }, - cookies: { - ['_auth_verification']: JSON.stringify({ - state: '__test_state__', - }), + it('should redirect to baseURL when legacy samesite fallback is off and main cookie missing', async () => { + const jar = request.jar(); + const authOpts = { + ...defaultConfig, + // Do not check the fallback cookie value. + legacySameSiteCookie: false, + }; + const router = auth(authOpts); + server = await createServer(router); + + // Only set the legacy fallback cookie (with underscore prefix), not the main cookie + const transient = new TransientCookieHandler(authOpts); + let cookieValue; + transient.store( + '_auth_verification', + {}, + { + cookie(key, ...args) { + if (key === '_auth_verification') { + cookieValue = args[0]; + } + }, }, - body: { + { value: JSON.stringify({ state: '__test_state__' }) }, + ); + jar.setCookie( + `_auth_verification=${cookieValue}; Max-Age=3600; Path=/; HttpOnly;`, + baseUrl + '/callback', + ); + + const response = await request.post('/callback', { + baseUrl, + jar, + json: { state: '__test_state__', id_token: '__invalid_token__', }, + followRedirect: false, }); - assert.equal(statusCode, 400); - assert.equal(err.message, 'checks.state argument is missing'); + + // Should redirect to baseURL since main cookie is missing and legacy fallback is off + assert.equal(response.statusCode, 302); + assert.equal(response.headers.location, 'http://example.org'); }); it('should include oauth error properties in error', async () => { @@ -308,8 +332,12 @@ describe('callback response_mode: form_post', () => { }, }, } = await setup({ - cookies: {}, + cookies: generateCookies({ + nonce: '__test_nonce__', + state: '__test_state__', + }), body: { + state: '__test_state__', error: 'foo', error_description: 'bar', }, @@ -1359,4 +1387,26 @@ describe('callback response_mode: form_post', () => { assert.equal(response.statusCode, 302); assert.equal(response.headers.location, 'http://example.org'); }); + + it('should redirect to baseURL on stale callback when user is not authenticated (cookie lost scenario)', async () => { + const jar = request.jar(); + const authOpts = { ...defaultConfig, authRequired: false }; + const router = auth(authOpts); + server = await createServer(router); + + // Simulate a stale/replayed callback - no auth_verification cookie, user not authenticated + const response = await request.post('/callback', { + baseUrl, + jar, + json: { + state: expectedDefaultState, + code: '__test_code__', + }, + followRedirect: false, + }); + + // Should redirect to baseURL instead of throwing an error + assert.equal(response.statusCode, 302); + assert.equal(response.headers.location, 'http://example.org'); + }); }); From 3232e5ac4b01d6a82f6b57e2ceaedb1f69922ba3 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 13 Apr 2026 13:41:42 +0530 Subject: [PATCH 5/6] handle undefined auth_verification cookie for unauthenticated users --- lib/context.js | 41 +++++++++++++++++----- test/callback.tests.js | 79 +++++++++++++++++------------------------- 2 files changed, 64 insertions(+), 56 deletions(-) diff --git a/lib/context.js b/lib/context.js index 83cdc5c6..e5ee4a32 100644 --- a/lib/context.js +++ b/lib/context.js @@ -380,18 +380,41 @@ class ResponseContext { res, ); - // Handle stale/replayed callback (e.g., user pressed back button, or cookie was lost) - // If no auth verification cookie, redirect to baseURL instead of throwing an error. - // If user is authenticated, they'll land on the home page. - // If not authenticated, authRequired middleware will redirect to login if needed. if (!authVerification) { - debug( - 'stale/replayed callback detected (no auth verification cookie), redirecting to baseURL', - ); - return res.redirect(config.baseURL); + if (req.oidc.isAuthenticated()) { + // User already has a valid session — this is a stale/replayed callback + // (e.g., browser back button navigated back to a consumed /callback URL). + debug( + 'stale callback detected, user already authenticated, redirecting to baseURL', + ); + return res.redirect(config.baseURL); + } else { + /* + * The transaction cookie is missing for an unauthenticated user. Possible causes: + * 1. A request was made directly to the callback URL without going through the + * login route first — no cookie was ever set. + * 2. The browser dropped the SameSite=None cookie during the IdP redirect + * (common in Safari/ITP, privacy mode, or browsers that reject SameSite=None + * without a Secure flag on non-HTTPS origins). + * 3. legacySameSiteCookie is false and the browser does not support SameSite=None, + * so neither the primary nor the fallback cookie was sent back. + * 4. Multiple apps share the same transactionCookie.name and cookie path on the + * same domain — a second app's login flow overwrote this app's cookie before + * the callback fired. Set a unique transactionCookie.name and session.cookie.path + * per app to isolate them. + */ + throw new Error( + `"${config.transactionCookie.name}" cookie not found. ` + + `Ensure the login flow is initiated through the SDK's login route before ` + + `the callback is processed. If using SameSite=None cookies, verify the ` + + `origin is served over HTTPS and the Secure flag is set. For multi-app ` + + `deployments on the same domain, configure a unique transactionCookie.name ` + + `and session.cookie.path per application.`, + ); + } } - const checks = authVerification ? JSON.parse(authVerification) : {}; + const checks = JSON.parse(authVerification); req.openidState = decodeState(checks.state); diff --git a/test/callback.tests.js b/test/callback.tests.js index 1280c628..c7c1cdde 100644 --- a/test/callback.tests.js +++ b/test/callback.tests.js @@ -154,7 +154,7 @@ describe('callback response_mode: form_post', () => { assert.equal(err.message, 'state missing from the response'); }); - it('should redirect to baseURL when the state cookie is missing (stale callback)', async () => { + it('should error with descriptive message when the state cookie is missing and user is not authenticated', async () => { const jar = request.jar(); const authOpts = { ...defaultConfig }; const router = auth(authOpts); @@ -170,9 +170,11 @@ describe('callback response_mode: form_post', () => { followRedirect: false, }); - // Should redirect to baseURL instead of throwing an error - assert.equal(response.statusCode, 302); - assert.equal(response.headers.location, 'http://example.org'); + assert.equal(response.statusCode, 400); + assert.match( + response.body.err.message, + /"auth_verification" cookie not found/, + ); }); it("should error when state doesn't match", async () => { @@ -278,49 +280,29 @@ describe('callback response_mode: form_post', () => { assert.match(err.message, /nonce mismatch/i); }); - it('should redirect to baseURL when legacy samesite fallback is off and main cookie missing', async () => { - const jar = request.jar(); - const authOpts = { - ...defaultConfig, - // Do not check the fallback cookie value. - legacySameSiteCookie: false, - }; - const router = auth(authOpts); - server = await createServer(router); - - // Only set the legacy fallback cookie (with underscore prefix), not the main cookie - const transient = new TransientCookieHandler(authOpts); - let cookieValue; - transient.store( - '_auth_verification', - {}, - { - cookie(key, ...args) { - if (key === '_auth_verification') { - cookieValue = args[0]; - } - }, + it('should error when legacy samesite fallback is off and main cookie missing', async () => { + const { + response: { + statusCode, + body: { err }, }, - { value: JSON.stringify({ state: '__test_state__' }) }, - ); - jar.setCookie( - `_auth_verification=${cookieValue}; Max-Age=3600; Path=/; HttpOnly;`, - baseUrl + '/callback', - ); - - const response = await request.post('/callback', { - baseUrl, - jar, - json: { + } = await setup({ + authOpts: { + // Do not check the fallback cookie value. + legacySameSiteCookie: false, + }, + cookies: { + ['_auth_verification']: JSON.stringify({ + state: '__test_state__', + }), + }, + body: { state: '__test_state__', id_token: '__invalid_token__', }, - followRedirect: false, }); - - // Should redirect to baseURL since main cookie is missing and legacy fallback is off - assert.equal(response.statusCode, 302); - assert.equal(response.headers.location, 'http://example.org'); + assert.equal(statusCode, 400); + assert.match(err.message, /"auth_verification" cookie not found/); }); it('should include oauth error properties in error', async () => { @@ -1388,13 +1370,14 @@ describe('callback response_mode: form_post', () => { assert.equal(response.headers.location, 'http://example.org'); }); - it('should redirect to baseURL on stale callback when user is not authenticated (cookie lost scenario)', async () => { + it('should error with descriptive message on callback when user is not authenticated and cookie is missing (dropped cookie scenario)', async () => { const jar = request.jar(); const authOpts = { ...defaultConfig, authRequired: false }; const router = auth(authOpts); server = await createServer(router); - // Simulate a stale/replayed callback - no auth_verification cookie, user not authenticated + // Simulate a callback with no auth_verification cookie and user not authenticated + // (e.g., SameSite=None cookie dropped by browser, or direct navigation to /callback) const response = await request.post('/callback', { baseUrl, jar, @@ -1405,8 +1388,10 @@ describe('callback response_mode: form_post', () => { followRedirect: false, }); - // Should redirect to baseURL instead of throwing an error - assert.equal(response.statusCode, 302); - assert.equal(response.headers.location, 'http://example.org'); + assert.equal(response.statusCode, 400); + assert.match( + response.body.err.message, + /"auth_verification" cookie not found/, + ); }); }); From 68238e0fa75c6c0a0c27595022509e4ae6388f06 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 13 Apr 2026 15:23:32 +0530 Subject: [PATCH 6/6] add FAQs section for missing auth verification cookie and update readme for deploying multiple apps on same domain --- FAQ.md | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 38 ++++++++++++++++++++++-- 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/FAQ.md b/FAQ.md index 32d71a5a..1fe6d189 100644 --- a/FAQ.md +++ b/FAQ.md @@ -12,6 +12,95 @@ This should not be an issue in production, because your application will be runn To resolve this, you should [run your local development server over https](https://auth0.com/docs/libraries/secure-local-development). +## I'm getting `"auth_verification" cookie not found` — login fails intermittently or only on certain browsers + +This error means the SDK's transaction cookie was absent when the OAuth callback was processed. The transaction cookie (`auth_verification` by default) is a short-lived, one-time-use cookie that the SDK sets at the start of every login flow and consumes at the callback. When it is missing, the SDK cannot verify the `state` and `nonce` values agreed upon at login. + +There are four distinct root causes that produce this error. Identify which one applies to your setup: + +### 1. The callback URL was opened directly without going through `/login` first + +If a user (or a test script) navigates directly to `/callback?code=...&state=...` without first hitting the login route, the transaction cookie was never set. + +**Fix:** Always initiate login through the SDK's `/login` route. Do not construct or bookmark callback URLs manually. + +--- + +### 2. The app is running over HTTP, not HTTPS, and the browser dropped the cookie + +The transaction cookie is set with `SameSite=None; Secure` because the OAuth callback is a cross-site redirect from your Identity Provider back to your app. Browsers require the `Secure` flag to honour `SameSite=None`, and `Secure` cookies are only sent over HTTPS. On an HTTP origin the browser silently discards the cookie before it is ever stored — so by the time the callback fires, there is nothing to read. + +This could be the most common cause in **local development**. + +**Fix:** Run your local server over HTTPS. See [Secure Local Development](https://auth0.com/docs/libraries/secure-local-development) for a step-by-step guide using a trusted local certificate. + +Alternatively, for local development only, you can switch to `response_type: 'code'` with `response_mode: 'query'`, which allows the SDK to use `SameSite=Lax` instead of `SameSite=None`. + +--- + +### 3. `legacySameSiteCookie` is set to `false` and the user's browser mishandles `SameSite=None` + +By default, the SDK sets two cookies on every login: the primary `auth_verification` cookie (`SameSite=None; Secure`) for modern browsers, and a fallback `_auth_verification` cookie (no `SameSite` attribute) for older browsers that incorrectly treat `SameSite=None` as `SameSite=Strict` — notably Safari 12 and Chrome versions before 80. + +When you opt out of this fallback by setting `legacySameSiteCookie: false`, users on those older browsers will have their primary cookie blocked on the cross-site return from the Identity Provider, with no fallback to recover from. + +**Fix:** Leave `legacySameSiteCookie` at its default (`true`) unless you are certain your entire user base is on browsers that correctly implement `SameSite=None`. + +--- + +### 4. Multiple apps on the same domain are overwriting each other's transaction cookie + +This is the most subtle cause and the one most likely to appear intermittently in production. When two or more applications that use this SDK are hosted on the same domain with different paths (e.g. `example.com/app1` and `example.com/app2`), they share the same cookie namespace by default. If a user starts a login flow on App 1 and then starts one on App 2 before completing App 1's callback, App 2's `Set-Cookie: auth_verification=...` overwrites App 1's cookie in the browser. When App 1's callback eventually fires it finds the wrong cookie — or no cookie at all if App 2's callback already consumed it. + +**Example of the collision:** + +``` +1. User starts login on App 1 + → Browser stores: auth_verification=; Path=/ + +2. Before completing App 1's login, user starts login on App 2 + → Browser stores: auth_verification=; Path=/ + → This overwrites App 1's cookie + +3. App 1's IdP redirects to /callback?state= + → Browser sends: auth_verification= ← wrong cookie + → SDK finds state mismatch, or cookie was already consumed → error +``` + +**Fix:** Give each application a unique cookie name and a scoped cookie path so their cookies do not collide. + +```js +// App 1 — mounted at /app1 +app.use( + '/app1', + auth({ + baseURL: 'https://example.com/app1', + session: { + name: 'app1Session', + cookie: { path: '/app1' }, + }, + transactionCookie: { name: 'app1_auth_verification' }, + }), +); + +// App 2 — mounted at /app2 +app.use( + '/app2', + auth({ + baseURL: 'https://example.com/app2', + session: { + name: 'app2Session', + cookie: { path: '/app2' }, + }, + transactionCookie: { name: 'app2_auth_verification' }, + }), +); +``` + +Setting both `session.cookie.path` and `transactionCookie.name` is recommended. The path scopes the cookies so the browser only sends each app's cookie to requests under its own path, and the unique name ensures that even if the paths overlap, the cookies cannot silently overwrite each other. + +--- + ## Login calls are failing with 'RequestError: The "listener" argument must be of type function. Received an instance of Object' This module depends indirectly on a newer version of the `agent-base` module. If an unrelated module depends on a version of the `agent-base` older than 5.0, that older dependency is monkeypatching the global `http.request` object, causing this module to fail. You can check if you have this problem by running this check: diff --git a/README.md b/README.md index 0e0615f4..5f1e592d 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ app.use( clientID: 'YOUR_CLIENT_ID', secret: 'LONG_RANDOM_STRING', idpLogout: true, - }) + }), ); ``` @@ -96,6 +96,40 @@ For other comprehensive examples such as route-specific authentication, custom a See the [examples](https://github.com/auth0/express-openid-connect/blob/master/EXAMPLES.md) for route-specific authentication, custom application session handling, requesting and using access tokens for external APIs, and more. +### Deploying Multiple Apps on the Same Domain + +If you host more than one application that uses this SDK under the same domain (e.g. `example.com/app1` and `example.com/app2`), each app must be given a unique session cookie name, transaction cookie name, and cookie path. Without this, the apps share the same cookie namespace and can silently overwrite each other's login state, causing intermittent `"auth_verification" cookie not found` errors. + +```js +// App 1 — mounted at /app1 +app.use( + '/app1', + auth({ + baseURL: 'https://example.com/app1', + session: { + name: 'app1Session', + cookie: { path: '/app1' }, + }, + transactionCookie: { name: 'app1_auth_verification' }, + }), +); + +// App 2 — mounted at /app2 +app.use( + '/app2', + auth({ + baseURL: 'https://example.com/app2', + session: { + name: 'app2Session', + cookie: { path: '/app2' }, + }, + transactionCookie: { name: 'app2_auth_verification' }, + }), +); +``` + +See the [FAQ](https://github.com/auth0/express-openid-connect/blob/master/FAQ.md#im-getting-auth_verification-cookie-not-found--login-fails-intermittently-or-only-on-certain-browsers) for a full explanation of the cookie collision mechanism and other causes of the same error. + ### Use of Custom Session Stores and `genid` If you create your own session id when using [Custom Session Stores](https://github.com/auth0/express-openid-connect/blob/master/EXAMPLES.md#9-use-a-custom-session-store) by overriding the `genid` configuration, you must use a suitable cryptographically strong random value of sufficient size to prevent collisions and reduce the ability to hijack a session by guessing the session ID. @@ -147,4 +181,4 @@ Please do not report security vulnerabilities on the public GitHub issue tracker

This project is licensed under the MIT license. See the LICENSE file for more info. -

\ No newline at end of file +