diff --git a/keyserver/src/keyserver.js b/keyserver/src/keyserver.js
--- a/keyserver/src/keyserver.js
+++ b/keyserver/src/keyserver.js
@@ -35,7 +35,10 @@
 } from './responders/website-responders.js';
 import { webWorkerResponder } from './responders/webworker-responders.js';
 import { onConnection } from './socket/socket.js';
-import { createAndMaintainTunnelbrokerWebsocket } from './socket/tunnelbroker.js';
+import {
+  createAndMaintainTunnelbrokerWebsocket,
+  createAndMaintainAnonymousTunnelbrokerWebsocket,
+} from './socket/tunnelbroker.js';
 import {
   multerProcessor,
   multimediaUploadResponder,
@@ -97,32 +100,13 @@
       process.exit(2);
     }
 
-    // Allow login to be optional until staging environment is available
-    try {
-      // We await here to ensure that the keyserver has been provisioned a
-      // commServicesAccessToken. In the future, this will be necessary for
-      // many keyserver operations.
-      const identityInfo = await verifyUserLoggedIn();
-      // We don't await here, as Tunnelbroker communication is not needed for
-      // normal keyserver behavior yet. In addition, this doesn't return
-      // information useful for other keyserver functions.
-      ignorePromiseRejections(
-        createAndMaintainTunnelbrokerWebsocket(identityInfo),
-      );
-      if (process.env.NODE_ENV === 'development') {
-        await createAuthoritativeKeyserverConfigFiles(identityInfo.userId);
-      }
-    } catch (e) {
-      console.warn(
-        'Failed identity login. Login optional until staging environment is available',
-      );
-    }
-
     if (shouldDisplayQRCodeInTerminal) {
       try {
         const aes256Key = crypto.randomBytes(32).toString('hex');
         const ed25519Key = await getContentSigningKey();
 
+        await createAndMaintainAnonymousTunnelbrokerWebsocket(aes256Key);
+
         console.log(
           '\nOpen the Comm app on your phone and scan the QR code below\n',
         );
@@ -136,6 +120,27 @@
       } catch (e) {
         console.log('Error generating QR code', e);
       }
+    } else {
+      // Allow login to be optional until staging environment is available
+      try {
+        // We await here to ensure that the keyserver has been provisioned a
+        // commServicesAccessToken. In the future, this will be necessary for
+        // many keyserver operations.
+        const identityInfo = await verifyUserLoggedIn();
+        // We don't await here, as Tunnelbroker communication is not needed for
+        // normal keyserver behavior yet. In addition, this doesn't return
+        // information useful for other keyserver functions.
+        ignorePromiseRejections(
+          createAndMaintainTunnelbrokerWebsocket(identityInfo),
+        );
+        if (process.env.NODE_ENV === 'development') {
+          await createAuthoritativeKeyserverConfigFiles(identityInfo.userId);
+        }
+      } catch (e) {
+        console.warn(
+          'Failed identity login. Login optional until staging environment is available',
+        );
+      }
     }
 
     if (!isCPUProfilingEnabled) {
diff --git a/keyserver/src/socket/tunnelbroker-socket.js b/keyserver/src/socket/tunnelbroker-socket.js
--- a/keyserver/src/socket/tunnelbroker-socket.js
+++ b/keyserver/src/socket/tunnelbroker-socket.js
@@ -4,6 +4,7 @@
 import uuid from 'uuid';
 import WebSocket from 'ws';
 
+import { hexToUintArray } from 'lib/media/data-utils.js';
 import { tunnelbrokerHeartbeatTimeout } from 'lib/shared/timeouts.js';
 import type { TunnelbrokerClientMessageToDevice } from 'lib/tunnelbroker/tunnelbroker-context.js';
 import type { MessageReceiveConfirmation } from 'lib/types/tunnelbroker/message-receive-confirmation-types.js';
@@ -15,12 +16,24 @@
   tunnelbrokerMessageValidator,
 } from 'lib/types/tunnelbroker/messages.js';
 import {
+  qrCodeAuthMessageValidator,
   type RefreshKeyRequest,
   refreshKeysRequestValidator,
+  type QRCodeAuthMessage,
 } from 'lib/types/tunnelbroker/peer-to-peer-message-types.js';
-import type { ConnectionInitializationMessage } from 'lib/types/tunnelbroker/session-types.js';
+import {
+  type QRCodeAuthMessagePayload,
+  qrCodeAuthMessagePayloadValidator,
+  qrCodeAuthMessageTypes,
+} from 'lib/types/tunnelbroker/qr-code-auth-message-types.js';
+import type {
+  ConnectionInitializationMessage,
+  AnonymousInitializationMessage,
+} from 'lib/types/tunnelbroker/session-types.js';
 import type { Heartbeat } from 'lib/types/websocket/heartbeat-types.js';
+import { convertBytesToObj } from 'lib/utils/conversion-utils.js';
 
+import { decrypt } from '../utils/aes-crypto-utils.js';
 import { uploadNewOneTimeKeys } from '../utils/olm-utils.js';
 
 type PromiseCallbacks = {
@@ -36,11 +49,16 @@
   promises: Promises = {};
   heartbeatTimeoutID: ?TimeoutID;
   oneTimeKeysPromise: ?Promise<void>;
+  anonymous: boolean = false;
+  qrAuthEncryptionKey: ?string;
 
   constructor(
     socketURL: string,
-    initMessage: ConnectionInitializationMessage,
+    initMessage:
+      | ConnectionInitializationMessage
+      | AnonymousInitializationMessage,
     onClose: () => mixed,
+    qrAuthEncryptionKey?: string,
   ) {
     const socket = new WebSocket(socketURL);
 
@@ -68,6 +86,10 @@
     socket.on('message', this.onMessage);
 
     this.ws = socket;
+    this.anonymous = !initMessage.accessToken;
+    if (qrAuthEncryptionKey) {
+      this.qrAuthEncryptionKey = qrAuthEncryptionKey;
+    }
   }
 
   onMessage: (event: ArrayBuffer) => Promise<void> = async (
@@ -95,7 +117,11 @@
     ) {
       if (message.status.type === 'Success' && !this.connected) {
         this.connected = true;
-        console.info('session with Tunnelbroker created');
+        console.info(
+          this.anonymous
+            ? 'anonymous session with Tunnelbroker created'
+            : 'session with Tunnelbroker created',
+        );
       } else if (message.status.type === 'Success' && this.connected) {
         console.info(
           'received ConnectionInitializationResponse with status: Success for already connected socket',
@@ -117,7 +143,17 @@
       const { payload } = message;
       try {
         const messageToKeyserver = JSON.parse(payload);
-        if (refreshKeysRequestValidator.is(messageToKeyserver)) {
+        if (qrCodeAuthMessageValidator.is(messageToKeyserver)) {
+          const request: QRCodeAuthMessage = messageToKeyserver;
+          const qrCodeAuthMessage = await this.parseQRCodeAuthMessage(request);
+          if (
+            !qrCodeAuthMessage ||
+            qrCodeAuthMessage.type !==
+              qrCodeAuthMessageTypes.DEVICE_LIST_UPDATE_SUCCESS
+          ) {
+            return;
+          }
+        } else if (refreshKeysRequestValidator.is(messageToKeyserver)) {
           const request: RefreshKeyRequest = messageToKeyserver;
           this.debouncedRefreshOneTimeKeys(request.numberOfKeys);
         }
@@ -212,6 +248,26 @@
       this.connected = false;
     }, tunnelbrokerHeartbeatTimeout);
   }
+
+  parseQRCodeAuthMessage: (
+    message: QRCodeAuthMessage,
+  ) => Promise<?QRCodeAuthMessagePayload> = async message => {
+    const encryptionKey = this.qrAuthEncryptionKey;
+    if (!encryptionKey) {
+      return null;
+    }
+    const encryptedData = Buffer.from(message.encryptedContent, 'base64');
+    const decryptedData = await decrypt(
+      hexToUintArray(encryptionKey),
+      new Uint8Array(encryptedData),
+    );
+    const payload = convertBytesToObj<QRCodeAuthMessagePayload>(decryptedData);
+    if (!qrCodeAuthMessagePayloadValidator.is(payload)) {
+      return null;
+    }
+
+    return payload;
+  };
 }
 
 export default TunnelbrokerSocket;
diff --git a/keyserver/src/socket/tunnelbroker.js b/keyserver/src/socket/tunnelbroker.js
--- a/keyserver/src/socket/tunnelbroker.js
+++ b/keyserver/src/socket/tunnelbroker.js
@@ -1,7 +1,10 @@
 // @flow
 
 import { clientTunnelbrokerSocketReconnectDelay } from 'lib/shared/timeouts.js';
-import type { ConnectionInitializationMessage } from 'lib/types/tunnelbroker/session-types.js';
+import type {
+  ConnectionInitializationMessage,
+  AnonymousInitializationMessage,
+} from 'lib/types/tunnelbroker/session-types.js';
 import { getCommConfig } from 'lib/utils/comm-config.js';
 import sleep from 'lib/utils/sleep.js';
 
@@ -45,13 +48,50 @@
     deviceType: 'keyserver',
   };
 
+  createAndMaintainTunnelbrokerWebsocketBase(tbConnectionInfo.url, initMessage);
+}
+
+async function createAndMaintainAnonymousTunnelbrokerWebsocket(
+  encryptionKey: string,
+) {
+  const [deviceID, tbConnectionInfo] = await Promise.all([
+    getContentSigningKey(),
+    getTBConnectionInfo(),
+  ]);
+
+  const initMessage: AnonymousInitializationMessage = {
+    type: 'AnonymousInitializationMessage',
+    deviceID: deviceID,
+    deviceType: 'keyserver',
+  };
+
+  createAndMaintainTunnelbrokerWebsocketBase(
+    tbConnectionInfo.url,
+    initMessage,
+    encryptionKey,
+  );
+}
+
+function createAndMaintainTunnelbrokerWebsocketBase(
+  url: string,
+  initMessage: ConnectionInitializationMessage | AnonymousInitializationMessage,
+  encryptionKey?: string,
+) {
   const createNewTunnelbrokerSocket = () => {
-    new TunnelbrokerSocket(tbConnectionInfo.url, initMessage, async () => {
-      await sleep(clientTunnelbrokerSocketReconnectDelay);
-      createNewTunnelbrokerSocket();
-    });
+    new TunnelbrokerSocket(
+      url,
+      initMessage,
+      async () => {
+        await sleep(clientTunnelbrokerSocketReconnectDelay);
+        createNewTunnelbrokerSocket();
+      },
+      encryptionKey,
+    );
   };
   createNewTunnelbrokerSocket();
 }
 
-export { createAndMaintainTunnelbrokerWebsocket };
+export {
+  createAndMaintainTunnelbrokerWebsocket,
+  createAndMaintainAnonymousTunnelbrokerWebsocket,
+};
diff --git a/lib/utils/conversion-utils.js b/lib/utils/conversion-utils.js
--- a/lib/utils/conversion-utils.js
+++ b/lib/utils/conversion-utils.js
@@ -153,9 +153,25 @@
   return input;
 }
 
+// NOTE: This function should not be called from native. On native, we should
+// use `convertObjToBytes` in native/backup/conversion-utils.js instead.
+function convertObjToBytes<T>(obj: T): Uint8Array {
+  const objStr = JSON.stringify(obj);
+  return new TextEncoder().encode(objStr ?? '');
+}
+
+// NOTE: This function should not be called from native. On native, we should
+// use `convertBytesToObj` in native/backup/conversion-utils.js instead.
+function convertBytesToObj<T>(bytes: Uint8Array): T {
+  const str = new TextDecoder().decode(bytes.buffer);
+  return JSON.parse(str);
+}
+
 export {
   convertClientIDsToServerIDs,
   convertServerIDsToClientIDs,
   extractUserIDsFromPayload,
   convertObject,
+  convertObjToBytes,
+  convertBytesToObj,
 };
diff --git a/lib/utils/conversion-utils.test.js b/lib/utils/conversion-utils.test.js
--- a/lib/utils/conversion-utils.test.js
+++ b/lib/utils/conversion-utils.test.js
@@ -7,6 +7,8 @@
   extractUserIDsFromPayload,
   convertServerIDsToClientIDs,
   convertClientIDsToServerIDs,
+  convertBytesToObj,
+  convertObjToBytes,
 } from './conversion-utils.js';
 import { tShape, tID, idSchemaRegex } from './validation-utils.js';
 import { fetchMessageInfosResponseValidator } from '../types/validators/message-validators.js';
@@ -119,3 +121,14 @@
     ).toEqual(['0', '1', '100', '200']);
   });
 });
+
+describe('convertObjToBytes and convertBytesToObj', () => {
+  it('should convert object to byte array and back', () => {
+    const obj = { hello: 'world', foo: 'bar', a: 2, b: false };
+
+    const bytes = convertObjToBytes(obj);
+    const restored = convertBytesToObj<typeof obj>(bytes);
+
+    expect(restored).toStrictEqual(obj);
+  });
+});
diff --git a/web/account/qr-code-login.react.js b/web/account/qr-code-login.react.js
--- a/web/account/qr-code-login.react.js
+++ b/web/account/qr-code-login.react.js
@@ -19,6 +19,10 @@
   qrCodeAuthMessagePayloadValidator,
   type QRCodeAuthMessagePayload,
 } from 'lib/types/tunnelbroker/qr-code-auth-message-types.js';
+import {
+  convertBytesToObj,
+  convertObjToBytes,
+} from 'lib/utils/conversion-utils.js';
 import { getContentSigningKey } from 'lib/utils/crypto-utils.js';
 import { getMessageForException } from 'lib/utils/errors.js';
 
@@ -29,10 +33,6 @@
   base64DecodeBuffer,
   base64EncodeBuffer,
 } from '../utils/base64-utils.js';
-import {
-  convertBytesToObj,
-  convertObjToBytes,
-} from '../utils/conversion-utils.js';
 
 async function composeTunnelbrokerMessage(
   encryptionKey: string,
diff --git a/web/utils/conversion-utils.js b/web/utils/conversion-utils.js
deleted file mode 100644
--- a/web/utils/conversion-utils.js
+++ /dev/null
@@ -1,13 +0,0 @@
-// @flow
-
-function convertObjToBytes<T>(obj: T): Uint8Array {
-  const objStr = JSON.stringify(obj);
-  return new TextEncoder().encode(objStr ?? '');
-}
-
-function convertBytesToObj<T>(bytes: Uint8Array): T {
-  const str = new TextDecoder().decode(bytes.buffer);
-  return JSON.parse(str);
-}
-
-export { convertObjToBytes, convertBytesToObj };
diff --git a/web/utils/conversion-utils.test.js b/web/utils/conversion-utils.test.js
deleted file mode 100644
--- a/web/utils/conversion-utils.test.js
+++ /dev/null
@@ -1,14 +0,0 @@
-// @flow
-
-import { convertBytesToObj, convertObjToBytes } from './conversion-utils.js';
-
-describe('convertObjToBytes and convertBytesToObj', () => {
-  it('should convert object to byte array and back', () => {
-    const obj = { hello: 'world', foo: 'bar', a: 2, b: false };
-
-    const bytes = convertObjToBytes(obj);
-    const restored = convertBytesToObj<typeof obj>(bytes);
-
-    expect(restored).toStrictEqual(obj);
-  });
-});