Repository: auth0/nextjs-auth0
Pull request #787 (2022-08-24 15:59:46)

[SDK-3557] Refactor session lifecycle
- [x] All new/changed/fixed functionality is covered by tests (or N/A)
- [x] I have added documentation for all new/changed functionality (or N/A)

### 📋 Changes

Changed the cookie writing behaviour from lazy (at the end of the request) to eager (when it's first accessed and the session is rolling and every-time it's updated).

This has enabled us to:
- remove `on-headers` dependency (which doesn't play well with Next.js server and doesn't work at all with Next.js middleware)
- remove wrappers (`getServerSideProps` wrapper and removed the need for an API route wrapper)
- removed the magic about implicit session updates by adding an explicit `updateUser` method
- allowed users to prevent the cookie from being written on every request when the session is absolute (not rolling) - which can be an issue for specific implementations and platforms

At a cost of potentially higher overhead when updating the session multiple times in the request, because multiple calls to `updateUser` will cause multiple cookie serialisation (encryption) attempts (but multiple reads will stay the same).

In order to do this we've needed to:
- Make a stateful Cookie class to manage/aggregate writes to the `set-cookie` header
- Add an `updateUser` method
- Update the session cache to eagerly write to the cookie, rather than use `on-headers`

### 📎 References

see #668 #425 #616 #524

### 🎯 Testing

https://github.com/auth0/nextjs-auth0/blob/54cd48f1e1cb78eb37b700f39f4da826a5683c1a/tests/session/update-user.test.ts


Patch:
diff --git a/FAQ.md b/FAQ.md
index fd931db2..9b4c08e6 100644
--- a/FAQ.md
+++ b/FAQ.md
@@ -2,7 +2,6 @@
 
 1. [Why do I get a `checks.state argument is missing` error when logging in from different tabs?](#1-why-do-i-get-a-checks.state-argument-is-missing-error-if-i-try-to-log-in-from-different-tabs)
 2. [How can I reduce the cookie size?](#2-how-can-i-reduce-the-cookie-size)
-3. [I'm getting the warning/error `You should not access 'res' after getServerSideProps resolves.`](#3-i-m-getting-the-warning-error--you-should-not-access--res--after-getserversideprops-resolves.)
 
 ## 1. Why do I get a `checks.state argument is missing` error if I try to log in from different tabs?
 
@@ -64,11 +63,3 @@ export default async function MyHandler(req, res) {
 ```
 
 > Note: support for custom session stores [is in our roadmap](https://github.com/auth0/nextjs-auth0/issues/279).
-
-## 3. I'm getting the warning/error `You should not access 'res' after getServerSideProps resolves.`
-
-Because this SDK provides a rolling session by default, it writes to the header at the end of every request. This can cause the above warning when you use `getSession` or `getAccessToken` in >=Next.js 12, and an error if your `props` are defined as a `Promise`.
-
-Wrapping your `getServerSideProps` in `getServerSidePropsWrapper` will fix this because it will constrain the lifecycle of the session to the life of `getServerSideProps`.
-
-> Note: you should not use this if you are already using `withPageAuthenticationRequired` since this should already constrain the lifecycle of the session.
diff --git a/README.md b/README.md
index cb954d9d..26b07034 100644
--- a/README.md
+++ b/README.md
@@ -183,8 +183,8 @@ For other comprehensive examples, see the [EXAMPLES.md](./EXAMPLES.md) document.
 - [handleProfile](https://auth0.github.io/nextjs-auth0/modules/handlers_profile.html)
 - [withApiAuthRequired](https://auth0.github.io/nextjs-auth0/modules/helpers_with_api_auth_required.html)
 - [withPageAuthRequired](https://auth0.github.io/nextjs-auth0/modules/helpers_with_page_auth_required.html#withpageauthrequired)
-- [getServerSidePropsWrapper](https://auth0.github.io/nextjs-auth0/modules/helpers_get_server_side_props_wrapper.html)
 - [getSession](https://auth0.github.io/nextjs-auth0/modules/session_get_session.html)
+- [updateUser](https://auth0.github.io/nextjs-auth0/modules/session_update_user.html)
 - [getAccessToken](https://auth0.github.io/nextjs-auth0/modules/session_get_access_token.html)
 - [initAuth0](https://auth0.github.io/nextjs-auth0/modules/instance.html)
 
diff --git a/V2_MIGRATION_GUIDE.md b/V2_MIGRATION_GUIDE.md
new file mode 100644
index 00000000..61f11d89
--- /dev/null
+++ b/V2_MIGRATION_GUIDE.md
@@ -0,0 +1,74 @@
+# V2 Migration Guide
+
+Guide to migrating from `1.x` to `2.x`
+
+- [`updateUser` has been added](#updateuser-has-been-added)
+- [`getServerSidePropsWrapper` has been removed](#getserversidepropswrapper-has-been-removed)
+
+## `updateUser` has been added
+
+### Before
+
+Previously your application could make modifications to the session during the lifecycle of the request and those updates would be saved implicitly when the response's headers were written, just before delivering the response to the client.
+
+```js
+// /pages/api/update-user
+import { getSession } from '@auth0/nextjs-auth0';
+
+function myApiRoute(req, res) {
+  const { user } = getSession(req, res);
+  user.foo = 'bar';
+  res.json({ success: true });
+}
+// The updated session is serialized and the cookie is updated
+// when the cookie headers are written to the response.
+```
+
+### After
+
+We've introduced a new `updateUser` method which must be explicitly invoked in order to update the session's user.
+
+This will immediately serialise the session and write it to the cookie.
+
+```js
+// /pages/api/update-user
+import { getSession, updateUser } from '@auth0/nextjs-auth0';
+
+function myApiRoute(req, res) {
+  const { user } = getSession(req, res);
+  // The session is updated, serialized and the cookie is updated
+  // everytime you call `updateUser`.
+  updateUser(req, res, { ...user, foo: 'bar' });
+  res.json({ success: true });
+}
+```
+
+## `getServerSidePropsWrapper` has been removed
+
+Because the process of modifying the session is now explicit, you no longer have to wrap `getServerSideProps` in `getServerSidePropsWrapper`.
+
+### Before
+
+```js
+export const getServerSideProps = getServerSidePropsWrapper(async (ctx) => {
+  const session = getSession(ctx.req, ctx.res);
+  if (session) {
+    // User is authenticated
+  } else {
+    // User is not authenticated
+  }
+});
+```
+
+### After
+
+```js
+export const getServerSideProps = async (ctx) => {
+  const session = getSession(ctx.req, ctx.res);
+  if (session) {
+    // User is authenticated
+  } else {
+    // User is not authenticated
+  }
+};
+```
diff --git a/package-lock.json b/package-lock.json
index b2bb142e..1bc44e27 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,7 +16,6 @@
         "http-errors": "^1.8.1",
         "joi": "^17.6.0",
         "jose": "^2.0.5",
-        "on-headers": "^1.0.2",
         "openid-client": "^4.9.1",
         "tslib": "^2.4.0",
         "url-join": "^4.0.1"
@@ -56,6 +55,7 @@
         "next": "^12.1.6",
         "nock": "^13.0.5",
         "oidc-provider": "^7.6.0",
+        "on-headers": "^1.0.2",
         "prettier": "^2.2.1",
         "react": "^17.0.2",
         "react-dom": "^17.0.2",
@@ -10408,6 +10408,7 @@
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
       "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
+      "dev": true,
       "engines": {
         "node": ">= 0.8"
       }
@@ -21386,7 +21387,8 @@
     "on-headers": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
-      "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA=="
+      "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
+      "dev": true
     },
     "once": {
       "version": "1.4.0",
diff --git a/package.json b/package.json
index 65071e9f..f3b5ad9d 100644
--- a/package.json
+++ b/package.json
@@ -93,6 +93,7 @@
     "next": "^12.1.6",
     "nock": "^13.0.5",
     "oidc-provider": "^7.6.0",
+    "on-headers": "^1.0.2",
     "prettier": "^2.2.1",
     "react": "^17.0.2",
     "react-dom": "^17.0.2",
@@ -113,7 +114,6 @@
     "http-errors": "^1.8.1",
     "joi": "^17.6.0",
     "jose": "^2.0.5",
-    "on-headers": "^1.0.2",
     "openid-client": "^4.9.1",
     "tslib": "^2.4.0",
     "url-join": "^4.0.1"
diff --git a/src/auth0-session/cookie-store.ts b/src/auth0-session/cookie-store.ts
index 802b42e2..52950341 100644
--- a/src/auth0-session/cookie-store.ts
+++ b/src/auth0-session/cookie-store.ts
@@ -3,7 +3,7 @@ import { strict as assert, AssertionError } from 'assert';
 import { JWE, JWK, JWKS, errors } from 'jose';
 import { encryption as deriveKey } from './utils/hkdf';
 import createDebug from './utils/debug';
-import { getAll as getCookies, clear as clearCookie, set as setCookie } from './utils/cookies';
+import Cookies from './utils/cookies';
 import { Config } from './config';
 import { CookieSerializeOptions, serialize } from 'cookie';
 
@@ -79,7 +79,7 @@ export default class CookieStore {
   }
 
   public read(req: IncomingMessage): [{ [key: string]: any }?, number?] {
-    const cookies = getCookies(req);
+    const cookies = Cookies.getAll(req);
     const { name: sessionName, rollingDuration, absoluteDuration } = this.config.session;
 
     let iat;
@@ -160,13 +160,15 @@ export default class CookieStore {
       cookie: { transient, ...cookieConfig },
       name: sessionName
     } = this.config.session;
-    const cookies = getCookies(req);
+    const cookies = Cookies.getAll(req);
+    const cookieSetter = new Cookies();
 
     if (!session) {
       debug('clearing all matching session cookies');
       for (const cookieName of Object.keys(cookies)) {
         if (cookieName.match(`^${sessionName}(?:\\.\\d)?$`)) {
-          clearCookie(res, cookieName, cookieConfig);
+          cookieSetter.clear(cookieName, cookieConfig);
+          cookieSetter.commit(res, this.config.session.name);
         }
       }
       return;
@@ -192,18 +194,19 @@ export default class CookieStore {
       for (let i = 0; i < chunkCount; i++) {
         const chunkValue = value.slice(i * this.chunkSize, (i + 1) * this.chunkSize);
         const chunkCookieName = `${sessionName}.${i}`;
-        setCookie(res, chunkCookieName, chunkValue, cookieOptions);
+        cookieSetter.set(chunkCookieName, chunkValue, cookieOptions);
       }
       if (sessionName in cookies) {
-        clearCookie(res, sessionName, cookieConfig);
+        cookieSetter.clear(sessionName, cookieConfig);
       }
     } else {
-      setCookie(res, sessionName, value, cookieOptions);
+      cookieSetter.set(sessionName, value, cookieOptions);
       for (const cookieName of Object.keys(cookies)) {
         if (cookieName.match(`^${sessionName}\\.\\d$`)) {
-          clearCookie(res, cookieName, cookieConfig);
+          cookieSetter.clear(cookieName, cookieConfig);
         }
       }
     }
+    cookieSetter.commit(res, this.config.session.name);
   }
 }
diff --git a/src/auth0-session/transient-store.ts b/src/auth0-session/transient-store.ts
index 245f67ea..d40a725a 100644
--- a/src/auth0-session/transient-store.ts
+++ b/src/auth0-session/transient-store.ts
@@ -2,7 +2,7 @@ import { IncomingMessage, ServerResponse } from 'http';
 import { generators } from 'openid-client';
 import { JWKS, JWS, JWK } from 'jose';
 import { signing as deriveKey } from './utils/hkdf';
-import { get as getCookie, clear as clearCookie, set as setCookie } from './utils/cookies';
+import Cookies from './utils/cookies';
 import { Config } from './config';
 
 export interface StoreOptions {
@@ -102,11 +102,12 @@ export default class TransientStore {
       domain,
       path
     };
+    const cookieSetter = new Cookies();
 
     {
       const cookieValue = generateCookieValue(key, value, this.currentKey as JWK.Key);
       // Set the cookie with the SameSite attribute and, if needed, the Secure flag.
-      setCookie(res, key, cookieValue, {
+      cookieSetter.set(key, cookieValue, {
         ...basicAttr,
         sameSite,
         secure: isSameSiteNone ? true : basicAttr.secure
@@ -116,9 +117,10 @@ export default class TransientStore {
     if (isSameSiteNone && this.config.legacySameSiteCookie) {
       const cookieValue = generateCookieValue(`_${key}`, value, this.currentKey as JWK.Key);
       // Set the fallback cookie with no SameSite or Secure attributes.
-      setCookie(res, `_${key}`, cookieValue, basicAttr);
+      cookieSetter.set(`_${key}`, cookieValue, basicAttr);
     }
 
+    cookieSetter.commit(res);
     return value;
   }
 
@@ -132,21 +134,24 @@ export default class TransientStore {
    * @return {String|undefined} Cookie value or undefined if cookie was not found.
    */
   read(key: string, req: IncomingMessage, res: ServerResponse): string | undefined {
-    const cookie = getCookie(req, key);
+    const cookies = Cookies.getAll(req);
+    const cookie = cookies[key];
     const cookieConfig = this.config.session.cookie;
+    const cookieSetter = new Cookies();
 
     let value = getCookieValue(key, cookie, this.keyStore);
-    clearCookie(res, key, cookieConfig);
+    cookieSetter.clear(key, cookieConfig);
 
     if (this.config.legacySameSiteCookie) {
       const fallbackKey = `_${key}`;
       if (!value) {
-        const fallbackCookie = getCookie(req, fallbackKey);
+        const fallbackCookie = cookies[fallbackKey];
         value = getCookieValue(fallbackKey, fallbackCookie, this.keyStore);
       }
-      clearCookie(res, fallbackKey, cookieConfig);
+      cookieSetter.clear(fallbackKey, cookieConfig);
     }
 
+    cookieSetter.commit(res);
     return value;
   }
 
diff --git a/src/auth0-session/utils/cookies.ts b/src/auth0-session/utils/cookies.ts
index ea5e689b..c81fcff4 100644
--- a/src/auth0-session/utils/cookies.ts
+++ b/src/auth0-session/utils/cookies.ts
@@ -1,39 +1,46 @@
 import { IncomingMessage, ServerResponse } from 'http';
 import { CookieSerializeOptions, parse, serialize } from 'cookie';
 
-export const getAll = (req: IncomingMessage): { [key: string]: string } => {
-  return parse(req.headers.cookie || '');
-};
+export default class Cookies {
+  private cookies: string[];
 
-export const get = (req: IncomingMessage, name: string): string => {
-  const cookies = getAll(req);
-  return cookies[name];
-};
-
-export const set = (res: ServerResponse, name: string, value: string, options: CookieSerializeOptions = {}): void => {
-  const strCookie = serialize(name, value, options);
+  constructor() {
+    this.cookies = [];
+  }
 
-  let previousCookies = res.getHeader('Set-Cookie') || [];
-  if (!Array.isArray(previousCookies)) {
-    previousCookies = [previousCookies as string];
+  set(name: string, value: string, options: CookieSerializeOptions = {}) {
+    this.cookies.push(serialize(name, value, options));
   }
 
-  res.setHeader('Set-Cookie', [...previousCookies, strCookie]);
-};
+  clear(name: string, options: CookieSerializeOptions = {}) {
+    const { domain, path, secure, sameSite } = options;
+    const clearOptions: CookieSerializeOptions = {
+      domain,
+      path,
+      maxAge: 0
+    };
+    // If SameSite=None is set, the cookie Secure attribute must also be set (or the cookie will be blocked)
+    // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite#none
+    if (sameSite === 'none') {
+      clearOptions.secure = secure;
+      clearOptions.sameSite = sameSite;
+    }
+    this.set(name, '', clearOptions);
+  }
 
-export const clear = (res: ServerResponse, name: string, options: CookieSerializeOptions = {}): void => {
-  const { domain, path, secure, sameSite } = options;
-  const clearOptions: CookieSerializeOptions = {
-    domain,
-    path,
-    maxAge: 0
-  };
-  // If SameSite=None is set, the cookie Secure attribute must also be set (or the cookie will be blocked)
-  // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite#none
-  if (sameSite === 'none') {
-    clearOptions.secure = secure;
-    clearOptions.sameSite = sameSite;
+  commit(res: ServerResponse, filterCookiePrefix?: string) {
+    let previousCookies = res.getHeader('Set-Cookie') || [];
+    if (!Array.isArray(previousCookies)) {
+      previousCookies = [previousCookies as string];
+    }
+    if (filterCookiePrefix) {
+      const re = new RegExp(`^${filterCookiePrefix}(\\.\\d+)?=`);
+      previousCookies = previousCookies.filter((cookie: string) => !re.test(cookie));
+    }
+    res.setHeader('Set-Cookie', [...previousCookies, ...this.cookies]);
   }
 
-  set(res, name, '', clearOptions);
-};
+  static getAll(req: IncomingMessage) {
+    return parse(req.headers.cookie || '');
+  }
+}
diff --git a/src/helpers/get-server-side-props-wrapper.ts b/src/helpers/get-server-side-props-wrapper.ts
deleted file mode 100644
index 9d3ab298..00000000
--- a/src/helpers/get-server-side-props-wrapper.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { GetServerSideProps } from 'next';
-import { ParsedUrlQuery } from 'querystring';
-import SessionCache from '../session/cache';
-
-/**
- * If you're using >=Next 12 and {@link getSession} or {@link getAccessToken} without `withPageAuthRequired`, because
- * you don't want to require authentication on your route, you might get a warning/error: "You should not access 'res'
- * after getServerSideProps resolves". You can work around this by wrapping your `getServerSideProps` in
- * `getServerSidePropsWrapper`, this ensures that the code that accesses `res` will run within
- * the lifecycle of `getServerSideProps`, avoiding the warning/error eg:
- *
- * **NOTE: you do not need to do this if you're already using {@link WithPageAuthRequired}**
- *
- * ```js
- * // pages/protected-page.js
- * import { withPageAuthRequired } from '@auth0/nextjs-auth0';
- *
- * export default function ProtectedPage() {
- *   return <div>Protected content</div>;
- * }
- *
- * export const getServerSideProps = getServerSidePropsWrapper(async (ctx) => {
- *   const session = getSession(ctx.req, ctx.res);
- *   if (session) {
- *     // Use is authenticated
- *   } else {
- *     // User is not authenticated
- *   }
- * });
- * ```
- *
- * @category Server
- */
-export type GetServerSidePropsWrapper<P = any, Q extends ParsedUrlQuery = ParsedUrlQuery> = (
-  getServerSideProps: GetServerSideProps<P, Q>
-) => GetServerSideProps<P, Q>;
-
-/**
- * @ignore
- */
-export default function getServerSidePropsWrapperFactory(getSessionCache: () => SessionCache) {
-  return function getServerSidePropsWrapper(getServerSideProps: GetServerSideProps): GetServerSideProps {
-    return function wrappedGetServerSideProps(...args) {
-      const sessionCache = getSessionCache();
-      const [ctx] = args;
-      sessionCache.init(ctx.req, ctx.res, false);
-      const ret = getServerSideProps(...args);
-      sessionCache.save(ctx.req, ctx.res);
-      return ret;
-    };
-  };
-}
diff --git a/src/helpers/index.ts b/src/helpers/index.ts
index d57ed453..b2024e2e 100644
--- a/src/helpers/index.ts
+++ b/src/helpers/index.ts
@@ -6,7 +6,3 @@ export {
   WithPageAuthRequiredOptions,
   PageRoute
 } from './with-page-auth-required';
-export {
-  default as getServerSidePropsWrapperFactory,
-  GetServerSidePropsWrapper
-} from './get-server-side-props-wrapper';
diff --git a/src/helpers/with-page-auth-required.ts b/src/helpers/with-page-auth-required.ts
index 2ca7b929..f5d01d7f 100644
--- a/src/helpers/with-page-auth-required.ts
+++ b/src/helpers/with-page-auth-required.ts
@@ -9,7 +9,6 @@ import {
 } from '../frontend/with-page-auth-required';
 import { withPageAuthRequired as withPageAuthRequiredCSR } from '../frontend';
 import { ParsedUrlQuery } from 'querystring';
-import getServerSidePropsWrapperFactory from './get-server-side-props-wrapper';
 
 /**
  * If you wrap your `getServerSideProps` with {@link WithPageAuthRequired} your props object will be augmented with
@@ -112,32 +111,26 @@ export default function withPageAuthRequiredFactory(
       return withPageAuthRequiredCSR(optsOrComponent, csrOpts);
     }
     const { getServerSideProps, returnTo } = optsOrComponent;
-    const getServerSidePropsWrapper = getServerSidePropsWrapperFactory(getSessionCache);
-    return getServerSidePropsWrapper(
-      async (ctx: GetServerSidePropsContext): Promise<GetServerSidePropsResultWithSession> => {
-        assertCtx(ctx);
-        const sessionCache = getSessionCache();
-        const session = sessionCache.get(ctx.req, ctx.res);
-        if (!session?.user) {
-          // 10 - redirect
-          // 9.5.4 - unstable_redirect
-          // 9.4 - res.setHeaders
-          return {
-            redirect: {
-              destination: `${loginUrl}?returnTo=${encodeURIComponent(returnTo || ctx.resolvedUrl)}`,
-              permanent: false
-            }
-          };
-        }
-        let ret: any = { props: {} };
-        if (getServerSideProps) {
-          ret = await getServerSideProps(ctx);
-        }
-        if (ret.props instanceof Promise) {
-          return { ...ret, props: ret.props.then((props: any) => ({ ...props, user: session.user })) };
-        }
-        return { ...ret, props: { ...ret.props, user: session.user } };
+    return async (ctx: GetServerSidePropsContext): Promise<GetServerSidePropsResultWithSession> => {
+      assertCtx(ctx);
+      const sessionCache = getSessionCache();
+      const session = sessionCache.get(ctx.req, ctx.res);
+      if (!session?.user) {
+        return {
+          redirect: {
+            destination: `${loginUrl}?returnTo=${encodeURIComponent(returnTo || ctx.resolvedUrl)}`,
+            permanent: false
+          }
+        };
       }
-    );
+      let ret: any = { props: {} };
+      if (getServerSideProps) {
+        ret = await getServerSideProps(ctx);
+      }
+      if (ret.props instanceof Promise) {
+        return { ...ret, props: ret.props.then((props: any) => ({ ...props, user: session.user })) };
+      }
+      return { ...ret, props: { ...ret.props, user: session.user } };
+    };
   };
 }
diff --git a/src/index.browser.ts b/src/index.browser.ts
index 1d0732da..cad7ccec 100644
--- a/src/index.browser.ts
+++ b/src/index.browser.ts
@@ -19,6 +19,9 @@ const instance: SignInWithAuth0 = {
   getSession() {
     throw new Error(serverSideOnly('getSession'));
   },
+  updateUser() {
+    throw new Error(serverSideOnly('updateUser'));
+  },
   getAccessToken() {
     throw new Error(serverSideOnly('getAccessToken'));
   },
@@ -42,9 +45,6 @@ const instance: SignInWithAuth0 = {
   },
   withPageAuthRequired() {
     throw new Error(serverSideOnly('withPageAuthRequired'));
-  },
-  getServerSidePropsWrapper() {
-    throw new Error(serverSideOnly('getServerSidePropsWrapper'));
   }
 };
 
diff --git a/src/index.ts b/src/index.ts
index 982daefd..7809ce86 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -35,7 +35,9 @@ import {
   Session,
   AccessTokenRequest,
   GetAccessTokenResult,
-  Claims
+  Claims,
+  updateUserFactory,
+  UpdateUser
 } from './session/';
 import {
   withPageAuthRequiredFactory,
@@ -44,9 +46,7 @@ import {
   WithPageAuthRequired,
   GetServerSidePropsResultWithSession,
   WithPageAuthRequiredOptions,
-  PageRoute,
-  getServerSidePropsWrapperFactory,
-  GetServerSidePropsWrapper
+  PageRoute
 } from './helpers';
 import { InitAuth0, SignInWithAuth0 } from './instance';
 import version from './version';
@@ -76,10 +76,10 @@ export const _initAuth = (params?: ConfigParameters): SignInWithAuth0 & { sessio
 
   // Init Next layer (with next config)
   const getSession = sessionFactory(sessionCache);
+  const updateUser = updateUserFactory(sessionCache);
   const getAccessToken = accessTokenFactory(nextConfig, getClient, sessionCache);
   const withApiAuthRequired = withApiAuthRequiredFactory(sessionCache);
   const withPageAuthRequired = withPageAuthRequiredFactory(nextConfig.routes.login, () => sessionCache);
-  const getServerSidePropsWrapper = getServerSidePropsWrapperFactory(() => sessionCache);
   const handleLogin = loginHandler(baseHandleLogin, nextConfig, baseConfig);
   const handleLogout = logoutHandler(baseHandleLogout);
   const handleCallback = callbackHandler(baseHandleCallback, nextConfig);
@@ -89,10 +89,10 @@ export const _initAuth = (params?: ConfigParameters): SignInWithAuth0 & { sessio
   return {
     sessionCache,
     getSession,
+    updateUser,
     getAccessToken,
     withApiAuthRequired,
     withPageAuthRequired,
-    getServerSidePropsWrapper,
     handleLogin,
     handleLogout,
     handleCallback,
@@ -108,10 +108,10 @@ export const initAuth0: InitAuth0 = (params) => {
 
 const getSessionCache = () => getInstance().sessionCache;
 export const getSession: GetSession = (...args) => getInstance().getSession(...args);
+export const updateUser: UpdateUser = (...args) => getInstance().updateUser(...args);
 export const getAccessToken: GetAccessToken = (...args) => getInstance().getAccessToken(...args);
 export const withApiAuthRequired: WithApiAuthRequired = (...args) => getInstance().withApiAuthRequired(...args);
 export const withPageAuthRequired: WithPageAuthRequired = withPageAuthRequiredFactory(getLoginUrl(), getSessionCache);
-export const getServerSidePropsWrapper: GetServerSidePropsWrapper = getServerSidePropsWrapperFactory(getSessionCache);
 export const handleLogin: HandleLogin = (...args) => getInstance().handleLogin(...args);
 export const handleLogout: HandleLogout = (...args) => getInstance().handleLogout(...args);
 export const handleCallback: HandleCallback = (...args) => getInstance().handleCallback(...args);
@@ -153,7 +153,6 @@ export {
   PageRoute,
   WithApiAuthRequired,
   WithPageAuthRequired,
-  GetServerSidePropsWrapper,
   SessionCache,
   GetSession,
   GetAccessToken,
diff --git a/src/instance.ts b/src/instance.ts
index 462768bf..0077885e 100644
--- a/src/instance.ts
+++ b/src/instance.ts
@@ -1,5 +1,5 @@
-import { GetSession, GetAccessToken } from './session';
-import { GetServerSidePropsWrapper, WithApiAuthRequired, WithPageAuthRequired } from './helpers';
+import { GetSession, GetAccessToken, UpdateUser } from './session';
+import { WithApiAuthRequired, WithPageAuthRequired } from './helpers';
 import { HandleAuth, HandleCallback, HandleLogin, HandleLogout, HandleProfile } from './handlers';
 import { ConfigParameters } from './auth0-session';
 
@@ -18,6 +18,11 @@ export interface SignInWithAuth0 {
    */
   getSession: GetSession;
 
+  /**
+   * Append properties to the user.
+   */
+  updateUser: UpdateUser;
+
   /**
    * Access Token getter
    */
@@ -53,12 +58,6 @@ export interface SignInWithAuth0 {
    */
   withPageAuthRequired: WithPageAuthRequired;
 
-  /**
-   * Wrap `getServerSideProps` to avoid accessing `res` after getServerSideProps resolves,
-   * see {@link GetServerSidePropsWrapper}
-   */
-  getServerSidePropsWrapper: GetServerSidePropsWrapper;
-
   /**
    * Create the main handlers for your api routes
    */
diff --git a/src/session/cache.ts b/src/session/cache.ts
index b8217987..773508ef 100644
--- a/src/session/cache.ts
+++ b/src/session/cache.ts
@@ -1,7 +1,6 @@
 import { IncomingMessage, ServerResponse } from 'http';
 import { NextApiRequest, NextApiResponse } from 'next';
 import { TokenSet } from 'openid-client';
-import onHeaders from 'on-headers';
 import { Config, SessionCache as ISessionCache, CookieStore } from '../auth0-session';
 import Session, { fromJson, fromTokenSet } from './session';
 
@@ -22,8 +21,8 @@ export default class SessionCache implements ISessionCache {
       const [json, iat] = this.cookieStore.read(req);
       this.iatCache.set(req, iat);
       this.cache.set(req, fromJson(json));
-      if (autoSave) {
-        onHeaders(res, () => this.save(req, res));
+      if (this.config.session.rolling && autoSave) {
+        this.save(req, res);
       }
     }
   }
@@ -34,12 +33,13 @@ export default class SessionCache implements ISessionCache {
 
   create(req: NextApiOrPageRequest, res: NextApiOrPageResponse, session: Session): void {
     this.cache.set(req, session);
-    onHeaders(res, () => this.save(req, res));
+    this.save(req, res);
   }
 
   delete(req: NextApiOrPageRequest, res: NextApiOrPageResponse): void {
-    this.init(req, res);
+    this.init(req, res, false);
     this.cache.set(req, null);
+    this.save(req, res);
   }
 
   isAuthenticated(req: NextApiOrPageRequest, res: NextApiOrPageResponse): boolean {
@@ -55,8 +55,9 @@ export default class SessionCache implements ISessionCache {
   }
 
   set(req: NextApiOrPageRequest, res: NextApiOrPageResponse, session: Session | null): void {
-    this.init(req, res);
+    this.init(req, res, false);
     this.cache.set(req, session);
+    this.save(req, res);
   }
 
   get(req: NextApiOrPageRequest, res: NextApiOrPageResponse): Session | null | undefined {
diff --git a/src/session/index.ts b/src/session/index.ts
index 23e0ee00..1fc810e0 100644
--- a/src/session/index.ts
+++ b/src/session/index.ts
@@ -7,3 +7,4 @@ export {
   GetAccessTokenResult
 } from './get-access-token';
 export { default as SessionCache } from './cache';
+export { default as updateUserFactory, UpdateUser } from './update-user';
diff --git a/src/session/update-user.ts b/src/session/update-user.ts
new file mode 100644
index 00000000..d06f5ce9
--- /dev/null
+++ b/src/session/update-user.ts
@@ -0,0 +1,43 @@
+import { IncomingMessage, ServerResponse } from 'http';
+import { NextApiRequest, NextApiResponse } from 'next';
+import { Claims, SessionCache } from '../session';
+
+/**
+ * Update the session's user object. The provided user object will replace `session.user`
+ *
+ * If no user is provided, or the user is not authenticated, this is a noop.
+ *
+ * ```js
+ * // pages/api/update-user.js
+ * import { getSession, updateUser } from '@auth0/nextjs-auth0';
+ *
+ * export default async function UpdateUser(req, res) {
+ *   if (req.method === 'PUT') {
+ *     const { user } = getSession(req, res);
+ *     updateUser(req, res, { ...user, foo: req.query.foo });
+ *     res.json({ success: true });
+ *   }
+ * };
+ * ```
+ *
+ * @category Server
+ */
+export type UpdateUser = (
+  req: IncomingMessage | NextApiRequest,
+  res: ServerResponse | NextApiResponse,
+  user: Claims
+) => void;
+
+/**
+ * @ignore
+ */
+export default function updateUserFactory(sessionCache: SessionCache): UpdateUser {
+  return (req, res, user): void => {
+    sessionCache.init(req, res, false);
+    const session = sessionCache.get(req, res);
+    if (!session || !user) {
+      return;
+    }
+    sessionCache.set(req, res, { ...session, user });
+  };
+}


Test patch:
diff --git a/tests/auth0-session/fixtures/helpers.ts b/tests/auth0-session/fixtures/helpers.ts
index c35e8e19..8eb01c9b 100644
--- a/tests/auth0-session/fixtures/helpers.ts
+++ b/tests/auth0-session/fixtures/helpers.ts
@@ -112,5 +112,5 @@ export const post = async (
     cookieJar,
     body,
     fullResponse
-  }: { body: { [key: string]: string }; cookieJar?: CookieJar; fullResponse?: boolean; https?: boolean }
+  }: { body: { [key: string]: any }; cookieJar?: CookieJar; fullResponse?: boolean; https?: boolean }
 ): Promise<any | Response> => request(`${baseURL}${path}`, 'POST', { body, cookieJar, fullResponse });
diff --git a/tests/auth0-session/utils/cookie.test.ts b/tests/auth0-session/utils/cookie.test.ts
index ab13032f..773c04ac 100644
--- a/tests/auth0-session/utils/cookie.test.ts
+++ b/tests/auth0-session/utils/cookie.test.ts
@@ -1,6 +1,6 @@
 import { AddressInfo } from 'net';
 import { createServer, get as getRequest, IncomingMessage, ServerResponse } from 'http';
-import { getAll, get, set, clear } from '../../../src/auth0-session/utils/cookies';
+import Cookies from '../../../src/auth0-session/utils/cookies';
 
 const setup = (): Promise<[IncomingMessage, ServerResponse, Function]> =>
   new Promise((resolve) => {
@@ -25,27 +25,31 @@ describe('cookie', () => {
   it('should get all cookies', async () => {
     const [req, , teardown] = await setup();
     req.headers.cookie = 'foo=bar; bar=baz;';
-    expect(getAll(req)).toMatchObject({ foo: 'bar', bar: 'baz' });
+    expect(Cookies.getAll(req)).toMatchObject({ foo: 'bar', bar: 'baz' });
     await teardown();
   });
 
   it('should get a cookie by name', async () => {
     const [req, , teardown] = await setup();
     req.headers.cookie = 'foo=bar; bar=baz;';
-    expect(get(req, 'foo')).toEqual('bar');
+    expect(Cookies.getAll(req)['foo']).toEqual('bar');
     await teardown();
   });
 
   it('should set a cookie', async () => {
     const [, res, teardown] = await setup();
-    set(res, 'foo', 'bar');
+    const setter = new Cookies();
+    setter.set('foo', 'bar');
+    setter.commit(res);
     expect(res.getHeader('Set-Cookie')).toEqual(['foo=bar']);
     await teardown();
   });
 
   it('should set a cookie with opts', async () => {
     const [, res, teardown] = await setup();
-    set(res, 'foo', 'bar', { httpOnly: true, sameSite: 'strict' });
+    const setter = new Cookies();
+    setter.set('foo', 'bar', { httpOnly: true, sameSite: 'strict' });
+    setter.commit(res);
     expect(res.getHeader('Set-Cookie')).toEqual(['foo=bar; HttpOnly; SameSite=Strict']);
     await teardown();
   });
@@ -53,22 +57,38 @@ describe('cookie', () => {
   it('should not overwrite existing set cookie', async () => {
     const [, res, teardown] = await setup();
     res.setHeader('Set-Cookie', 'foo=bar');
-    set(res, 'baz', 'qux');
+    const setter = new Cookies();
+    setter.set('baz', 'qux');
+    setter.commit(res);
     expect(res.getHeader('Set-Cookie')).toEqual(['foo=bar', 'baz=qux']);
     await teardown();
   });
 
-  it('should not overwrite existing set cookie array', async () => {
+  it('should override existing cookies that equal name', async () => {
     const [, res, teardown] = await setup();
-    set(res, 'foo', 'bar');
-    set(res, 'baz', 'qux');
-    expect(res.getHeader('Set-Cookie')).toEqual(['foo=bar', 'baz=qux']);
+    res.setHeader('Set-Cookie', ['foo=bar', 'baz=qux']);
+    const setter = new Cookies();
+    setter.set('foo', 'qux');
+    setter.commit(res, 'foo');
+    expect(res.getHeader('Set-Cookie')).toEqual(['baz=qux', 'foo=qux']);
+    await teardown();
+  });
+
+  it('should override existing cookies that match name', async () => {
+    const [, res, teardown] = await setup();
+    res.setHeader('Set-Cookie', ['foo.1=bar', 'foo.2=baz']);
+    const setter = new Cookies();
+    setter.set('foo', 'qux');
+    setter.commit(res, 'foo');
+    expect(res.getHeader('Set-Cookie')).toEqual(['foo=qux']);
     await teardown();
   });
 
   it('should clear cookies', async () => {
     const [, res, teardown] = await setup();
-    clear(res, 'foo');
+    const setter = new Cookies();
+    setter.clear('foo');
+    setter.commit(res);
     expect(res.getHeader('Set-Cookie')).toEqual(['foo=; Max-Age=0']);
     await teardown();
   });
diff --git a/tests/fixtures/setup.ts b/tests/fixtures/setup.ts
index 5cc91203..eca23042 100644
--- a/tests/fixtures/setup.ts
+++ b/tests/fixtures/setup.ts
@@ -60,10 +60,10 @@ export const setup = async (
     handleLogout,
     handleProfile,
     getSession,
+    updateUser,
     getAccessToken,
     withApiAuthRequired,
-    withPageAuthRequired,
-    getServerSidePropsWrapper
+    withPageAuthRequired
   } = await initAuth0(config);
   (global as any).handleAuth = handleAuth.bind(null, {
     async callback(req, res) {
@@ -100,12 +100,12 @@ export const setup = async (
   });
 
   (global as any).getSession = getSession;
+  (global as any).updateUser = updateUser;
   (global as any).withApiAuthRequired = withApiAuthRequired;
   (global as any).withPageAuthRequired = (): any => withPageAuthRequired(withPageAuthRequiredOptions);
   (global as any).withPageAuthRequiredCSR = withPageAuthRequired;
   (global as any).getAccessToken = (req: NextApiRequest, res: NextApiResponse): Promise<GetAccessTokenResult> =>
     getAccessToken(req, res, getAccessTokenOptions);
-  (global as any).getServerSidePropsWrapper = getServerSidePropsWrapper;
   (global as any).asyncProps = asyncProps;
   return start();
 };
@@ -114,12 +114,12 @@ export const teardown = async (): Promise<void> => {
   nock.cleanAll();
   await stop();
   delete (global as any).getSession;
+  delete (global as any).updateUser;
   delete (global as any).handleAuth;
   delete (global as any).withApiAuthRequired;
   delete (global as any).withPageAuthRequired;
   delete (global as any).withPageAuthRequiredCSR;
   delete (global as any).getAccessToken;
-  delete (global as any).getServerSidePropsWrapper;
   delete (global as any).asyncProps;
 };
 
diff --git a/tests/fixtures/test-app/pages/api/update-user.ts b/tests/fixtures/test-app/pages/api/update-user.ts
new file mode 100644
index 00000000..a33bf15a
--- /dev/null
+++ b/tests/fixtures/test-app/pages/api/update-user.ts
@@ -0,0 +1,8 @@
+import { NextApiRequest, NextApiResponse } from 'next';
+
+export default function sessionHandler(req: NextApiRequest, res: NextApiResponse): void {
+  const session = (global as any).getSession(req, res);
+  const updated = { ...session?.user, ...req.body?.user };
+  (global as any).updateUser(req, res, updated);
+  res.status(200).json(updated);
+}
diff --git a/tests/fixtures/test-app/pages/wrapped-get-server-side-props.tsx b/tests/fixtures/test-app/pages/wrapped-get-server-side-props.tsx
deleted file mode 100644
index 33868249..00000000
--- a/tests/fixtures/test-app/pages/wrapped-get-server-side-props.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-import { NextPageContext } from 'next';
-
-export default function wrappedGetServerSidePropsPage({
-  isAuthenticated
-}: {
-  isAuthenticated?: boolean;
-}): React.ReactElement {
-  return <div>isAuthenticated: {String(isAuthenticated)}</div>;
-}
-
-export const getServerSideProps = (_ctx: NextPageContext): any =>
-  (global as any).getServerSidePropsWrapper(async (ctx: NextPageContext) => {
-    const session = (global as any).getSession(ctx.req, ctx.res);
-    const asyncProps = (global as any).asyncProps;
-    const props = { isAuthenticated: !!session };
-    return { props: asyncProps ? Promise.resolve(props) : props };
-  })(_ctx);
diff --git a/tests/helpers/get-server-side-props-wrapper.test.ts b/tests/helpers/get-server-side-props-wrapper.test.ts
deleted file mode 100644
index 82a0ca13..00000000
--- a/tests/helpers/get-server-side-props-wrapper.test.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { login, setup, teardown } from '../fixtures/setup';
-import { withoutApi } from '../fixtures/default-settings';
-import { get } from '../auth0-session/fixtures/helpers';
-
-describe('get-server-side-props-wrapper', () => {
-  afterEach(teardown);
-
-  test('wrap getServerSideProps', async () => {
-    const baseUrl = await setup(withoutApi);
-
-    const {
-      res: { statusCode },
-      data
-    } = await get(baseUrl, '/wrapped-get-server-side-props', { fullResponse: true });
-    expect(statusCode).toBe(200);
-    expect(data).toMatch(/isAuthenticated: .*false/);
-  });
-
-  test('wrap getServerSideProps with session', async () => {
-    const baseUrl = await setup(withoutApi);
-    const cookieJar = await login(baseUrl);
-
-    const {
-      res: { statusCode },
-      data
-    } = await get(baseUrl, '/wrapped-get-server-side-props', { fullResponse: true, cookieJar });
-    expect(statusCode).toBe(200);
-    expect(data).toMatch(/isAuthenticated: .*true/);
-  });
-
-  test('wrap getServerSideProps with async props', async () => {
-    const baseUrl = await setup(withoutApi, { asyncProps: true });
-
-    const {
-      res: { statusCode },
-      data
-    } = await get(baseUrl, '/wrapped-get-server-side-props', { fullResponse: true });
-    expect(statusCode).toBe(200);
-    expect(data).toMatch(/isAuthenticated: .*false/);
-  });
-
-  test('wrap getServerSideProps with async props and session', async () => {
-    const baseUrl = await setup(withoutApi, { asyncProps: true });
-    const cookieJar = await login(baseUrl);
-
-    const {
-      res: { statusCode },
-      data
-    } = await get(baseUrl, '/wrapped-get-server-side-props', { fullResponse: true, cookieJar });
-    expect(statusCode).toBe(200);
-    expect(data).toMatch(/isAuthenticated: .*true/);
-  });
-});
diff --git a/tests/session/cache.test.ts b/tests/session/cache.test.ts
index 90392352..8efb9baf 100644
--- a/tests/session/cache.test.ts
+++ b/tests/session/cache.test.ts
@@ -2,11 +2,9 @@ import { IncomingMessage, ServerResponse } from 'http';
 import { Socket } from 'net';
 import { mocked } from 'ts-jest/utils';
 import { CookieStore, getConfig } from '../../src/auth0-session';
-import { Session, SessionCache } from '../../src';
+import { ConfigParameters, Session, SessionCache } from '../../src';
 import { withoutApi } from '../fixtures/default-settings';
 
-jest.mock('on-headers', () => (_res: ServerResponse, cb: Function): void => cb());
-
 describe('SessionCache', () => {
   let cache: SessionCache;
   let req: IncomingMessage;
@@ -14,8 +12,8 @@ describe('SessionCache', () => {
   let session: Session;
   let cookieStore: CookieStore;
 
-  beforeEach(() => {
-    const config = getConfig(withoutApi);
+  const setup = (conf: ConfigParameters) => {
+    const config = getConfig(conf);
     cookieStore = mocked(new CookieStore(config));
     cookieStore.save = jest.fn();
     session = new Session({ sub: '__test_user__' });
@@ -23,6 +21,10 @@ describe('SessionCache', () => {
     cache = new SessionCache(config, cookieStore);
     req = mocked(new IncomingMessage(new Socket()));
     res = mocked(new ServerResponse(req));
+  };
+
+  beforeEach(() => {
+    setup(withoutApi);
   });
 
   test('should create an instance', () => {
@@ -60,7 +62,18 @@ describe('SessionCache', () => {
     expect(cache.getIdToken(req, res)).toBeUndefined();
   });
 
-  test('should read and update the session', () => {
+  test('should save the session on read and update with a rolling session', () => {
+    cookieStore.read = jest.fn().mockReturnValue([{ user: { sub: '__test_user__' } }, 500]);
+    expect(cache.isAuthenticated(req, res)).toEqual(true);
+    expect(cache.get(req, res)?.user).toEqual({ sub: '__test_user__' });
+    cache.set(req, res, new Session({ sub: '__new_user__' }));
+    expect(cache.get(req, res)?.user).toEqual({ sub: '__new_user__' });
+    expect(cookieStore.read).toHaveBeenCalledTimes(1);
+    expect(cookieStore.save).toHaveBeenCalledTimes(2);
+  });
+
+  test('should save the session only on update without a rolling session', () => {
+    setup({ ...withoutApi, session: { rolling: false } });
     cookieStore.read = jest.fn().mockReturnValue([{ user: { sub: '__test_user__' } }, 500]);
     expect(cache.isAuthenticated(req, res)).toEqual(true);
     expect(cache.get(req, res)?.user).toEqual({ sub: '__test_user__' });
diff --git a/tests/session/update-user.test.ts b/tests/session/update-user.test.ts
new file mode 100644
index 00000000..0ee09035
--- /dev/null
+++ b/tests/session/update-user.test.ts
@@ -0,0 +1,34 @@
+import { login, setup, teardown } from '../fixtures/setup';
+import { withoutApi } from '../fixtures/default-settings';
+import { get, post } from '../auth0-session/fixtures/helpers';
+
+describe('update-user', () => {
+  afterEach(teardown);
+
+  test('should update user', async () => {
+    const baseUrl = await setup(withoutApi);
+    const cookieJar = await login(baseUrl);
+    const user = await get(baseUrl, '/api/auth/me', { cookieJar });
+    expect(user).toEqual({ nickname: '__test_nickname__', sub: '__test_sub__' });
+    await post(baseUrl, '/api/update-user', { cookieJar, body: { user: { foo: 'bar' } } });
+    const updatedUser = await get(baseUrl, '/api/auth/me', { cookieJar });
+    expect(updatedUser).toMatchObject({ foo: 'bar' });
+  });
+
+  test('should ignore updates if user is not defined', async () => {
+    const baseUrl = await setup(withoutApi);
+    const cookieJar = await login(baseUrl);
+    const user = await get(baseUrl, '/api/auth/me', { cookieJar });
+    expect(user).toEqual({ nickname: '__test_nickname__', sub: '__test_sub__' });
+    await post(baseUrl, '/api/update-user', { cookieJar, body: { user: undefined } });
+    const updatedUser = await get(baseUrl, '/api/auth/me', { cookieJar });
+    expect(updatedUser).toEqual({ nickname: '__test_nickname__', sub: '__test_sub__' });
+  });
+
+  test('should ignore updates if user is not logged in', async () => {
+    const baseUrl = await setup(withoutApi);
+    await expect(get(baseUrl, '/api/auth/me')).rejects.toThrow('Unauthorized');
+    await post(baseUrl, '/api/update-user', { body: { user: { sub: 'foo' } } });
+    await expect(get(baseUrl, '/api/auth/me')).rejects.toThrow('Unauthorized');
+  });
+});