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
@@ -49,6 +49,7 @@
 
 #[derive(Clone)]
 pub struct LoginState {
+  pub user_id: String,
   pub flattened_device_key_upload: FlattenedDeviceKeyUpload,
   pub opaque_server_login: comm_opaque2::server::Login,
 }
@@ -235,13 +236,13 @@
   ) -> Result<tonic::Response<OpaqueLoginStartResponse>, tonic::Status> {
     let message = request.into_inner();
 
-    let password_file_bytes = if let Some(bytes) = self
+    let (user_id, password_file_bytes) = if let Some((id, bytes)) = self
       .client
-      .get_password_file_from_username(&message.username)
+      .get_user_id_and_password_file_from_username(&message.username)
       .await
       .map_err(handle_db_error)?
     {
-      bytes
+      (id, bytes)
     } else {
       return Err(tonic::Status::not_found("user not found"));
     };
@@ -285,6 +286,7 @@
       let key_info = KeyPayload::from_str(&payload)
         .map_err(|_| tonic::Status::invalid_argument("malformed payload"))?;
       let login_state = LoginState {
+        user_id,
         opaque_server_login: server_login,
         flattened_device_key_upload: FlattenedDeviceKeyUpload {
           device_id_key: key_info.primary_identity_public_keys.curve25519,
@@ -316,9 +318,50 @@
 
   async fn login_password_user_finish(
     &self,
-    _request: tonic::Request<OpaqueLoginFinishRequest>,
+    request: tonic::Request<OpaqueLoginFinishRequest>,
   ) -> Result<tonic::Response<OpaqueLoginFinishResponse>, tonic::Status> {
-    unimplemented!();
+    let message = request.into_inner();
+
+    if let Some(WorkflowInProgress::Login(state)) =
+      self.cache.get(&message.session_id)
+    {
+      self.cache.invalidate(&message.session_id).await;
+
+      let mut server_login = state.opaque_server_login.clone();
+      server_login
+        .finish(&message.opaque_login_upload)
+        .map_err(protocol_error_to_grpc_status)?;
+
+      self
+        .client
+        .add_device_to_users_table(state.clone())
+        .await
+        .map_err(handle_db_error)?;
+
+      // Create access token
+      let token = AccessTokenData::new(
+        message.session_id,
+        state.flattened_device_key_upload.device_id_key,
+        crate::token::AuthType::Password,
+        &mut OsRng,
+      );
+
+      let access_token = token.access_token.clone();
+
+      self
+        .client
+        .put_access_token_data(token)
+        .await
+        .map_err(handle_db_error)?;
+
+      let response = OpaqueLoginFinishResponse {
+        user_id: state.user_id,
+        access_token,
+      };
+      Ok(Response::new(response))
+    } else {
+      Err(tonic::Status::not_found("session not found"))
+    }
   }
 
   async fn login_wallet_user(
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
@@ -11,11 +11,11 @@
 use aws_sdk_dynamodb::types::Blob;
 use aws_sdk_dynamodb::{Client, Error as DynamoDBError};
 use chrono::{DateTime, Utc};
-use opaque_ke::{errors::ProtocolError, ServerRegistration};
+use opaque_ke::errors::ProtocolError;
 use serde::{Deserialize, Serialize};
 use tracing::{debug, error, info, warn};
 
-use crate::client_service::RegistrationState;
+use crate::client_service::{LoginState, RegistrationState};
 use crate::config::CONFIG;
 use crate::constants::{
   ACCESS_TOKEN_SORT_KEY, ACCESS_TOKEN_TABLE,
@@ -40,7 +40,6 @@
 use crate::nonce::NonceData;
 use crate::token::{AccessTokenData, AuthType};
 use crate::utils::generate_uuid;
-use comm_opaque::Cipher;
 
 #[derive(Serialize, Deserialize)]
 pub struct OlmKeys {
@@ -222,6 +221,109 @@
 
     Ok(user_id)
   }
+
+  pub async fn add_device_to_users_table(
+    &self,
+    login_state: LoginState,
+  ) -> Result<(), Error> {
+    let device_info = HashMap::from([
+      (
+        USERS_TABLE_DEVICES_MAP_DEVICE_TYPE_ATTRIBUTE_NAME.to_string(),
+        AttributeValue::S(Device::Client.to_string()),
+      ),
+      (
+        USERS_TABLE_DEVICES_MAP_KEY_PAYLOAD_ATTRIBUTE_NAME.to_string(),
+        AttributeValue::S(login_state.flattened_device_key_upload.key_payload),
+      ),
+      (
+        USERS_TABLE_DEVICES_MAP_KEY_PAYLOAD_SIGNATURE_ATTRIBUTE_NAME
+          .to_string(),
+        AttributeValue::S(
+          login_state
+            .flattened_device_key_upload
+            .key_payload_signature,
+        ),
+      ),
+      (
+        USERS_TABLE_DEVICES_MAP_IDENTITY_PREKEY_ATTRIBUTE_NAME.to_string(),
+        AttributeValue::S(
+          login_state.flattened_device_key_upload.identity_prekey,
+        ),
+      ),
+      (
+        USERS_TABLE_DEVICES_MAP_IDENTITY_PREKEY_SIGNATURE_ATTRIBUTE_NAME
+          .to_string(),
+        AttributeValue::S(
+          login_state
+            .flattened_device_key_upload
+            .identity_prekey_signature,
+        ),
+      ),
+      (
+        USERS_TABLE_DEVICES_MAP_IDENTITY_ONETIME_KEYS_ATTRIBUTE_NAME
+          .to_string(),
+        AttributeValue::L(
+          login_state
+            .flattened_device_key_upload
+            .identity_onetime_keys
+            .into_iter()
+            .map(AttributeValue::S)
+            .collect(),
+        ),
+      ),
+      (
+        USERS_TABLE_DEVICES_MAP_NOTIF_PREKEY_ATTRIBUTE_NAME.to_string(),
+        AttributeValue::S(login_state.flattened_device_key_upload.notif_prekey),
+      ),
+      (
+        USERS_TABLE_DEVICES_MAP_NOTIF_PREKEY_SIGNATURE_ATTRIBUTE_NAME
+          .to_string(),
+        AttributeValue::S(
+          login_state
+            .flattened_device_key_upload
+            .notif_prekey_signature,
+        ),
+      ),
+      (
+        USERS_TABLE_DEVICES_MAP_NOTIF_ONETIME_KEYS_ATTRIBUTE_NAME.to_string(),
+        AttributeValue::L(
+          login_state
+            .flattened_device_key_upload
+            .notif_onetime_keys
+            .into_iter()
+            .map(AttributeValue::S)
+            .collect(),
+        ),
+      ),
+    ]);
+
+    let update_expression =
+      format!("SET {}.#{} = :v", USERS_TABLE_DEVICES_ATTRIBUTE, "deviceID",);
+    let expression_attribute_names = HashMap::from([(
+      format!("#{}", "deviceID"),
+      login_state.flattened_device_key_upload.device_id_key,
+    )]);
+    let expression_attribute_values =
+      HashMap::from([(":v".to_string(), AttributeValue::M(device_info))]);
+
+    self
+      .client
+      .update_item()
+      .table_name(USERS_TABLE)
+      .key(
+        USERS_TABLE_PARTITION_KEY,
+        AttributeValue::S(login_state.user_id),
+      )
+      .update_expression(update_expression)
+      .set_expression_attribute_names(Some(expression_attribute_names))
+      .set_expression_attribute_values(Some(expression_attribute_values))
+      .send()
+      .await
+      .map_err(|e| Error::AwsSdk(e.into()))?;
+
+    Ok(())
+  }
+
   pub async fn delete_user(
     &self,
     user_id: String,
@@ -444,19 +546,25 @@
     }
   }
 
-  pub async fn get_password_file_from_username(
+  pub async fn get_user_id_and_password_file_from_username(
     &self,
     username: &str,
-  ) -> Result<Option<Vec<u8>>, Error> {
+  ) -> Result<Option<(String, 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(Some(mut user)) => {
+        let user_id = parse_string_attribute(
+          USERS_TABLE_PARTITION_KEY,
+          user.remove(USERS_TABLE_PARTITION_KEY),
+        )?;
+        let password_file = parse_registration_data_attribute(
+          user.remove(USERS_TABLE_REGISTRATION_ATTRIBUTE),
+        )?;
+
+        Ok(Some((user_id, password_file)))
+      }
       Ok(_) => {
         info!(
           "No item found for user {} in PAKE registration table",