This example verifies that @auth0/auth0-express picks up an existing
express-openid-connect session without forcing re-authentication, for both
stateless (cookie) and stateful (Redis) sessions, plus backchannel logout.
before/— the legacy app, built withexpress-openid-connect.after/— the new app, built with@auth0/auth0-expressandlegacyCompatibilityenabled so it reads sessions created bybefore/.shared/— the Redisdocker-compose.ymlused by the stateful scenario.
Start either app from the repo root with npm start --workspace <path>, passing
the folder path shown in the steps below.
Both apps run on http://localhost:3000 (one at a time) and use the appSession
cookie name so the same-browser cookie is picked up across the migration.
- From the repo root:
npm install && npm run build. - A test Auth0 tenant + Regular Web App. In the app settings register:
- Allowed Callback URLs:
http://localhost:3000/callback(express-openid-connect) andhttp://localhost:3000/auth/callback(auth0-express). - Allowed Logout URLs:
http://localhost:3000. - For the backchannel logout test: set the app's Back-Channel Logout URI to
http://localhost:3000/auth/backchannel-logoutand enable Back-Channel Logout.
- Allowed Callback URLs:
- Copy each app's
.env.exampleto.envand fill in tenant values. Use the same session secret in both (SECRETinbefore/,AUTH0_SESSION_SECRETinafter/).
- Start the legacy app (no
REDIS_URLin its.env):npm start --workspace examples/migration-express-openid-connect/before - Open
http://localhost:3000, click Login, complete auth. Confirm home shows your user and anappSessioncookie exists (DevTools → Application → Cookies). Note the Session facts panel: yoursub, the token audience/scope, and that a refresh + id token are present. (The apps intentionally never print the access token itself — it is a bearer secret.) - Stop the legacy app (Ctrl-C).
- Start the new app (no
REDIS_URLin its.env):npm start --workspace examples/migration-express-openid-connect/after - Reload
http://localhost:3000in the same browser. Expected: still logged in — the legacy cookie was decrypted, transformed, and re-encrypted in modern format. Confirm the Session facts match the legacy app's (samesub, same audience/scope, refresh token still present) — this is what proves the session carried over. Confirm/privateis accessible without a new login. - (Optional, strongest proof) With
AUTH0_SECOND_AUDIENCEset to a second API in your tenant, open/refresh-token. It exchanges the carried-over refresh token for a fresh token set — succeeding proves the migrated refresh token is intact — and reports the new token set's audience/scope/expiry (never the token).
- Start Redis:
docker compose -f examples/migration-express-openid-connect/shared/docker-compose.yml up -d. - Set
REDIS_URL=redis://localhost:6379in BOTH apps'.env. - Start the legacy app:
npm start --workspace examples/migration-express-openid-connect/before. Log in. Confirm a session key exists in Redis:docker compose -f examples/migration-express-openid-connect/shared/docker-compose.yml exec redis redis-cli keys '*' - Stop the legacy app. Start the new app:
npm start --workspace examples/migration-express-openid-connect/after. - Reload
http://localhost:3000. Expected: still logged in (migration store read the eoc envelope from Redis, transformed it, and immediately wrote the modernStateDataplus alogout:sid:<sid>index back to the same key — no further action needed). Confirm the index key exists:... redis-cli keys 'logout:sid:*' - Trigger backchannel logout. In production Auth0 posts this automatically on
logout elsewhere; to test locally, POST a real
logout_tokenobtained from your tenant:curl -i -X POST http://localhost:3000/auth/backchannel-logout -H 'Content-Type: application/x-www-form-urlencoded' --data-urlencode "logout_token=<JWT>"Expected:204. Then confirm both the session key and itslogout:sid:<sid>index are gone from Redis. Reloading/shows logged-out.
Scenarios 1 and 2 both start from a fresh login, so they never exercise the state
where the migration logs a user out: a session already older than this SDK's default
absoluteDuration (3 days) but still valid under express-openid-connect (default 7
days). The migration stores keep the original creation time, so
createdAt + absoluteDuration can already be in the past. When it is, the store
treats the session as expired and refuses it on the first read — a migrated
cookie still carries express-openid-connect's own Max-Age, so the browser keeps
sending it, and the store (not the browser) is what enforces this SDK's cap. The
after app sets absoluteDuration: 604800 to avoid this; this scenario proves that
guard is load-bearing.
- Log in with the legacy app (either store) as in Scenario 1 or 2, then stop it.
- Simulate an aged session by lowering the cap below the session's age: start the
afterapp withSESSION_ABSOLUTE_DURATION=1(1 second), then reload in the same browser. Expected: you are logged out on that first reload for both stores — the store computescreatedAt + absoluteDurationin the past and returns no session, sogetUser()is empty and protected routes redirect to login. This is the failure the fix prevents. - Restart the
afterapp without the override (back to the defaultabsoluteDuration: 604800) and repeat with the original legacy cookie/session still present. Expected: still logged in — the aged session is preserved because the cap now exceeds its age.
logout_tokenis a signed JWT issued by Auth0; you cannot hand-craft one that passesverifyLogoutToken. Obtain it from a real logout event (tenant logs / a second app), or observe the automatic POST when logging out from another app in the same session.