diff --git a/services/identity/src/client_service.rs b/services/identity/src/client_service.rs
--- a/services/identity/src/client_service.rs
+++ b/services/identity/src/client_service.rs
@@ -29,6 +29,7 @@
 pub use client_proto::identity_client_service_server::{
   IdentityClientService, IdentityClientServiceServer,
 };
+use comm_opaque2::grpc::protocol_error_to_grpc_status;
 use moka::future::Cache;
 use rand::rngs::OsRng;
 use tonic::Response;
@@ -47,7 +48,10 @@
 }
 
 #[derive(Clone)]
-pub struct UserLoginInfo(FlattenedDeviceKeyUpload);
+pub struct UserLoginInfo {
+  pub flattened_device_key_upload: FlattenedDeviceKeyUpload,
+  pub opaque_server_login: comm_opaque2::server::Login,
+}
 
 #[derive(Clone)]
 pub struct FlattenedDeviceKeyUpload {
@@ -118,7 +122,7 @@
       let server_registration = comm_opaque2::server::Registration::new();
       let server_message = server_registration
         .start(&CONFIG.server_setup, &register_message, username.as_bytes())
-        .map_err(comm_opaque2::grpc::protocol_error_to_grpc_status)?;
+        .map_err(protocol_error_to_grpc_status)?;
       let key_info = KeyPayload::from_str(&payload)
         .map_err(|_| tonic::Status::invalid_argument("malformed payload"))?;
       let registration_state = UserRegistrationInfo {
@@ -168,7 +172,7 @@
       let server_registration = comm_opaque2::server::Registration::new();
       let password_file = server_registration
         .finish(&message.opaque_registration_upload)
-        .map_err(comm_opaque2::grpc::protocol_error_to_grpc_status)?;
+        .map_err(protocol_error_to_grpc_status)?;
 
       let device_id = state.flattened_device_key_upload.device_id_key.clone();
       let user_id = self
@@ -221,9 +225,83 @@
 
   async fn login_password_user_start(
     &self,
-    _request: tonic::Request<OpaqueLoginStartRequest>,
+    request: tonic::Request<OpaqueLoginStartRequest>,
   ) -> Result<tonic::Response<OpaqueLoginStartResponse>, tonic::Status> {
-    unimplemented!();
+    let message = request.into_inner();
+
+    let password_file_bytes = self
+      .client
+      .get_password_file_from_username(&message.username)
+      .await
+      .map_err(handle_db_error)?
+      .ok_or(tonic::Status::not_found("user not found"))?;
+
+    if let client_proto::OpaqueLoginStartRequest {
+      opaque_login_request: login_message,
+      username,
+      device_key_upload:
+        Some(client_proto::DeviceKeyUpload {
+          device_key_info:
+            Some(client_proto::IdentityKeyInfo {
+              payload,
+              payload_signature,
+              social_proof: _social_proof,
+            }),
+          identity_upload:
+            Some(client_proto::PreKey {
+              pre_key: identity_prekey,
+              pre_key_signature: identity_prekey_signature,
+            }),
+          notif_upload:
+            Some(client_proto::PreKey {
+              pre_key: notif_prekey,
+              pre_key_signature: notif_prekey_signature,
+            }),
+          onetime_identity_prekeys,
+          onetime_notif_prekeys,
+        }),
+    } = message
+    {
+      let mut server_login = comm_opaque2::server::Login::new();
+      let server_response = server_login
+        .start(
+          &CONFIG.server_setup,
+          &password_file_bytes,
+          &login_message,
+          username.as_bytes(),
+        )
+        .map_err(protocol_error_to_grpc_status)?;
+
+      let key_info = KeyPayload::from_str(&payload)
+        .map_err(|_| tonic::Status::invalid_argument("malformed payload"))?;
+      let login_state = UserLoginInfo {
+        opaque_server_login: server_login,
+        flattened_device_key_upload: FlattenedDeviceKeyUpload {
+          device_id_key: key_info.primary_identity_public_keys.curve25519,
+          key_payload: payload,
+          key_payload_signature: payload_signature,
+          identity_prekey,
+          identity_prekey_signature,
+          identity_onetime_keys: onetime_identity_prekeys,
+          notif_prekey,
+          notif_prekey_signature,
+          notif_onetime_keys: onetime_notif_prekeys,
+        },
+      };
+      let session_id = generate_uuid();
+      self
+        .cache
+        .insert(session_id.clone(), WorkflowInProgress::Login(login_state))
+        .await;
+
+      let response = Response::new(OpaqueLoginStartResponse {
+        session_id,
+        opaque_login_response: server_response,
+      });
+      Ok(response)
+    } else {
+      Err(tonic::Status::invalid_argument("unexpected message data"))
+    }
   }
 
   async fn login_password_user_finish(
diff --git a/services/identity/src/database.rs b/services/identity/src/database.rs
--- a/services/identity/src/database.rs
+++ b/services/identity/src/database.rs
@@ -105,36 +105,6 @@
     }
   }
 
-  pub async fn get_pake_registration(
-    &self,
-    user_id: String,
-  ) -> Result<Option<ServerRegistration<Cipher>>, Error> {
-    match self.get_item_from_users_table(&user_id).await {
-      Ok(GetItemOutput {
-        item: Some(mut item),
-        ..
-      }) => parse_registration_data_attribute(
-        item.remove(USERS_TABLE_REGISTRATION_ATTRIBUTE),
-      )
-      .map(Some)
-      .map_err(Error::Attribute),
-      Ok(_) => {
-        info!(
-          "No item found for user {} in PAKE registration table",
-          user_id
-        );
-        Ok(None)
-      }
-      Err(e) => {
-        error!(
-          "DynamoDB client failed to get registration data for user {}: {}",
-          user_id, e
-        );
-        Err(e)
-      }
-    }
-  }
-
   pub async fn add_user_to_users_table(
     &self,
     registration_state: UserRegistrationInfo,
@@ -397,11 +367,11 @@
     Ok(result.is_some())
   }
 
-  pub async fn get_user_id_from_user_info(
+  async fn get_user_from_user_info(
     &self,
     user_info: String,
     auth_type: AuthType,
-  ) -> Result<Option<String>, Error> {
+  ) -> Result<Option<HashMap<String, AttributeValue>>, Error> {
     let (index, attribute_name) = match auth_type {
       AuthType::Password => {
         (USERS_TABLE_USERNAME_INDEX, USERS_TABLE_USERNAME_ATTRIBUTE)
@@ -422,8 +392,7 @@
       .await
     {
       Ok(QueryOutput {
-        items: Some(mut items),
-        ..
+        items: Some(items), ..
       }) => {
         let num_items = items.len();
         if num_items == 0 {
@@ -435,12 +404,8 @@
             num_items, attribute_name, user_info, items
           );
         }
-        parse_string_attribute(
-          USERS_TABLE_PARTITION_KEY,
-          items[0].remove(USERS_TABLE_PARTITION_KEY),
-        )
-        .map(Some)
-        .map_err(Error::Attribute)
+        let first_item = items[0].clone();
+        Ok(Some(first_item))
       }
       Ok(_) => {
         info!(
@@ -451,7 +416,7 @@
       }
       Err(e) => {
         error!(
-          "DynamoDB client failed to get user ID from {} {}: {}",
+          "DynamoDB client failed to get user from {} {}: {}",
           attribute_name, user_info, e
         );
         Err(Error::AwsSdk(e.into()))
@@ -459,6 +424,56 @@
     }
   }
 
+  pub async fn get_user_id_from_user_info(
+    &self,
+    user_info: String,
+    auth_type: AuthType,
+  ) -> Result<Option<String>, Error> {
+    match self
+      .get_user_from_user_info(user_info.clone(), auth_type)
+      .await
+    {
+      Ok(Some(mut user)) => parse_string_attribute(
+        USERS_TABLE_PARTITION_KEY,
+        user.remove(USERS_TABLE_PARTITION_KEY),
+      )
+      .map(Some)
+      .map_err(Error::Attribute),
+      Ok(_) => Ok(None),
+      Err(e) => Err(e),
+    }
+  }
+
+  pub async fn get_password_file_from_username(
+    &self,
+    username: &str,
+  ) -> Result<Option<Vec<u8>>, Error> {
+    match self
+      .get_user_from_user_info(username.to_string(), AuthType::Password)
+      .await
+    {
+      Ok(Some(mut user)) => parse_registration_data_attribute(
+        user.remove(USERS_TABLE_REGISTRATION_ATTRIBUTE),
+      )
+      .map(Some)
+      .map_err(Error::Attribute),
+      Ok(_) => {
+        info!(
+          "No item found for user {} in PAKE registration table",
+          username
+        );
+        Ok(None)
+      }
+      Err(e) => {
+        error!(
+          "DynamoDB client failed to get registration data for user {}: {}",
+          username, e
+        );
+        Err(e)
+      }
+    }
+  }
+
   pub async fn get_item_from_users_table(
     &self,
     user_id: &str,
@@ -673,19 +688,10 @@
 
 fn parse_registration_data_attribute(
   attribute: Option<AttributeValue>,
-) -> Result<ServerRegistration<Cipher>, DBItemError> {
-  match &attribute {
+) -> Result<Vec<u8>, DBItemError> {
+  match attribute {
     Some(AttributeValue::B(server_registration_bytes)) => {
-      match ServerRegistration::<Cipher>::deserialize(
-        server_registration_bytes.as_ref(),
-      ) {
-        Ok(server_registration) => Ok(server_registration),
-        Err(e) => Err(DBItemError::new(
-          USERS_TABLE_REGISTRATION_ATTRIBUTE,
-          attribute,
-          DBItemAttributeError::Pake(e),
-        )),
-      }
+      Ok(server_registration_bytes.into_inner())
     }
     Some(_) => Err(DBItemError::new(
       USERS_TABLE_REGISTRATION_ATTRIBUTE,
diff --git a/shared/comm-opaque2/src/server/login.rs b/shared/comm-opaque2/src/server/login.rs
--- a/shared/comm-opaque2/src/server/login.rs
+++ b/shared/comm-opaque2/src/server/login.rs
@@ -7,6 +7,7 @@
 
 use crate::Cipher;
 
+#[derive(Clone)]
 pub struct Login {
   state: Option<ServerLogin<Cipher>>,
   rng: OsRng,