Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<state1, nonce1>; Path=/

2. Before completing App 1's login, user starts login on App 2
→ Browser stores: auth_verification=<state2, nonce2>; Path=/
→ This overwrites App 1's cookie

3. App 1's IdP redirects to /callback?state=<state1>
→ Browser sends: auth_verification=<state2, nonce2> ← 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:
Expand Down
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ app.use(
clientID: 'YOUR_CLIENT_ID',
secret: 'LONG_RANDOM_STRING',
idpLogout: true,
})
}),
);
```

Expand All @@ -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.
Expand Down Expand Up @@ -147,4 +181,4 @@ Please do not report security vulnerabilities on the public GitHub issue tracker
</p>
<p align="center">
This project is licensed under the MIT license. See the <a href="https://github.com/auth0/express-openid-connect/blob/master/LICENSE"> LICENSE</a> file for more info.
</p>
</p>
5 changes: 4 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -924,7 +924,10 @@ 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`.
*
* To prevent cookie collision when multiple apps are hosted on the same domain
Comment thread
aks96 marked this conversation as resolved.
* (e.g., `example.com/app1` and `example.com/app2`), set this to your app's base path.
*/
path?: string;

Expand Down
36 changes: 35 additions & 1 deletion lib/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,41 @@ class ResponseContext {
res,
);

const checks = authVerification ? JSON.parse(authVerification) : {};
if (!authVerification) {
Comment thread
aks96 marked this conversation as resolved.
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 = JSON.parse(authVerification);

req.openidState = decodeState(checks.state);

Expand Down
97 changes: 83 additions & 14 deletions test/callback.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,21 +154,27 @@ 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 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);
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');

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 () => {
Expand Down Expand Up @@ -274,7 +280,7 @@ describe('callback response_mode: form_post', () => {
assert.match(err.message, /nonce mismatch/i);
});

it('should error when legacy samesite fallback is off', async () => {
it('should error when legacy samesite fallback is off and main cookie missing', async () => {
const {
response: {
statusCode,
Expand All @@ -296,7 +302,7 @@ describe('callback response_mode: form_post', () => {
},
});
assert.equal(statusCode, 400);
assert.equal(err.message, 'checks.state argument is missing');
assert.match(err.message, /"auth_verification" cookie not found/);
});

it('should include oauth error properties in error', async () => {
Expand All @@ -308,8 +314,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',
},
Expand Down Expand Up @@ -1325,4 +1335,63 @@ 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');
});

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 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,
json: {
state: expectedDefaultState,
code: '__test_code__',
},
followRedirect: false,
});

assert.equal(response.statusCode, 400);
assert.match(
response.body.err.message,
/"auth_verification" cookie not found/,
);
});
});
9 changes: 9 additions & 0 deletions test/config.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,15 @@ describe('get config', () => {
});
});

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 fail when the baseURL is http and cookie is secure', function () {
assert.throws(() => {
getConfig({
Expand Down
Loading