diff --git a/package.json b/package.json
index 326567f044..29f8a0737f 100644
--- a/package.json
+++ b/package.json
@@ -83,6 +83,7 @@
     "glob-to-regexp": "^0.4.1",
     "highlight.js": "^11.3.1",
     "html-entities": "^1.4.0",
+    "idb-mutex": "^0.11.0",
     "is-ip": "^3.1.0",
     "jszip": "^3.7.0",
     "katex": "^0.12.0",
diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts
index b4f48ec511..bff378878b 100644
--- a/src/Lifecycle.ts
+++ b/src/Lifecycle.ts
@@ -58,6 +58,7 @@ import LazyLoadingDisabledDialog from "./components/views/dialogs/LazyLoadingDis
 import SessionRestoreErrorDialog from "./components/views/dialogs/SessionRestoreErrorDialog";
 import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialog";
 import { setSentryUser } from "./sentry";
+import { IRenewedMatrixClientCreds, TokenLifecycle } from "./TokenLifecycle";
 
 const HOMESERVER_URL_KEY = "mx_hs_url";
 const ID_SERVER_URL_KEY = "mx_is_url";
@@ -203,6 +204,7 @@ export function attemptTokenLogin(
         "m.login.token", {
             token: queryParams.loginToken as string,
             initial_device_display_name: defaultDeviceDisplayName,
+            refresh_token: TokenLifecycle.instance.isFeasible,
         },
     ).then(function(creds) {
         logger.log("Logged in with token");
@@ -309,6 +311,8 @@ export interface IStoredSession {
     userId: string;
     deviceId: string;
     isGuest: boolean;
+    accessTokenExpiryTs?: number; // set if the token expires
+    accessTokenRefreshToken?: string | IEncryptedPayload; // set if the token can be renewed
 }
 
 /**
@@ -319,7 +323,7 @@ export interface IStoredSession {
 export async function getStoredSessionVars(): Promise<IStoredSession> {
     const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY);
     const isUrl = localStorage.getItem(ID_SERVER_URL_KEY);
-    let accessToken;
+    let accessToken: string;
     try {
         accessToken = await StorageManager.idbLoad("account", "mx_access_token");
     } catch (e) {
@@ -337,6 +341,43 @@ export async function getStoredSessionVars(): Promise<IStoredSession> {
             }
         }
     }
+
+    let accessTokenExpiryTs: number;
+    let accessTokenRefreshToken: string;
+    if (accessToken) {
+        const expiration = localStorage.getItem("mx_access_token_expires_ts");
+        if (expiration) accessTokenExpiryTs = Number(expiration);
+
+        if (localStorage.getItem("mx_has_refresh_token")) {
+            try {
+                accessTokenRefreshToken = await StorageManager.idbLoad(
+                    "account", "mx_refresh_token",
+                );
+            } catch (e) {
+                logger.warn(
+                    "StorageManager.idbLoad failed for account:mx_refresh_token " +
+                    "(presuming no refresh token)",
+                    e,
+                );
+            }
+
+            if (!accessTokenRefreshToken) {
+                accessTokenRefreshToken = localStorage.getItem("mx_refresh_token");
+                if (accessTokenRefreshToken) {
+                    try {
+                        // try to migrate refresh token to IndexedDB if we can
+                        await StorageManager.idbSave(
+                            "account", "mx_refresh_token", accessTokenRefreshToken,
+                        );
+                        localStorage.removeItem("mx_refresh_token");
+                    } catch (e) {
+                        logger.error("migration of refresh token to IndexedDB failed", e);
+                    }
+                }
+            }
+        }
+    }
+
     // if we pre-date storing "mx_has_access_token", but we retrieved an access
     // token, then we should say we have an access token
     const hasAccessToken =
@@ -352,7 +393,17 @@ export async function getStoredSessionVars(): Promise<IStoredSession> {
         isGuest = localStorage.getItem("matrix-is-guest") === "true";
     }
 
-    return { hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest };
+    return {
+        hsUrl,
+        isUrl,
+        hasAccessToken,
+        accessToken,
+        accessTokenExpiryTs,
+        accessTokenRefreshToken,
+        userId,
+        deviceId,
+        isGuest,
+    };
 }
 
 // The pickle key is a string of unspecified length and format.  For AES, we
@@ -391,6 +442,41 @@ async function abortLogin() {
     }
 }
 
+export async function getRenewedStoredSessionVars(): Promise<IRenewedMatrixClientCreds> {
+    const {
+        userId,
+        deviceId,
+        accessToken,
+        accessTokenExpiryTs,
+        accessTokenRefreshToken,
+    } = await getStoredSessionVars();
+
+    let decryptedAccessToken = accessToken;
+    let decryptedRefreshToken = accessTokenRefreshToken;
+    const pickleKey = await PlatformPeg.get().getPickleKey(userId, deviceId);
+    if (pickleKey) {
+        logger.log("Got pickle key");
+        if (typeof accessToken !== "string") {
+            const encrKey = await pickleKeyToAesKey(pickleKey);
+            decryptedAccessToken = await decryptAES(accessToken, encrKey, "access_token");
+            encrKey.fill(0);
+        }
+        if (accessTokenRefreshToken && typeof accessTokenRefreshToken !== "string") {
+            const encrKey = await pickleKeyToAesKey(pickleKey);
+            decryptedRefreshToken = await decryptAES(accessTokenRefreshToken, encrKey, "refresh_token");
+            encrKey.fill(0);
+        }
+    } else {
+        logger.log("No pickle key available");
+    }
+
+    return {
+        accessToken: decryptedAccessToken as string,
+        accessTokenExpiryTs: accessTokenExpiryTs,
+        accessTokenRefreshToken: decryptedRefreshToken as string,
+    };
+}
+
 // returns a promise which resolves to true if a session is found in
 // localstorage
 //
@@ -408,7 +494,16 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }):
         return false;
     }
 
-    const { hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest } = await getStoredSessionVars();
+    const {
+        hsUrl,
+        isUrl,
+        hasAccessToken,
+        accessToken,
+        userId,
+        deviceId,
+        isGuest,
+        accessTokenExpiryTs,
+    } = await getStoredSessionVars();
 
     if (hasAccessToken && !accessToken) {
         abortLogin();
@@ -420,18 +515,11 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }):
             return false;
         }
 
-        let decryptedAccessToken = accessToken;
         const pickleKey = await PlatformPeg.get().getPickleKey(userId, deviceId);
-        if (pickleKey) {
-            logger.log("Got pickle key");
-            if (typeof accessToken !== "string") {
-                const encrKey = await pickleKeyToAesKey(pickleKey);
-                decryptedAccessToken = await decryptAES(accessToken, encrKey, "access_token");
-                encrKey.fill(0);
-            }
-        } else {
-            logger.log("No pickle key available");
-        }
+        const {
+            accessToken: decryptedAccessToken,
+            accessTokenRefreshToken: decryptedRefreshToken,
+        } = await getRenewedStoredSessionVars();
 
         const freshLogin = sessionStorage.getItem("mx_fresh_login") === "true";
         sessionStorage.removeItem("mx_fresh_login");
@@ -446,6 +534,8 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }):
             guest: isGuest,
             pickleKey: pickleKey,
             freshLogin: freshLogin,
+            accessTokenExpiryTs: accessTokenExpiryTs,
+            accessTokenRefreshToken: decryptedRefreshToken as string,
         }, false);
         return true;
     } else {
@@ -511,12 +601,10 @@ export async function setLoggedIn(credentials: IMatrixClientCreds): Promise<Matr
  *
  * If the credentials belong to a different user from the session already stored,
  * the old session will be cleared automatically.
- *
- * @param {MatrixClientCreds} credentials The credentials to use
- *
+ * @param {IMatrixClientCreds} credentials The credentials to use
  * @returns {Promise} promise which resolves to the new MatrixClient once it has been started
  */
-export function hydrateSession(credentials: IMatrixClientCreds): Promise<MatrixClient> {
+export async function hydrateSession(credentials: IMatrixClientCreds): Promise<MatrixClient> {
     const oldUserId = MatrixClientPeg.get().getUserId();
     const oldDeviceId = MatrixClientPeg.get().getDeviceId();
 
@@ -529,9 +617,42 @@ export function hydrateSession(credentials: IMatrixClientCreds): Promise<MatrixC
         logger.warn("Clearing all data: Old session belongs to a different user/session");
     }
 
+    if (!credentials.pickleKey) {
+        logger.info("Lifecycle#hydrateSession: Pickle key not provided - trying to get one");
+        credentials.pickleKey = await PlatformPeg.get().getPickleKey(credentials.userId, credentials.deviceId);
+    }
+
     return doSetLoggedIn(credentials, overwrite);
 }
 
+/**
+ * Similar to hydrateSession(), this will update the credentials used by the current
+ * session in-place. Services will not be restarted, and storage will not be deleted.
+ * @param {IMatrixClientCreds} credentials The credentials to use
+ * @returns {Promise} promise which resolves to the new MatrixClient once it has been started
+ */
+export async function hydrateSessionInPlace(credentials: IMatrixClientCreds): Promise<MatrixClient> {
+    const oldUserId = MatrixClientPeg.get().getUserId();
+    const oldDeviceId = MatrixClientPeg.get().getDeviceId();
+    if (credentials.userId !== oldUserId || credentials.deviceId !== oldDeviceId) {
+        throw new Error("Attempted to hydrate in-place with a different session");
+    }
+
+    const cli = MatrixClientPeg.get();
+    if (!cli) {
+        throw new Error("Attempted to hydrate a non-existent MatrixClient");
+    }
+
+    logger.info("Lifecycle#hydrateInPlace: Persisting credentials and updating access token");
+    await persistCredentials(credentials);
+    MatrixClientPeg.updateUsingCreds(credentials);
+
+    // reset the token timers
+    TokenLifecycle.instance.startTimers(credentials);
+
+    return cli;
+}
+
 /**
  * fires on_logging_in, optionally clears localstorage, persists new credentials
  * to localstorage, starts the new client.
@@ -554,8 +675,10 @@ async function doSetLoggedIn(
         " deviceId: " + credentials.deviceId +
         " guest: " + credentials.guest +
         " hs: " + credentials.homeserverUrl +
-        " softLogout: " + softLogout,
-        " freshLogin: " + credentials.freshLogin,
+        " softLogout: " + softLogout +
+        " freshLogin: " + credentials.freshLogin +
+        " tokenExpires: " + (!!credentials.accessTokenExpiryTs) +
+        " tokenRenewable: " + (!!credentials.accessTokenRefreshToken),
     );
 
     // This is dispatched to indicate that the user is still in the process of logging in
@@ -583,6 +706,29 @@ async function doSetLoggedIn(
 
     MatrixClientPeg.replaceUsingCreds(credentials);
 
+    // Check the token's renewal early so we don't have to undo some of the work down below.
+    logger.info("Lifecycle#doSetLoggedIn: Trying token refresh in case it is needed");
+    let didTokenRefresh = false;
+    try {
+        const result = await TokenLifecycle.instance.tryTokenExchangeIfNeeded(credentials, MatrixClientPeg.get());
+        if (result) {
+            logger.info("Lifecycle#doSetLoggedIn: Token refresh successful, using credentials");
+            credentials.accessToken = result.accessToken;
+            credentials.accessTokenExpiryTs = result.accessTokenExpiryTs;
+            credentials.accessTokenRefreshToken = result.accessTokenRefreshToken;
+
+            // don't forget to replace the client with the new credentials
+            MatrixClientPeg.replaceUsingCreds(credentials);
+
+            didTokenRefresh = true;
+        } else {
+            logger.info("Lifecycle#doSetLoggedIn: Token refresh indicated as not needed");
+        }
+    } catch (e) {
+        logger.error("Lifecycle#doSetLoggedIn: Failed to exchange token", e);
+        await abortLogin();
+    }
+
     setSentryUser(credentials.userId);
 
     if (PosthogAnalytics.instance.isEnabled()) {
@@ -605,8 +751,12 @@ async function doSetLoggedIn(
     if (localStorage) {
         try {
             await persistCredentials(credentials);
-            // make sure we don't think that it's a fresh login any more
+            // make sure we don't think that it's a fresh login anymore
             sessionStorage.removeItem("mx_fresh_login");
+
+            if (didTokenRefresh) {
+                TokenLifecycle.instance.flagNewCredentialsPersisted();
+            }
         } catch (e) {
             logger.warn("Error using local storage: can't persist session!", e);
         }
@@ -614,6 +764,9 @@ async function doSetLoggedIn(
         logger.warn("No local storage available: can't persist session!");
     }
 
+    // Start the token lifecycle as late as possible in case something above goes wrong
+    TokenLifecycle.instance.startTimers(credentials);
+
     dis.dispatch({ action: 'on_logged_in' });
 
     await startMatrixClient(/*startSyncing=*/!softLogout);
@@ -640,20 +793,44 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise<void
     localStorage.setItem("mx_user_id", credentials.userId);
     localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest));
 
+    if (credentials.accessTokenExpiryTs) {
+        localStorage.setItem("mx_access_token_expires_ts", credentials.accessTokenExpiryTs.toString());
+    }
+
     // store whether we expect to find an access token, to detect the case
     // where IndexedDB is blown away
     if (credentials.accessToken) {
         localStorage.setItem("mx_has_access_token", "true");
     } else {
-        localStorage.deleteItem("mx_has_access_token");
+        localStorage.removeItem("mx_has_access_token");
+    }
+
+    // store a similar flag for the refresh token
+    if (credentials.accessTokenRefreshToken) {
+        localStorage.setItem("mx_has_refresh_token", "true");
+    } else {
+        localStorage.removeItem("mx_has_refresh_token");
+        localStorage.removeItem("mx_refresh_token");
+
+        try {
+            await StorageManager.idbDelete("account", "mx_refresh_token");
+        } catch (e) {
+            // ignore - no action needed
+        }
     }
 
     if (credentials.pickleKey) {
-        let encryptedAccessToken;
+        let encryptedAccessToken: IEncryptedPayload;
+        let encryptedRefreshToken: IEncryptedPayload;
         try {
             // try to encrypt the access token using the pickle key
             const encrKey = await pickleKeyToAesKey(credentials.pickleKey);
             encryptedAccessToken = await encryptAES(credentials.accessToken, encrKey, "access_token");
+            if (credentials.accessTokenRefreshToken) {
+                encryptedRefreshToken = await encryptAES(
+                    credentials.accessTokenRefreshToken, encrKey, "refresh_token",
+                );
+            }
             encrKey.fill(0);
         } catch (e) {
             logger.warn("Could not encrypt access token", e);
@@ -666,11 +843,20 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise<void
                 "account", "mx_access_token",
                 encryptedAccessToken || credentials.accessToken,
             );
+            if (encryptedRefreshToken) {
+                await StorageManager.idbSave(
+                    "account", "mx_refresh_token",
+                    encryptedRefreshToken || credentials.accessTokenRefreshToken,
+                );
+            }
         } catch (e) {
             // if we couldn't save to indexedDB, fall back to localStorage.  We
             // store the access token unencrypted since localStorage only saves
             // strings.
             localStorage.setItem("mx_access_token", credentials.accessToken);
+            if (credentials.accessTokenRefreshToken) {
+                localStorage.setItem("mx_refresh_token", credentials.accessTokenRefreshToken);
+            }
         }
         localStorage.setItem("mx_has_pickle_key", String(true));
     } else {
@@ -681,6 +867,15 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise<void
         } catch (e) {
             localStorage.setItem("mx_access_token", credentials.accessToken);
         }
+        if (credentials.accessTokenRefreshToken) {
+            try {
+                await StorageManager.idbSave(
+                    "account", "mx_refresh_token", credentials.accessTokenRefreshToken,
+                );
+            } catch (e) {
+                localStorage.setItem("mx_refresh_token", credentials.accessTokenRefreshToken);
+            }
+        }
         if (localStorage.getItem("mx_has_pickle_key")) {
             logger.error("Expected a pickle key, but none provided.  Encryption may not work.");
         }
@@ -891,6 +1086,7 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise<void
  * on MatrixClientPeg after stopping.
  */
 export function stopMatrixClient(unsetClient = true): void {
+    TokenLifecycle.instance.stopTimers();
     Notifier.stop();
     CallHandler.instance.stop();
     UserActivity.sharedInstance().stop();
diff --git a/src/Login.ts b/src/Login.ts
index f7b188c64a..cf88b7814d 100644
--- a/src/Login.ts
+++ b/src/Login.ts
@@ -22,6 +22,7 @@ import { logger } from "matrix-js-sdk/src/logger";
 
 import { IMatrixClientCreds } from "./MatrixClientPeg";
 import SecurityCustomisations from "./customisations/Security";
+import { TokenLifecycle } from "./TokenLifecycle";
 
 interface ILoginOptions {
     defaultDeviceDisplayName?: string;
@@ -64,6 +65,11 @@ interface ILoginParams {
     token?: string;
     device_id?: string;
     initial_device_display_name?: string;
+
+    // If true, a refresh token will be requested. If the server supports it, it
+    // will be returned. Does nothing out of the ordinary if not set, false, or
+    // the server doesn't support the feature.
+    refresh_token?: boolean;
 }
 /* eslint-enable camelcase */
 
@@ -162,6 +168,7 @@ export default class Login {
             password,
             identifier,
             initial_device_display_name: this.defaultDeviceDisplayName,
+            refresh_token: TokenLifecycle.instance.isFeasible,
         };
 
         const tryFallbackHs = (originalError) => {
@@ -235,6 +242,9 @@ export async function sendLoginRequest(
         userId: data.user_id,
         deviceId: data.device_id,
         accessToken: data.access_token,
+        // Use the browser's local time for expiration timestamp - see TokenLifecycle for more info
+        accessTokenExpiryTs: data.expires_in_ms ? (data.expires_in_ms + Date.now()) : null,
+        accessTokenRefreshToken: data.refresh_token,
     };
 
     SecurityCustomisations.examineLoginResponse?.(data, creds);
diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts
index 42a54e3ca1..fefba6c117 100644
--- a/src/MatrixClientPeg.ts
+++ b/src/MatrixClientPeg.ts
@@ -44,6 +44,8 @@ export interface IMatrixClientCreds {
     userId: string;
     deviceId?: string;
     accessToken: string;
+    accessTokenExpiryTs?: number; // set if access token expires
+    accessTokenRefreshToken?: string; // set if access token can be renewed
     guest?: boolean;
     pickleKey?: string;
     freshLogin?: boolean;
@@ -99,6 +101,14 @@ export interface IMatrixClientPeg {
      * @param {IMatrixClientCreds} creds The new credentials to use.
      */
     replaceUsingCreds(creds: IMatrixClientCreds): void;
+
+    /**
+     * Similar to replaceUsingCreds(), but without the replacement operation.
+     * Credentials that can be updated in-place will be updated. All others
+     * will be ignored.
+     * @param {IMatrixClientCreds} creds The new credentials to use.
+     */
+    updateUsingCreds(creds: IMatrixClientCreds): void;
 }
 
 /**
@@ -164,6 +174,15 @@ class MatrixClientPegClass implements IMatrixClientPeg {
         this.createClient(creds);
     }
 
+    public updateUsingCreds(creds: IMatrixClientCreds): void {
+        if (creds?.accessToken) {
+            this.currentClientCreds = creds;
+            this.matrixClient.setAccessToken(creds.accessToken);
+        } else {
+            // ignore, per signature
+        }
+    }
+
     public async assign(): Promise<any> {
         for (const dbType of ['indexeddb', 'memory']) {
             try {
@@ -233,7 +252,15 @@ class MatrixClientPegClass implements IMatrixClientPeg {
     }
 
     public getCredentials(): IMatrixClientCreds {
+        let copiedCredentials = this.currentClientCreds;
+        if (this.currentClientCreds?.userId !== this.matrixClient?.credentials?.userId) {
+            // cached credentials belong to a different user - don't use them
+            copiedCredentials = null;
+        }
         return {
+            // Copy the cached credentials before overriding what we can.
+            ...(copiedCredentials ?? {}),
+
             homeserverUrl: this.matrixClient.baseUrl,
             identityServerUrl: this.matrixClient.idBaseUrl,
             userId: this.matrixClient.credentials.userId,
diff --git a/src/TokenLifecycle.ts b/src/TokenLifecycle.ts
new file mode 100644
index 0000000000..e5d3a2d516
--- /dev/null
+++ b/src/TokenLifecycle.ts
@@ -0,0 +1,233 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { logger } from "matrix-js-sdk/src/logger";
+import { MatrixClient } from "matrix-js-sdk/src";
+import { randomString } from "matrix-js-sdk/src/randomstring";
+import Mutex from "idb-mutex";
+import { Optional } from "matrix-events-sdk";
+
+import { IMatrixClientCreds, MatrixClientPeg } from "./MatrixClientPeg";
+import { getRenewedStoredSessionVars, hydrateSessionInPlace } from "./Lifecycle";
+import { IDB_SUPPORTED } from "./utils/StorageManager";
+
+export interface IRenewedMatrixClientCreds extends Pick<IMatrixClientCreds,
+    "accessToken" | "accessTokenExpiryTs" | "accessTokenRefreshToken"> {}
+
+const LOCALSTORAGE_UPDATED_BY_KEY = "mx_token_updated_by";
+
+const CLIENT_ID = randomString(64);
+
+export class TokenLifecycle {
+    public static readonly instance = new TokenLifecycle();
+
+    private refreshAtTimerId: number;
+    private mutex: Mutex;
+
+    protected constructor() {
+        // we only really want one of these floating around, so private-ish
+        // constructor. Protected allows for unit tests.
+
+        // Don't try to create a mutex if it'll explode
+        if (IDB_SUPPORTED) {
+            this.mutex = new Mutex("token_refresh", null, {
+                expiry: 120000, // 2 minutes - enough time for the refresh request to time out
+            });
+        }
+
+        // Watch for other tabs causing token refreshes, so we can react to them too.
+        window.addEventListener("storage", (ev: StorageEvent) => {
+            if (ev.key === LOCALSTORAGE_UPDATED_BY_KEY) {
+                const updateBy = localStorage.getItem(LOCALSTORAGE_UPDATED_BY_KEY);
+                if (!updateBy || updateBy === CLIENT_ID) return; // ignore deletions & echos
+
+                logger.info("TokenLifecycle#storageWatch: Token update received");
+
+                // noinspection JSIgnoredPromiseFromCall
+                this.forceHydration();
+            }
+        });
+    }
+
+    /**
+     * Can the client reasonably support token refreshes?
+     */
+    public get isFeasible(): boolean {
+        return IDB_SUPPORTED;
+    }
+
+    // noinspection JSMethodCanBeStatic
+    private get fiveMinutesAgo(): number {
+        return Date.now() - 300000;
+    }
+
+    // noinspection JSMethodCanBeStatic
+    private get fiveMinutesFromNow(): number {
+        return Date.now() + 300000;
+    }
+
+    public flagNewCredentialsPersisted() {
+        logger.info("TokenLifecycle#flagPersisted: Credentials marked as persisted - flagging for other tabs");
+        if (localStorage.getItem(LOCALSTORAGE_UPDATED_BY_KEY) !== CLIENT_ID) {
+            localStorage.setItem(LOCALSTORAGE_UPDATED_BY_KEY, CLIENT_ID);
+        }
+    }
+
+    /**
+     * Attempts a token renewal, if renewal is needed/possible. If renewal is not possible
+     * then this will return falsy. Otherwise, the new token's details (credentials) will
+     * be returned or an error if something went wrong.
+     * @param {IMatrixClientCreds} credentials The input credentials.
+     * @param {MatrixClient} client A client set up with those credentials.
+     * @returns {Promise<Optional<IRenewedMatrixClientCreds>>} Resolves to the new credentials,
+     * or falsy if renewal not possible/needed. Throws on error.
+     */
+    public async tryTokenExchangeIfNeeded(
+        credentials: IMatrixClientCreds,
+        client: MatrixClient,
+    ): Promise<Optional<IRenewedMatrixClientCreds>> {
+        if (!credentials.accessTokenExpiryTs && credentials.accessTokenRefreshToken) {
+            logger.warn(
+                "TokenLifecycle#tryExchange: Got a refresh token, but no expiration time. The server is " +
+                "not compliant with the specification and might result in unexpected logouts.",
+            );
+        }
+
+        if (!this.isFeasible) {
+            logger.warn("TokenLifecycle#tryExchange: Client cannot do token refreshes reliably");
+            return;
+        }
+
+        if (credentials.accessTokenExpiryTs && credentials.accessTokenRefreshToken) {
+            if (this.fiveMinutesAgo >= credentials.accessTokenExpiryTs) {
+                logger.info("TokenLifecycle#tryExchange: Token has or will expire soon, refreshing");
+                return await this.doTokenRefresh(credentials, client);
+            }
+        }
+    }
+
+    // noinspection JSMethodCanBeStatic
+    private async doTokenRefresh(
+        credentials: IMatrixClientCreds,
+        client: MatrixClient,
+    ): Promise<Optional<IRenewedMatrixClientCreds>> {
+        try {
+            logger.info("TokenLifecycle#doRefresh: Acquiring lock");
+            await this.mutex.lock();
+            logger.info("TokenLifecycle#doRefresh: Lock acquired");
+
+            logger.info("TokenLifecycle#doRefresh: Performing refresh");
+            localStorage.removeItem(LOCALSTORAGE_UPDATED_BY_KEY);
+            const newCreds = await client.refreshToken(credentials.accessTokenRefreshToken);
+            return {
+                // We use the browser's local time to do two things:
+                // 1. Avoid having to write code that counts down and stores a "time left" variable
+                // 2. Work around any time drift weirdness by assuming the user's local machine will
+                //    drift consistently with itself.
+                // We additionally add our own safety buffer when renewing tokens to avoid cases where
+                // the time drift is accelerating.
+                accessTokenExpiryTs: Date.now() + newCreds.expires_in_ms,
+                accessToken: newCreds.access_token,
+                accessTokenRefreshToken: newCreds.refresh_token,
+            };
+        } catch (e) {
+            logger.error("TokenLifecycle#doRefresh: Error refreshing token: ", e);
+            if (e.errcode === "M_UNKNOWN_TOKEN") {
+                // Emit the logout manually because the function inhibits it.
+                client.emit("Session.logged_out", e);
+            } else {
+                throw e; // we can't do anything with it, so re-throw
+            }
+        } finally {
+            logger.info("TokenLifecycle#doRefresh: Releasing lock");
+            await this.mutex.unlock();
+        }
+    }
+
+    public startTimers(credentials: IMatrixClientCreds) {
+        this.stopTimers();
+
+        if (!credentials.accessTokenExpiryTs && credentials.accessTokenRefreshToken) {
+            logger.warn(
+                "TokenLifecycle#start: Got a refresh token, but no expiration time. The server is " +
+                "not compliant with the specification and might result in unexpected logouts.",
+            );
+        }
+
+        if (!this.isFeasible) {
+            logger.warn("TokenLifecycle#start: Not starting refresh timers - browser unsupported");
+        }
+
+        if (credentials.accessTokenExpiryTs && credentials.accessTokenRefreshToken) {
+            // We schedule the refresh task for 5 minutes before the expiration timestamp as
+            // a safety buffer. We assume/hope that servers won't be expiring tokens faster
+            // than every 5 minutes, but we do need to consider cases where the expiration is
+            // fairly quick (<10 minutes, for example).
+            let relativeTime = credentials.accessTokenExpiryTs - this.fiveMinutesFromNow;
+            if (relativeTime <= 0) {
+                logger.warn(`TokenLifecycle#start: Refresh was set for ${relativeTime}ms - readjusting`);
+                relativeTime = Math.floor(Math.random() * 5000) + 30000; // 30 seconds + 5s jitter
+            }
+            this.refreshAtTimerId = setTimeout(() => {
+                // noinspection JSIgnoredPromiseFromCall
+                this.forceTokenExchange();
+            }, relativeTime);
+            logger.info(`TokenLifecycle#start: Refresh timer set for ${relativeTime}ms from now`);
+        } else {
+            logger.info("TokenLifecycle#start: Not setting a refresh timer - token not renewable");
+        }
+    }
+
+    public stopTimers() {
+        clearTimeout(this.refreshAtTimerId);
+        logger.info("TokenLifecycle#stop: Stopped refresh timer (if it was running)");
+    }
+
+    private async forceTokenExchange() {
+        const credentials = MatrixClientPeg.getCredentials();
+        await this.rehydrate(await this.doTokenRefresh(credentials, MatrixClientPeg.get()));
+        this.flagNewCredentialsPersisted();
+    }
+
+    private async forceHydration() {
+        const {
+            accessToken,
+            accessTokenRefreshToken,
+            accessTokenExpiryTs,
+        } = await getRenewedStoredSessionVars();
+        return this.rehydrate({ accessToken, accessTokenRefreshToken, accessTokenExpiryTs });
+    }
+
+    private async rehydrate(newCreds: IRenewedMatrixClientCreds) {
+        const credentials = MatrixClientPeg.getCredentials();
+        try {
+            if (!newCreds) {
+                logger.error("TokenLifecycle#expireExchange: Expecting new credentials, got nothing. Rescheduling.");
+                this.startTimers(credentials);
+            } else {
+                logger.info("TokenLifecycle#expireExchange: Updating client credentials using rehydration");
+                await hydrateSessionInPlace({
+                    ...credentials,
+                    ...newCreds, // override from credentials
+                });
+                // hydrateSessionInPlace will ultimately call back to startTimers() for us, so no need to do it here.
+            }
+        } catch (e) {
+            logger.error("TokenLifecycle#expireExchange: Error getting new credentials. Rescheduling.", e);
+            this.startTimers(credentials);
+        }
+    }
+}
diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx
index 828ca8d79d..8288198e9a 100644
--- a/src/components/structures/auth/Registration.tsx
+++ b/src/components/structures/auth/Registration.tsx
@@ -37,6 +37,7 @@ import AuthBody from "../../views/auth/AuthBody";
 import AuthHeader from "../../views/auth/AuthHeader";
 import InteractiveAuth from "../InteractiveAuth";
 import Spinner from "../../views/elements/Spinner";
+import { TokenLifecycle } from "../../../TokenLifecycle";
 
 interface IProps {
     serverConfig: ValidatedServerConfig;
@@ -415,6 +416,7 @@ export default class Registration extends React.Component<IProps, IState> {
             initial_device_display_name: this.props.defaultDeviceDisplayName,
             auth: undefined,
             inhibit_login: undefined,
+            refresh_token: TokenLifecycle.instance.isFeasible,
         };
         if (auth) registerParams.auth = auth;
         if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin;
diff --git a/src/components/structures/auth/SoftLogout.tsx b/src/components/structures/auth/SoftLogout.tsx
index 86e6711359..757240256f 100644
--- a/src/components/structures/auth/SoftLogout.tsx
+++ b/src/components/structures/auth/SoftLogout.tsx
@@ -33,6 +33,7 @@ import AccessibleButton from '../../views/elements/AccessibleButton';
 import Spinner from "../../views/elements/Spinner";
 import AuthHeader from "../../views/auth/AuthHeader";
 import AuthBody from "../../views/auth/AuthBody";
+import { TokenLifecycle } from "../../../TokenLifecycle";
 
 const LOGIN_VIEW = {
     LOADING: 1,
@@ -154,6 +155,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
             },
             password: this.state.password,
             device_id: MatrixClientPeg.get().getDeviceId(),
+            refresh_token: TokenLifecycle.instance.isFeasible,
         };
 
         let credentials = null;
@@ -187,6 +189,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
         const loginParams = {
             token: this.props.realQueryParams['loginToken'],
             device_id: MatrixClientPeg.get().getDeviceId(),
+            refresh_token: TokenLifecycle.instance.isFeasible,
         };
 
         let credentials = null;
diff --git a/src/utils/StorageManager.ts b/src/utils/StorageManager.ts
index 7d9ce885f7..16fe6bd030 100644
--- a/src/utils/StorageManager.ts
+++ b/src/utils/StorageManager.ts
@@ -25,11 +25,13 @@ const localStorage = window.localStorage;
 
 // just *accessing* indexedDB throws an exception in firefox with
 // indexeddb disabled.
-let indexedDB;
+let indexedDB: IDBFactory;
 try {
     indexedDB = window.indexedDB;
 } catch (e) {}
 
+export const IDB_SUPPORTED = !!indexedDB;
+
 // The JS SDK will add a prefix of "matrix-js-sdk:" to the sync store name.
 const SYNC_STORE_NAME = "riot-web-sync";
 const CRYPTO_STORE_NAME = "matrix-js-sdk:crypto";
@@ -197,7 +199,7 @@ export function setCryptoInitialised(cryptoInited) {
 /* Simple wrapper functions around IndexedDB.
  */
 
-let idb = null;
+let idb: IDBDatabase = null;
 
 async function idbInit(): Promise<void> {
     if (!indexedDB) {
diff --git a/yarn.lock b/yarn.lock
index ce8543a251..981bac5d75 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4750,6 +4750,11 @@ iconv-lite@^0.6.2:
   dependencies:
     safer-buffer ">= 2.1.2 < 3.0.0"
 
+idb-mutex@^0.11.0:
+  version "0.11.0"
+  resolved "https://registry.yarnpkg.com/idb-mutex/-/idb-mutex-0.11.0.tgz#1573321f74ab83c12c3d200c7cf22ee7c6800d2d"
+  integrity sha512-jirzMahSlkvNpq9MXzr5uBKjxQrA9gdPYhOJkQXhDW7MvP6RuJpSbog50HYOugkmZWfJ0WmHVhhX0/lG39qOZQ==
+
 ieee754@^1.1.12, ieee754@^1.1.13:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"