diff --git a/lib/components/qr-auth-handler.react.js b/lib/components/qr-auth-handler.react.js
--- a/lib/components/qr-auth-handler.react.js
+++ b/lib/components/qr-auth-handler.react.js
@@ -12,22 +12,35 @@
 import {
   peerToPeerMessageTypes,
   peerToPeerMessageValidator,
+  type QRCodeAuthMessage,
 } from '../types/tunnelbroker/peer-to-peer-message-types.js';
-import { qrCodeAuthMessageTypes } from '../types/tunnelbroker/qr-code-auth-message-types.js';
 import {
-  createQRAuthTunnelbrokerMessage,
-  parseQRAuthTunnelbrokerMessage,
-} from '../utils/qr-code-auth.js';
+  qrCodeAuthMessageTypes,
+  type QRCodeAuthMessagePayload,
+} from '../types/tunnelbroker/qr-code-auth-message-types.js';
 
 type QRAuthHandlerProps = {
   +secondaryDeviceID: ?string,
   +aesKey: ?string,
   +performSecondaryDeviceRegistration: (userID: string) => Promise<void>,
+  +composeMessage: (
+    encryptionKey: string,
+    payload: QRCodeAuthMessagePayload,
+  ) => Promise<QRCodeAuthMessage>,
+  +processMessage: (
+    encryptionKey: string,
+    message: QRCodeAuthMessage,
+  ) => Promise<?QRCodeAuthMessagePayload>,
 };
 
 function QRAuthHandler(props: QRAuthHandlerProps): React.Node {
-  const { secondaryDeviceID, aesKey, performSecondaryDeviceRegistration } =
-    props;
+  const {
+    secondaryDeviceID,
+    aesKey,
+    processMessage,
+    composeMessage,
+    performSecondaryDeviceRegistration,
+  } = props;
   const [primaryDeviceID, setPrimaryDeviceID] = React.useState<?string>();
   const {
     setUnauthorizedDeviceID,
@@ -53,7 +66,7 @@
     }
 
     void (async () => {
-      const message = createQRAuthTunnelbrokerMessage(aesKey, {
+      const message = await composeMessage(aesKey, {
         type: qrCodeAuthMessageTypes.SECONDARY_DEVICE_REGISTRATION_SUCCESS,
       });
       await sendMessage({
@@ -68,6 +81,7 @@
     primaryDeviceID,
     aesKey,
     secondaryDeviceID,
+    composeMessage,
   ]);
 
   const tunnelbrokerMessageListener = React.useCallback(
@@ -92,10 +106,7 @@
       ) {
         return;
       }
-      const qrCodeAuthMessage = parseQRAuthTunnelbrokerMessage(
-        aesKey,
-        innerMessage,
-      );
+      const qrCodeAuthMessage = await processMessage(aesKey, innerMessage);
 
       if (
         qrCodeAuthMessage?.type ===
@@ -123,6 +134,7 @@
       identityClient,
       aesKey,
       performSecondaryDeviceRegistration,
+      processMessage,
     ],
   );
 
diff --git a/lib/utils/qr-code-auth.js b/lib/utils/qr-code-auth.js
deleted file mode 100644
--- a/lib/utils/qr-code-auth.js
+++ /dev/null
@@ -1,34 +0,0 @@
-// @flow
-
-import {
-  peerToPeerMessageTypes,
-  type QRCodeAuthMessage,
-} from '../types/tunnelbroker/peer-to-peer-message-types.js';
-import {
-  qrCodeAuthMessagePayloadValidator,
-  type QRCodeAuthMessagePayload,
-} from '../types/tunnelbroker/qr-code-auth-message-types.js';
-
-function createQRAuthTunnelbrokerMessage(
-  encryptionKey: string,
-  payload: QRCodeAuthMessagePayload,
-): QRCodeAuthMessage {
-  return {
-    type: peerToPeerMessageTypes.QR_CODE_AUTH_MESSAGE,
-    encryptedContent: JSON.stringify(payload),
-  };
-}
-
-function parseQRAuthTunnelbrokerMessage(
-  encryptionKey: string,
-  message: QRCodeAuthMessage,
-): ?QRCodeAuthMessagePayload {
-  const payload = JSON.parse(message.encryptedContent);
-  if (!qrCodeAuthMessagePayloadValidator.is(payload)) {
-    return null;
-  }
-
-  return payload;
-}
-
-export { createQRAuthTunnelbrokerMessage, parseQRAuthTunnelbrokerMessage };
diff --git a/native/profile/secondary-device-qr-code-scanner.react.js b/native/profile/secondary-device-qr-code-scanner.react.js
--- a/native/profile/secondary-device-qr-code-scanner.react.js
+++ b/native/profile/secondary-device-qr-code-scanner.react.js
@@ -20,13 +20,13 @@
   type PeerToPeerMessage,
 } from 'lib/types/tunnelbroker/peer-to-peer-message-types.js';
 import { qrCodeAuthMessageTypes } from 'lib/types/tunnelbroker/qr-code-auth-message-types.js';
-import {
-  createQRAuthTunnelbrokerMessage,
-  parseQRAuthTunnelbrokerMessage,
-} from 'lib/utils/qr-code-auth.js';
 
 import type { ProfileNavigationProp } from './profile.react.js';
 import type { NavigationRoute } from '../navigation/route-names.js';
+import {
+  composeTunnelbrokerQRAuthMessage,
+  parseTunnelbrokerQRAuthMessage,
+} from '../qr-code/qr-code-utils.js';
 import { useStyles } from '../themes/colors.js';
 import Alert from '../utils/alert.js';
 
@@ -145,7 +145,7 @@
         return;
       }
 
-      const payload = parseQRAuthTunnelbrokerMessage(
+      const payload = await parseTunnelbrokerQRAuthMessage(
         encryptionKey,
         innerMessage,
       );
@@ -158,12 +158,15 @@
 
       void broadcastDeviceListUpdate();
 
-      const backupKeyMessage = createQRAuthTunnelbrokerMessage(encryptionKey, {
-        type: qrCodeAuthMessageTypes.BACKUP_DATA_KEY_MESSAGE,
-        backupID: 'stub',
-        backupDataKey: 'stub',
-        backupLogDataKey: 'stub',
-      });
+      const backupKeyMessage = await composeTunnelbrokerQRAuthMessage(
+        encryptionKey,
+        {
+          type: qrCodeAuthMessageTypes.BACKUP_DATA_KEY_MESSAGE,
+          backupID: 'stub',
+          backupDataKey: 'stub',
+          backupLogDataKey: 'stub',
+        },
+      );
       await tunnelbrokerContext.sendMessage({
         deviceID: targetDeviceID,
         payload: JSON.stringify(backupKeyMessage),
@@ -228,7 +231,7 @@
           throw new Error('missing auth metadata');
         }
         await addDeviceToList(ed25519);
-        const message = createQRAuthTunnelbrokerMessage(aes256, {
+        const message = await composeTunnelbrokerQRAuthMessage(aes256, {
           type: qrCodeAuthMessageTypes.DEVICE_LIST_UPDATE_SUCCESS,
           userID,
           primaryDeviceID,
diff --git a/native/qr-code/qr-code-screen.react.js b/native/qr-code/qr-code-screen.react.js
--- a/native/qr-code/qr-code-screen.react.js
+++ b/native/qr-code/qr-code-screen.react.js
@@ -16,6 +16,10 @@
 } from 'lib/types/identity-service-types.js';
 
 import type { QRCodeSignInNavigationProp } from './qr-code-sign-in-navigator.react.js';
+import {
+  composeTunnelbrokerQRAuthMessage,
+  parseTunnelbrokerQRAuthMessage,
+} from './qr-code-utils.js';
 import { commCoreModule } from '../native-modules.js';
 import type { NavigationRoute } from '../navigation/route-names.js';
 import { useStyles } from '../themes/colors.js';
@@ -93,6 +97,8 @@
         secondaryDeviceID={qrData?.deviceID}
         aesKey={qrData?.aesKey}
         performSecondaryDeviceRegistration={performRegistration}
+        composeMessage={composeTunnelbrokerQRAuthMessage}
+        processMessage={parseTunnelbrokerQRAuthMessage}
       />
       <View style={styles.container}>
         <Text style={styles.heading}>Log in to Comm</Text>
diff --git a/native/qr-code/qr-code-utils.js b/native/qr-code/qr-code-utils.js
new file mode 100644
--- /dev/null
+++ b/native/qr-code/qr-code-utils.js
@@ -0,0 +1,55 @@
+// @flow
+
+import { hexToUintArray } from 'lib/media/data-utils.js';
+import {
+  peerToPeerMessageTypes,
+  type QRCodeAuthMessage,
+} from 'lib/types/tunnelbroker/peer-to-peer-message-types.js';
+import {
+  qrCodeAuthMessagePayloadValidator,
+  type QRCodeAuthMessagePayload,
+} from 'lib/types/tunnelbroker/qr-code-auth-message-types.js';
+
+import {
+  convertBytesToObj,
+  convertObjToBytes,
+} from '../backup/conversion-utils.js';
+import { commUtilsModule } from '../native-modules.js';
+import * as AES from '../utils/aes-crypto-module.js';
+
+function composeTunnelbrokerQRAuthMessage(
+  encryptionKey: string,
+  obj: QRCodeAuthMessagePayload,
+): Promise<QRCodeAuthMessage> {
+  const objBytes = convertObjToBytes(obj);
+  const keyBytes = hexToUintArray(encryptionKey);
+  const encryptedBytes = AES.encrypt(keyBytes, objBytes);
+  const encryptedContent = commUtilsModule.base64EncodeBuffer(
+    encryptedBytes.buffer,
+  );
+  return Promise.resolve({
+    type: peerToPeerMessageTypes.QR_CODE_AUTH_MESSAGE,
+    encryptedContent,
+  });
+}
+
+function parseTunnelbrokerQRAuthMessage(
+  encryptionKey: string,
+  message: QRCodeAuthMessage,
+): Promise<?QRCodeAuthMessagePayload> {
+  const encryptedData = commUtilsModule.base64DecodeBuffer(
+    message.encryptedContent,
+  );
+  const decryptedData = AES.decrypt(
+    hexToUintArray(encryptionKey),
+    new Uint8Array(encryptedData),
+  );
+  const payload = convertBytesToObj<QRCodeAuthMessagePayload>(decryptedData);
+  if (!qrCodeAuthMessagePayloadValidator.is(payload)) {
+    return Promise.resolve(null);
+  }
+
+  return Promise.resolve(payload);
+}
+
+export { composeTunnelbrokerQRAuthMessage, parseTunnelbrokerQRAuthMessage };
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
@@ -10,7 +10,8 @@
 import { QRAuthHandler } from 'lib/components/qr-auth-handler.react.js';
 import { qrCodeLinkURL } from 'lib/facts/links.js';
 import { generateKeyCommon } from 'lib/media/aes-crypto-utils-common.js';
-import { uintArrayToHexString } from 'lib/media/data-utils.js';
+import * as AES from 'lib/media/aes-crypto-utils-common.js';
+import { hexToUintArray, uintArrayToHexString } from 'lib/media/data-utils.js';
 import { IdentityClientContext } from 'lib/shared/identity-client-context.js';
 import { useTunnelbroker } from 'lib/tunnelbroker/tunnelbroker-context.js';
 import type { CryptoStore, PickledOLMAccount } from 'lib/types/crypto-types.js';
@@ -19,11 +20,27 @@
   SignedMessage,
 } from 'lib/types/identity-service-types.js';
 import type { WebAppState } from 'lib/types/redux-types.js';
+import {
+  peerToPeerMessageTypes,
+  type QRCodeAuthMessage,
+} from 'lib/types/tunnelbroker/peer-to-peer-message-types.js';
+import {
+  qrCodeAuthMessagePayloadValidator,
+  type QRCodeAuthMessagePayload,
+} from 'lib/types/tunnelbroker/qr-code-auth-message-types.js';
 import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js';
 
 import css from './qr-code-login.css';
 import { initOlm } from '../olm/olm-utils.js';
 import { useSelector } from '../redux/redux-utils.js';
+import {
+  base64DecodeBuffer,
+  base64EncodeBuffer,
+} from '../utils/base64-utils.js';
+import {
+  convertBytesToObj,
+  convertObjToBytes,
+} from '../utils/conversion-utils.js';
 
 const deviceIDAndPrimaryAccountSelector: (state: WebAppState) => {
   ed25519Key: ?string,
@@ -36,6 +53,38 @@
   }),
 );
 
+async function composeTunnelbrokerMessage(
+  encryptionKey: string,
+  obj: QRCodeAuthMessagePayload,
+): Promise<QRCodeAuthMessage> {
+  const objBytes = convertObjToBytes(obj);
+  const keyBytes = hexToUintArray(encryptionKey);
+  const encryptedBytes = await AES.encryptCommon(crypto, keyBytes, objBytes);
+  const encryptedContent = base64EncodeBuffer(encryptedBytes);
+  return {
+    type: peerToPeerMessageTypes.QR_CODE_AUTH_MESSAGE,
+    encryptedContent,
+  };
+}
+
+async function parseTunnelbrokerMessage(
+  encryptionKey: string,
+  message: QRCodeAuthMessage,
+): Promise<?QRCodeAuthMessagePayload> {
+  const encryptedData = base64DecodeBuffer(message.encryptedContent);
+  const decryptedData = await AES.decryptCommon(
+    crypto,
+    hexToUintArray(encryptionKey),
+    new Uint8Array(encryptedData),
+  );
+  const payload = convertBytesToObj<QRCodeAuthMessagePayload>(decryptedData);
+  if (!qrCodeAuthMessagePayloadValidator.is(payload)) {
+    return null;
+  }
+
+  return payload;
+}
+
 function QrCodeLogin(): React.Node {
   const [qrCodeValue, setQrCodeValue] = React.useState<?string>();
   const { ed25519Key, primaryAccount } = useSelector(
@@ -120,6 +169,8 @@
         secondaryDeviceID={deviceKeys?.deviceID}
         aesKey={deviceKeys?.aesKey}
         performSecondaryDeviceRegistration={performRegistration}
+        composeMessage={composeTunnelbrokerMessage}
+        processMessage={parseTunnelbrokerMessage}
       />
       <div className={css.qrContainer}>
         <div className={css.title}>Log in to Comm</div>
diff --git a/web/jest-setup.js b/web/jest-setup.js
--- a/web/jest-setup.js
+++ b/web/jest-setup.js
@@ -1,8 +1,12 @@
 // @flow
+/* eslint-disable no-undef -- "global is not defined" */
 
 import crypto from 'crypto';
+import util from 'util';
 
 // crypto.webcrypto was introduced in Node 15.10.0.
 // It is not defined in Flow so we need a cast
-// eslint-disable-next-line no-undef -- "global is not defined"
 global.crypto = (crypto: any).webcrypto;
+
+global.TextEncoder = util.TextEncoder;
+global.TextDecoder = util.TextDecoder;
diff --git a/web/utils/conversion-utils.js b/web/utils/conversion-utils.js
new file mode 100644
--- /dev/null
+++ b/web/utils/conversion-utils.js
@@ -0,0 +1,13 @@
+// @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
new file mode 100644
--- /dev/null
+++ b/web/utils/conversion-utils.test.js
@@ -0,0 +1,14 @@
+// @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);
+  });
+});