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
@@ -50,6 +50,7 @@
 pub use grpc_clients::identity::DeviceType;
 
 mod device_list;
+pub use device_list::DeviceListRow;
 
 #[derive(Serialize, Deserialize)]
 pub struct OlmKeys {
diff --git a/services/identity/src/ddb_utils.rs b/services/identity/src/ddb_utils.rs
--- a/services/identity/src/ddb_utils.rs
+++ b/services/identity/src/ddb_utils.rs
@@ -1,4 +1,5 @@
 use aws_sdk_dynamodb::model::{AttributeValue, PutRequest, WriteRequest};
+use chrono::{DateTime, NaiveDateTime, Utc};
 use std::collections::HashMap;
 use std::iter::IntoIterator;
 
@@ -78,3 +79,14 @@
     })
   }
 }
+
+pub trait DateTimeExt {
+  fn from_utc_timestamp_millis(timestamp: i64) -> Option<DateTime<Utc>>;
+}
+
+impl DateTimeExt for DateTime<Utc> {
+  fn from_utc_timestamp_millis(timestamp: i64) -> Option<Self> {
+    let naive = NaiveDateTime::from_timestamp_millis(timestamp)?;
+    Some(Self::from_utc(naive, Utc))
+  }
+}
diff --git a/services/identity/src/grpc_services/authenticated.rs b/services/identity/src/grpc_services/authenticated.rs
--- a/services/identity/src/grpc_services/authenticated.rs
+++ b/services/identity/src/grpc_services/authenticated.rs
@@ -1,6 +1,7 @@
 use std::collections::HashMap;
 
 use crate::config::CONFIG;
+use crate::database::DeviceListRow;
 use crate::grpc_utils::DeviceInfoWithAuth;
 use crate::{
   client_service::{
@@ -8,9 +9,11 @@
   },
   constants::request_metadata,
   database::DatabaseClient,
+  ddb_utils::DateTimeExt,
   grpc_services::shared::get_value,
   token::AuthType,
 };
+use chrono::{DateTime, Utc};
 use comm_opaque2::grpc::protocol_error_to_grpc_status;
 use moka::future::Cache;
 use tonic::{Request, Response, Status};
@@ -18,9 +21,10 @@
 
 use super::protos::auth::{
   find_user_id_request, identity_client_service_server::IdentityClientService,
-  FindUserIdRequest, FindUserIdResponse, InboundKeyInfo,
-  InboundKeysForUserRequest, InboundKeysForUserResponse, KeyserverKeysResponse,
-  OutboundKeyInfo, OutboundKeysForUserRequest, OutboundKeysForUserResponse,
+  FindUserIdRequest, FindUserIdResponse, GetDeviceListRequest,
+  GetDeviceListResponse, InboundKeyInfo, InboundKeysForUserRequest,
+  InboundKeysForUserResponse, KeyserverKeysResponse, OutboundKeyInfo,
+  OutboundKeysForUserRequest, OutboundKeysForUserResponse,
   RefreshUserPreKeysRequest, UpdateUserPasswordFinishRequest,
   UpdateUserPasswordStartRequest, UpdateUserPasswordStartResponse,
   UploadOneTimeKeysRequest,
@@ -375,4 +379,130 @@
     let response = Empty {};
     Ok(Response::new(response))
   }
+
+  async fn get_device_list_for_user(
+    &self,
+    request: tonic::Request<GetDeviceListRequest>,
+  ) -> Result<tonic::Response<GetDeviceListResponse>, tonic::Status> {
+    let GetDeviceListRequest {
+      user_id,
+      since_timestamp,
+    } = request.into_inner();
+
+    let since = since_timestamp
+      .map(|timestamp| {
+        DateTime::<Utc>::from_utc_timestamp_millis(timestamp)
+          .ok_or_else(|| tonic::Status::invalid_argument("Invalid timestamp"))
+      })
+      .transpose()?;
+
+    let mut db_result = self
+      .db_client
+      .get_device_list_history(user_id, since)
+      .await
+      .map_err(handle_db_error)?;
+
+    // these should be sorted already, but just in case
+    db_result.sort_by_key(|list| list.timestamp);
+
+    let device_list_updates: Vec<SignedDeviceList> = db_result
+      .into_iter()
+      .map(RawDeviceList::from)
+      .map(SignedDeviceList::try_from_raw)
+      .collect::<Result<Vec<_>, _>>()?;
+
+    let stringified_updates = device_list_updates
+      .iter()
+      .map(serde_json::to_string)
+      .collect::<Result<Vec<_>, _>>()
+      .map_err(|err| {
+        error!("Failed to serialize device list updates: {}", err);
+        tonic::Status::failed_precondition("unexpected error")
+      })?;
+
+    Ok(Response::new(GetDeviceListResponse {
+      device_list_updates: stringified_updates,
+    }))
+  }
+}
+
+// raw device list that can be serialized to JSON (and then signed in the future)
+#[derive(serde::Serialize)]
+struct RawDeviceList {
+  devices: Vec<String>,
+  timestamp: i64,
+}
+
+impl From<DeviceListRow> for RawDeviceList {
+  fn from(row: DeviceListRow) -> Self {
+    Self {
+      devices: row.device_ids,
+      timestamp: row.timestamp.timestamp_millis(),
+    }
+  }
+}
+
+#[derive(serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SignedDeviceList {
+  /// JSON-stringified [`RawDeviceList`]
+  raw_device_list: String,
+}
+
+impl SignedDeviceList {
+  /// Serialize (and sign in the future) a [`RawDeviceList`]
+  fn try_from_raw(raw: RawDeviceList) -> Result<Self, tonic::Status> {
+    let stringified_list = serde_json::to_string(&raw).map_err(|err| {
+      error!("Failed to serialize raw device list: {}", err);
+      tonic::Status::failed_precondition("unexpected error")
+    })?;
+
+    Ok(Self {
+      raw_device_list: stringified_list,
+    })
+  }
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+
+  #[test]
+  fn serialize_device_list_updates() {
+    let raw_updates = vec![
+      RawDeviceList {
+        devices: vec!["device1".into()],
+        timestamp: 111111111,
+      },
+      RawDeviceList {
+        devices: vec!["device1".into(), "device2".into()],
+        timestamp: 222222222,
+      },
+    ];
+
+    let expected_raw_list1 = r#"{"devices":["device1"],"timestamp":111111111}"#;
+    let expected_raw_list2 =
+      r#"{"devices":["device1","device2"],"timestamp":222222222}"#;
+
+    let signed_updates = raw_updates
+      .into_iter()
+      .map(SignedDeviceList::try_from_raw)
+      .collect::<Result<Vec<_>, _>>()
+      .expect("signing device list updates failed");
+
+    assert_eq!(signed_updates[0].raw_device_list, expected_raw_list1);
+    assert_eq!(signed_updates[1].raw_device_list, expected_raw_list2);
+
+    let stringified_updates = signed_updates
+      .iter()
+      .map(serde_json::to_string)
+      .collect::<Result<Vec<_>, _>>()
+      .expect("serialize signed device lists failed");
+
+    let expected_stringified_list1 = r#"{"rawDeviceList":"{\"devices\":[\"device1\"],\"timestamp\":111111111}"}"#;
+    let expected_stringified_list2 = r#"{"rawDeviceList":"{\"devices\":[\"device1\",\"device2\"],\"timestamp\":222222222}"}"#;
+
+    assert_eq!(stringified_updates[0], expected_stringified_list1);
+    assert_eq!(stringified_updates[1], expected_stringified_list2);
+  }
 }
diff --git a/shared/protos/identity_authenticated.proto b/shared/protos/identity_authenticated.proto
--- a/shared/protos/identity_authenticated.proto
+++ b/shared/protos/identity_authenticated.proto
@@ -52,6 +52,10 @@
 
   // Returns userID for given username or wallet address
   rpc FindUserID(FindUserIDRequest) returns (FindUserIDResponse) {}
+
+  // Returns device list history
+  rpc GetDeviceListForUser(GetDeviceListRequest) returns
+    (GetDeviceListResponse) {}
 }
 
 // Helper types
@@ -157,3 +161,24 @@
   string sessionID = 1;
   bytes opaqueRegistrationResponse = 2;
 }
+
+// GetDeviceListForUser
+
+message GetDeviceListRequest {
+  // User whose device lists we want to retrieve
+  string user_id = 1;
+  // UTC timestamp in milliseconds
+  // If none, whole device list history will be retrieved
+  optional int64 since_timestamp = 2;
+}
+
+message GetDeviceListResponse {
+  // A list of stringified JSON objects of the following format:
+  // {
+  //   "rawDeviceList": JSON.stringify({
+  //     "devices": [<device_id: string>, ...]
+  //     "timestamp": <UTC timestamp in milliseconds: int>,
+  //   })
+  // }
+  repeated string device_list_updates = 1;
+}