diff --git a/keyserver/src/responders/website-responders.js b/keyserver/src/responders/website-responders.js
--- a/keyserver/src/responders/website-responders.js
+++ b/keyserver/src/responders/website-responders.js
@@ -70,6 +70,7 @@
   +cssInclude: string,
   +olmFilename: string,
   +sqljsFilename: string,
+  +opaqueURL: string,
 };
 let assetInfo: ?AssetInfo = null;
 async function getAssetInfo() {
@@ -84,6 +85,7 @@
       cssInclude: '',
       olmFilename: '',
       sqljsFilename: '',
+      opaqueURL: 'http://localhost:8080/opaque-ke.wasm',
     };
     return assetInfo;
   }
@@ -107,6 +109,7 @@
       `,
       olmFilename: manifest['olm.wasm'],
       sqljsFilename: webworkersManifest['sql-wasm.wasm'],
+      opaqueURL: `compiled/${manifest['comm_opaque2_wasm_bg.wasm']}`,
     };
     return assetInfo;
   } catch {
@@ -332,7 +335,7 @@
     return pushConfig.publicKey;
   })();
 
-  const { jsURL, fontsURL, cssInclude, olmFilename, sqljsFilename } =
+  const { jsURL, fontsURL, cssInclude, olmFilename, sqljsFilename, opaqueURL } =
     await assetInfoPromise;
 
   // prettier-ignore
@@ -433,6 +436,7 @@
           var baseURL = "${baseURL}";
           var olmFilename = "${olmFilename}";
           var sqljsFilename = "${sqljsFilename}";
+          var opaqueURL = "${opaqueURL}";
         </script>
         <script src="${jsURL}"></script>
       </body>
diff --git a/web/app.react.js b/web/app.react.js
--- a/web/app.react.js
+++ b/web/app.react.js
@@ -34,6 +34,7 @@
 import Chat from './chat/chat.react.js';
 import { TooltipProvider } from './chat/tooltip-provider.js';
 import NavigationArrows from './components/navigation-arrows.react.js';
+import { initOpaque } from './crypto/opaque-utils.js';
 import electron from './electron.js';
 import InputStateContainer from './input/input-state-container.react.js';
 import LoadingIndicator from './loading-indicator.react.js';
@@ -61,6 +62,8 @@
 import { type NavInfo } from './types/nav-types.js';
 import { canonicalURLFromReduxState, navInfoFromURL } from './url-utils.js';
 
+initOpaque();
+
 // We want Webpack's css-loader and style-loader to handle the Fontawesome CSS,
 // so we disable the autoAddCss logic and import the CSS file. Otherwise every
 // icon flashes huge for a second before the CSS is loaded.
diff --git a/web/crypto/opaque-utils.js b/web/crypto/opaque-utils.js
new file mode 100644
--- /dev/null
+++ b/web/crypto/opaque-utils.js
@@ -0,0 +1,19 @@
+// @flow
+
+import initOpaqueKe from '@commapp/opaque-ke-wasm';
+
+declare var opaqueURL: string;
+
+let opaqueKeLoadingState: void | true | Promise<mixed>;
+
+function initOpaque(): Promise<mixed> {
+  if (opaqueKeLoadingState === true) {
+    return Promise.resolve();
+  }
+  if (!opaqueKeLoadingState) {
+    opaqueKeLoadingState = initOpaqueKe(opaqueURL);
+  }
+  return opaqueKeLoadingState;
+}
+
+export { initOpaque };
diff --git a/web/flow-typed/npm/@commapp/opaque-ke-wasm_vx.x.x.js b/web/flow-typed/npm/@commapp/opaque-ke-wasm_vx.x.x.js
new file mode 100644
--- /dev/null
+++ b/web/flow-typed/npm/@commapp/opaque-ke-wasm_vx.x.x.js
@@ -0,0 +1,25 @@
+// flow-typed signature: 8ee70b4d84f861b07c5c7ae0ccf6ca25
+// flow-typed version: <<STUB>>/@commapp/opaque-ke-wasm_v0.0.3/flow_v0.182.0
+
+declare module '@commapp/opaque-ke-wasm' {
+
+  declare export class Login {
+    constructor(): void;
+    free(): void;
+    start(password: string): Uint8Array;
+    finish(response_payload: Uint8Array): Uint8Array;
+    +session_key: Uint8Array;
+  }
+
+  declare export class Registration {
+    constructor(): void;
+    free(): void;
+    start(password: string): Uint8Array;
+    finish(password: string, response_payload: Uint8Array): Uint8Array;
+  }
+
+  declare export default function init(
+    input: void | string | Request | URL,
+  ): Promise<mixed>;
+
+}
diff --git a/web/package.json b/web/package.json
--- a/web/package.json
+++ b/web/package.json
@@ -35,6 +35,7 @@
   },
   "dependencies": {
     "@babel/runtime": "^7.13.10",
+    "@commapp/opaque-ke-wasm": "npm:@commapp/opaque-ke-wasm@^0.0.3",
     "@emoji-mart/data": "^1.1.2",
     "@emoji-mart/react": "^1.1.1",
     "@fortawesome/fontawesome-svg-core": "1.2.25",
diff --git a/web/webpack.config.cjs b/web/webpack.config.cjs
--- a/web/webpack.config.cjs
+++ b/web/webpack.config.cjs
@@ -57,6 +57,16 @@
         },
       ],
     }),
+    new CopyPlugin({
+      patterns: [
+        {
+          from:
+            'node_modules/@commapp/opaque-ke-wasm' +
+            '/pkg/comm_opaque2_wasm_bg.wasm',
+          to: path.join(__dirname, 'dist', 'opaque-ke.wasm'),
+        },
+      ],
+    }),
   ],
 };
 
@@ -71,6 +81,16 @@
         },
       ],
     }),
+    new CopyPlugin({
+      patterns: [
+        {
+          from:
+            'node_modules/@commapp/opaque-ke-wasm' +
+            '/pkg/comm_opaque2_wasm_bg.wasm',
+          to: path.join(__dirname, 'dist', 'opaque-ke.[contenthash:12].wasm'),
+        },
+      ],
+    }),
     new WebpackManifestPlugin({
       publicPath: '',
     }),
diff --git a/yarn.lock b/yarn.lock
--- a/yarn.lock
+++ b/yarn.lock
@@ -1719,6 +1719,11 @@
     stream-browserify "^3.0.0"
     util "^0.12.4"
 
+"@commapp/opaque-ke-wasm@npm:@commapp/opaque-ke-wasm@^0.0.3":
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/@commapp/opaque-ke-wasm/-/opaque-ke-wasm-0.0.3.tgz#789b7351e593932461858658c61da5c2de51dc03"
+  integrity sha512-K88XPRnwKmpRCQqU26R7w1Syflp9IB1Ets1UPXTB3roumLweiHG8Y7UA+LrlpzCYd+8d8Tukp7i+/h4OFfovUw==
+
 "@commapp/sqlcipher-amalgamation@^4.4.3-a":
   version "4.4.3-a"
   resolved "https://registry.yarnpkg.com/@commapp/sqlcipher-amalgamation/-/sqlcipher-amalgamation-4.4.3-a.tgz#39c297132b9ad02358bf022a164bfc03292ecce1"