diff --git a/nix/dev-shell.nix b/nix/dev-shell.nix
--- a/nix/dev-shell.nix
+++ b/nix/dev-shell.nix
@@ -40,6 +40,7 @@
 , sqlite
 , terraform
 , rustfmt
+, wasm-pack
 , yarn
 }:
 
@@ -62,6 +63,7 @@
     yarn
     python3
     redis
+    wasm-pack
 
     # native dependencies
     # C/CXX toolchains are already brought in with mkShell
diff --git a/shared/comm-opaque2/.gitignore b/shared/comm-opaque2/.gitignore
--- a/shared/comm-opaque2/.gitignore
+++ b/shared/comm-opaque2/.gitignore
@@ -1 +1,2 @@
 target
+pkg
diff --git a/shared/comm-opaque2/Cargo.lock b/shared/comm-opaque2/Cargo.lock
--- a/shared/comm-opaque2/Cargo.lock
+++ b/shared/comm-opaque2/Cargo.lock
@@ -77,6 +77,12 @@
  "generic-array",
 ]
 
+[[package]]
+name = "bumpalo"
+version = "3.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535"
+
 [[package]]
 name = "byteorder"
 version = "1.4.3"
@@ -100,10 +106,23 @@
 version = "0.1.0"
 dependencies = [
  "argon2",
+ "getrandom",
  "log",
  "opaque-ke",
  "rand",
  "tonic",
+ "wasm-bindgen",
+ "wasm-bindgen-test",
+]
+
+[[package]]
+name = "console_error_panic_hook"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen",
 ]
 
 [[package]]
@@ -290,8 +309,10 @@
 checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31"
 dependencies = [
  "cfg-if",
+ "js-sys",
  "libc",
  "wasi",
+ "wasm-bindgen",
 ]
 
 [[package]]
@@ -351,6 +372,15 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6"
 
+[[package]]
+name = "js-sys"
+version = "0.3.61"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "445dde2150c55e483f3d8416706b97ec8e8237c307e5b7b4b8dd15e6af2a0730"
+dependencies = [
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "libc"
 version = "0.2.140"
@@ -497,6 +527,12 @@
  "getrandom",
 ]
 
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
 [[package]]
 name = "sec1"
 version = "0.3.0"
@@ -721,6 +757,106 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
 
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.84"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31f8dcbc21f30d9b8f2ea926ecb58f6b91192c17e9d33594b3df58b2007ca53b"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.84"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95ce90fd5bcc06af55a641a86428ee4229e44e07033963a2290a8e241607ccb9"
+dependencies = [
+ "bumpalo",
+ "log",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f219e0d211ba40266969f6dbdd90636da12f75bee4fc9d6c23d1260dadb51454"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.84"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c21f77c0bedc37fd5dc21f897894a5ca01e7bb159884559461862ae90c0b4c5"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.84"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2aff81306fcac3c7515ad4e177f521b5c9a15f2b08f4e32d823066102f35a5f6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.84"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0046fef7e28c3804e5e38bfa31ea2a0f73905319b677e57ebe37e49358989b5d"
+
+[[package]]
+name = "wasm-bindgen-test"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6db36fc0f9fb209e88fb3642590ae0205bb5a56216dabd963ba15879fe53a30b"
+dependencies = [
+ "console_error_panic_hook",
+ "js-sys",
+ "scoped-tls",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-bindgen-test-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-test-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0734759ae6b3b1717d661fe4f016efcfb9828f5edb4520c18eaee05af3b43be9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.61"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e33b99f4b23ba3eec1a53ac264e35a755f00e966e0065077d6027c0f575b0b97"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "windows-sys"
 version = "0.45.0"
diff --git a/shared/comm-opaque2/Cargo.toml b/shared/comm-opaque2/Cargo.toml
--- a/shared/comm-opaque2/Cargo.toml
+++ b/shared/comm-opaque2/Cargo.toml
@@ -8,7 +8,17 @@
 
 [dependencies]
 argon2 = "0.4"
+getrandom = { version = "0.2", features = [ "js", "wasm-bindgen" ] }
 log = "0.4"
 opaque-ke = { version = "2.0", features = [ "argon2" ] }
 rand = "0.8"
 tonic = { version = "0.8", default-features = false }
+wasm-bindgen = "0.2"
+
+[dev-dependencies]
+wasm-bindgen-test = "0.3"
+
+[profile.release]
+# Optimize for small code size
+opt-level = "s"
+strip = "debuginfo"
diff --git a/shared/comm-opaque2/src/client/login.rs b/shared/comm-opaque2/src/client/login.rs
--- a/shared/comm-opaque2/src/client/login.rs
+++ b/shared/comm-opaque2/src/client/login.rs
@@ -3,18 +3,22 @@
   CredentialResponse,
 };
 use rand::rngs::OsRng;
+use wasm_bindgen::prelude::wasm_bindgen;
 
-use crate::Cipher;
+use crate::{error::OpaqueError, Cipher};
 
+#[wasm_bindgen]
 pub struct Login {
   state: Option<ClientLogin<Cipher>>,
   password: Option<String>,
   rng: OsRng,
   export_key: Option<Vec<u8>>,
-  pub session_key: Option<Vec<u8>>,
+  session_key: Option<Vec<u8>>,
 }
 
+#[wasm_bindgen]
 impl Login {
+  #[wasm_bindgen(constructor)]
   pub fn new() -> Login {
     Login {
       state: None,
@@ -25,7 +29,7 @@
     }
   }
 
-  pub fn start(&mut self, password: &str) -> Result<Vec<u8>, ProtocolError> {
+  pub fn start(&mut self, password: &str) -> Result<Vec<u8>, OpaqueError> {
     let client_start_result =
       ClientLogin::<Cipher>::start(&mut self.rng, password.as_bytes())?;
     self.state = Some(client_start_result.state);
@@ -36,7 +40,7 @@
   pub fn finish(
     &mut self,
     response_payload: &[u8],
-  ) -> Result<Vec<u8>, ProtocolError> {
+  ) -> Result<Vec<u8>, OpaqueError> {
     let response = CredentialResponse::deserialize(response_payload)?;
     let password = self
       .password
@@ -57,4 +61,12 @@
 
     Ok(result.message.serialize().to_vec())
   }
+
+  #[wasm_bindgen(getter)]
+  pub fn session_key(&self) -> Result<Vec<u8>, OpaqueError> {
+    match &self.session_key {
+      Some(v) => Ok(v.clone()),
+      None => Err(ProtocolError::InvalidLoginError.into()),
+    }
+  }
 }
diff --git a/shared/comm-opaque2/src/client/register.rs b/shared/comm-opaque2/src/client/register.rs
--- a/shared/comm-opaque2/src/client/register.rs
+++ b/shared/comm-opaque2/src/client/register.rs
@@ -3,16 +3,21 @@
   ClientRegistrationFinishParameters, RegistrationResponse,
 };
 use rand::rngs::OsRng;
+use wasm_bindgen::prelude::*;
 
+use crate::error::OpaqueError;
 use crate::Cipher;
 
+#[wasm_bindgen]
 pub struct Registration {
   state: Option<ClientRegistration<Cipher>>,
   rng: OsRng,
   export_key: Option<Vec<u8>>,
 }
 
+#[wasm_bindgen]
 impl Registration {
+  #[wasm_bindgen(constructor)]
   pub fn new() -> Registration {
     Registration {
       state: None,
@@ -21,7 +26,7 @@
     }
   }
 
-  pub fn start(&mut self, password: &str) -> Result<Vec<u8>, ProtocolError> {
+  pub fn start(&mut self, password: &str) -> Result<Vec<u8>, OpaqueError> {
     let result =
       ClientRegistration::<Cipher>::start(&mut self.rng, password.as_bytes())?;
     self.state = Some(result.state);
@@ -32,7 +37,7 @@
     &mut self,
     password: &str,
     response_payload: &[u8],
-  ) -> Result<Vec<u8>, ProtocolError> {
+  ) -> Result<Vec<u8>, OpaqueError> {
     let response = RegistrationResponse::deserialize(response_payload)?;
     let state = self
       .state
diff --git a/shared/comm-opaque2/src/error.rs b/shared/comm-opaque2/src/error.rs
new file mode 100644
--- /dev/null
+++ b/shared/comm-opaque2/src/error.rs
@@ -0,0 +1,41 @@
+use opaque_ke::errors::ProtocolError;
+use std::ops::Deref;
+use wasm_bindgen::{JsError, JsValue};
+
+/// Due to Rust's orphan rules, we cannot directly bridge
+/// opaque_ke::errors::ProtocolError to wasm_bindgen::JsValue. Instead we
+/// must define our own type, and add the impl's ourselves
+#[derive(Debug)]
+pub struct OpaqueError(ProtocolError);
+
+impl Into<JsValue> for OpaqueError {
+  fn into(self) -> JsValue {
+    JsValue::from(protocol_error_to_js_error(self.0))
+  }
+}
+
+impl From<ProtocolError> for OpaqueError {
+  fn from(error: ProtocolError) -> OpaqueError {
+    OpaqueError(error)
+  }
+}
+
+impl Deref for OpaqueError {
+  type Target = ProtocolError;
+
+  fn deref(&self) -> &Self::Target {
+    &self.0
+  }
+}
+
+fn protocol_error_to_js_error(error: ProtocolError) -> JsError {
+  match error {
+    ProtocolError::IdentityGroupElementError => JsError::new("server error"),
+    ProtocolError::InvalidLoginError => JsError::new("login failed"),
+    ProtocolError::LibraryError(_) => JsError::new("internal error"),
+    ProtocolError::ReflectedValueError => {
+      JsError::new("invalid server response")
+    }
+    ProtocolError::SerializationError => JsError::new("invalid argument"),
+  }
+}
diff --git a/shared/comm-opaque2/src/lib.rs b/shared/comm-opaque2/src/lib.rs
--- a/shared/comm-opaque2/src/lib.rs
+++ b/shared/comm-opaque2/src/lib.rs
@@ -1,4 +1,5 @@
 pub mod client;
+pub mod error;
 pub mod grpc;
 pub mod opaque;
 pub mod server;
@@ -30,8 +31,8 @@
   let password_file_bytes = server_register.finish(&client_upload).unwrap();
 
   // Login user
-  let mut login_client = client::Login::new();
-  let client_request = login_client.start(pass).unwrap();
+  let mut client_login = client::Login::new();
+  let client_request = client_login.start(pass).unwrap();
 
   let mut server_login = server::Login::new();
   let server_response = server_login
@@ -43,10 +44,12 @@
     )
     .unwrap();
 
-  let client_upload = login_client.finish(&server_response).unwrap();
+  let client_upload = client_login.finish(&server_response).unwrap();
 
   server_login.finish(&client_upload).unwrap();
 
-  assert_eq!(login_client.session_key.is_some(), true);
-  assert_eq!(login_client.session_key, server_login.session_key);
+  assert_eq!(
+    client_login.session_key().unwrap(),
+    server_login.session_key.unwrap()
+  );
 }