diff --git a/services/identity/Cargo.lock b/services/identity/Cargo.lock
--- a/services/identity/Cargo.lock
+++ b/services/identity/Cargo.lock
@@ -119,6 +119,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4"
 dependencies = [
+ "block-padding",
  "generic-array",
 ]
 
@@ -131,6 +132,12 @@
  "generic-array",
 ]
 
+[[package]]
+name = "block-padding"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d696c370c750c948ada61c69a0ee2cbbb9c50b1019ddb86d9317157a99c2cae"
+
 [[package]]
 name = "bumpalo"
 version = "3.9.1"
@@ -266,6 +273,18 @@
  "cfg-if",
 ]
 
+[[package]]
+name = "crypto-bigint"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83bd3bb4314701c568e340cd8cf78c975aa0ca79e03d3f6d1677d5b0c9c0c03"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.3",
+ "subtle",
+ "zeroize",
+]
+
 [[package]]
 name = "crypto-common"
 version = "0.1.3"
@@ -299,6 +318,12 @@
  "zeroize",
 ]
 
+[[package]]
+name = "der"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79b71cca7d95d7681a4b3b9cdf63c8dbc3730d0584c2c74e31416d64a90493f4"
+
 [[package]]
 name = "derive_more"
 version = "0.99.17"
@@ -364,12 +389,39 @@
  "syn",
 ]
 
+[[package]]
+name = "ecdsa"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43ee23aa5b4f68c7a092b5c3beb25f50c406adc75e2363634f242f28ab255372"
+dependencies = [
+ "der",
+ "elliptic-curve",
+ "hmac",
+ "signature",
+]
+
 [[package]]
 name = "either"
 version = "1.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457"
 
+[[package]]
+name = "elliptic-curve"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "beca177dcb8eb540133e7680baff45e7cc4d93bf22002676cec549f82343721b"
+dependencies = [
+ "crypto-bigint",
+ "ff",
+ "generic-array",
+ "group",
+ "rand_core 0.6.3",
+ "subtle",
+ "zeroize",
+]
+
 [[package]]
 name = "fastrand"
 version = "1.7.0"
@@ -379,6 +431,16 @@
  "instant",
 ]
 
+[[package]]
+name = "ff"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0f40b2dcd8bc322217a5f6559ae5f9e9d1de202a2ecee2e9eafcbece7562a4f"
+dependencies = [
+ "rand_core 0.6.3",
+ "subtle",
+]
+
 [[package]]
 name = "fixedbitset"
 version = "0.4.1"
@@ -529,6 +591,17 @@
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "group"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c363a5301b8f153d80747126a04b3c82073b9fe3130571a9d170cacdeaf7912"
+dependencies = [
+ "ff",
+ "rand_core 0.6.3",
+ "subtle",
+]
+
 [[package]]
 name = "h2"
 version = "0.3.13"
@@ -706,7 +779,9 @@
  "rusoto_core",
  "rusoto_dynamodb",
  "sha2",
+ "siwe",
  "tokio",
+ "tokio-stream",
  "tonic",
  "tonic-build",
  "tracing",
@@ -732,6 +807,15 @@
  "cfg-if",
 ]
 
+[[package]]
+name = "iri-string"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f0f7638c1e223529f1bfdc48c8b133b9e0b434094d1d28473161ee48b235f78"
+dependencies = [
+ "nom",
+]
+
 [[package]]
 name = "itertools"
 version = "0.10.3"
@@ -756,6 +840,24 @@
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "k256"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "903ae2481bcdfdb7b68e0a9baa4b7c9aff600b9ae2e8e5bb5833b8c91ab851ea"
+dependencies = [
+ "cfg-if",
+ "ecdsa",
+ "elliptic-curve",
+ "sha3",
+]
+
+[[package]]
+name = "keccak"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9b7d56ba4a8344d6be9729995e6b06f928af29998cdf79fe390cbf6b1fee838"
+
 [[package]]
 name = "lazy_static"
 version = "1.4.0"
@@ -794,6 +896,12 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
 
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
 [[package]]
 name = "mio"
 version = "0.8.2"
@@ -841,6 +949,16 @@
  "tempfile",
 ]
 
+[[package]]
+name = "nom"
+version = "7.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
 [[package]]
 name = "ntapi"
 version = "0.3.7"
@@ -1393,6 +1511,18 @@
  "opaque-debug",
 ]
 
+[[package]]
+name = "sha3"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f81199417d4e5de3f04b1e871023acea7389672c4135918f05aa9cbf2f2fa809"
+dependencies = [
+ "block-buffer 0.9.0",
+ "digest 0.9.0",
+ "keccak",
+ "opaque-debug",
+]
+
 [[package]]
 name = "sharded-slab"
 version = "0.1.4"
@@ -1417,6 +1547,32 @@
  "libc",
 ]
 
+[[package]]
+name = "signature"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2807892cfa58e081aa1f1111391c7a0649d4fa127a4ffbe34bcbfb35a1171a4"
+dependencies = [
+ "digest 0.9.0",
+ "rand_core 0.6.3",
+]
+
+[[package]]
+name = "siwe"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86f2d8ae2d4ae58df46e173aa496562ea857ac6a4f0d435ed30fcd19da0aaa79"
+dependencies = [
+ "chrono",
+ "hex",
+ "http",
+ "iri-string",
+ "k256",
+ "rand",
+ "sha3",
+ "thiserror",
+]
+
 [[package]]
 name = "slab"
 version = "0.4.6"
@@ -1594,9 +1750,9 @@
 
 [[package]]
 name = "tokio-stream"
-version = "0.1.8"
+version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50145484efff8818b5ccd256697f36863f587da82cf8b409c53adf1e840798e3"
+checksum = "df54d54117d6fdc4e4fea40fe1e4e566b3505700e148a6827e59b34b0d2600d9"
 dependencies = [
  "futures-core",
  "pin-project-lite",
diff --git a/services/identity/Cargo.toml b/services/identity/Cargo.toml
--- a/services/identity/Cargo.toml
+++ b/services/identity/Cargo.toml
@@ -8,6 +8,7 @@
 prost = "0.9"
 futures-core = "0.3"
 tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] }
+tokio-stream = "0.1.9"
 opaque-ke = { version = "1.2.0", features = ["std"] }
 argon2 = "0.3"
 curve25519-dalek = "3"
@@ -23,6 +24,7 @@
 rand = "0.8"
 bytes = "1.1"
 constant_time_eq = "0.2.2"
+siwe = "0.3"
 
 [build-dependencies]
 tonic-build = "0.6"
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
@@ -13,6 +13,7 @@
 use crate::opaque::Cipher;
 use crate::token::{AccessTokenData, AuthType};
 
+#[derive(Clone)]
 pub struct DatabaseClient {
   client: DynamoDbClient,
 }
diff --git a/services/identity/src/service.rs b/services/identity/src/service.rs
--- a/services/identity/src/service.rs
+++ b/services/identity/src/service.rs
@@ -1,9 +1,14 @@
+use chrono::Utc;
 use constant_time_eq::constant_time_eq;
 use futures_core::Stream;
+use rand::rngs::OsRng;
 use rand::{CryptoRng, Rng};
 use rusoto_core::RusotoError;
 use rusoto_dynamodb::{GetItemError, PutItemError};
+use siwe::Message;
 use std::pin::Pin;
+use tokio::sync::mpsc;
+use tokio_stream::{wrappers::ReceiverStream, StreamExt};
 use tonic::{Request, Response, Status};
 use tracing::{error, info, instrument};
 
@@ -14,11 +19,14 @@
 pub use proto::identity_service_server::IdentityServiceServer;
 use proto::{
   identity_service_server::IdentityService,
+  login_request::Data::PakeLoginRequest,
+  login_request::Data::WalletLoginRequest,
   login_response::Data::PakeLoginResponse,
   login_response::Data::WalletLoginResponse,
   pake_login_response::Data::AccessToken, LoginRequest, LoginResponse,
   PakeLoginResponse as PakeLoginResponseStruct, RegistrationRequest,
   RegistrationResponse, VerifyUserTokenRequest, VerifyUserTokenResponse,
+  WalletLoginRequest as WalletLoginRequestStruct,
   WalletLoginResponse as WalletLoginResponseStruct,
 };
 
@@ -56,12 +64,65 @@
   type LoginUserStream =
     Pin<Box<dyn Stream<Item = Result<LoginResponse, Status>> + Send + 'static>>;
 
+  #[instrument(skip(self))]
   async fn login_user(
     &self,
     request: Request<tonic::Streaming<LoginRequest>>,
   ) -> Result<Response<Self::LoginUserStream>, Status> {
-    println!("Got a login request: {:?}", request);
-    unimplemented!()
+    let mut in_stream = request.into_inner();
+    let (tx, rx) = mpsc::channel(1);
+    let client = self.client.clone();
+    tokio::spawn(async move {
+      let mut num_messages_received = 0;
+      while let Some(message) = in_stream.next().await {
+        match message {
+          Ok(login_request) => {
+            if let Some(data) = login_request.data {
+              match data {
+                WalletLoginRequest(req) => {
+                  if let Err(e) = tx
+                    .send(
+                      wallet_login_helper(
+                        client,
+                        req,
+                        &mut OsRng,
+                        num_messages_received,
+                      )
+                      .await,
+                    )
+                    .await
+                  {
+                    error!("Response was dropped: {}", e);
+                  }
+                  break;
+                }
+                PakeLoginRequest(_) => unimplemented!(),
+              }
+            } else {
+              error!("Received empty login request");
+              if let Err(e) = tx
+                .send(Err(Status::invalid_argument("invalid message")))
+                .await
+              {
+                error!("Response was dropped: {}", e);
+              }
+              break;
+            }
+          }
+          Err(e) => {
+            error!("Received an unexpected error: {}", e);
+            if let Err(e) = tx.send(Err(Status::unknown("unknown error"))).await
+            {
+              error!("Response was dropped: {}", e);
+            }
+            break;
+          }
+        }
+        num_messages_received += 1;
+      }
+    });
+    let out_stream = ReceiverStream::new(rx);
+    Ok(Response::new(Box::pin(out_stream) as Self::LoginUserStream))
   }
 
   #[instrument(skip(self))]
@@ -139,3 +200,78 @@
     }
   }
 }
+
+fn parse_and_verify_siwe_message(
+  user_id: &str,
+  device_id: &str,
+  siwe_message: &str,
+  siwe_signature: Vec<u8>,
+) -> Result<(), Status> {
+  if user_id.is_empty() || device_id.is_empty() {
+    error!(
+      "Incomplete data: user ID {}, device ID {}",
+      user_id, device_id
+    );
+    return Err(Status::aborted("user not found"));
+  }
+  let siwe_message: Message = match siwe_message.parse() {
+    Ok(m) => m,
+    Err(e) => {
+      error!("Failed to parse SIWE message: {}", e);
+      return Err(Status::invalid_argument("invalid message"));
+    }
+  };
+  match siwe_message.verify(
+    match siwe_signature.try_into() {
+      Ok(s) => s,
+      Err(e) => {
+        error!("Conversion to SIWE signature failed: {:?}", e);
+        return Err(Status::invalid_argument("invalid message"));
+      }
+    },
+    None,
+    None,
+    Some(&Utc::now()),
+  ) {
+    Err(e) => {
+      error!(
+        "Signature verification failed for user {} on device {}: {}",
+        user_id, device_id, e
+      );
+      Err(Status::unauthenticated("message not authenticated"))
+    }
+    Ok(_) => Ok(()),
+  }
+}
+
+async fn wallet_login_helper(
+  client: DatabaseClient,
+  wallet_login_request: WalletLoginRequestStruct,
+  rng: &mut (impl Rng + CryptoRng),
+  num_messages_received: u8,
+) -> Result<LoginResponse, Status> {
+  if num_messages_received != 0 {
+    error!("Too many messages received in stream, aborting");
+    return Err(Status::aborted("please retry"));
+  }
+  match parse_and_verify_siwe_message(
+    &wallet_login_request.user_id,
+    &wallet_login_request.device_id,
+    &wallet_login_request.siwe_message,
+    wallet_login_request.siwe_signature,
+  ) {
+    Ok(()) => Ok(LoginResponse {
+      data: Some(WalletLoginResponse(WalletLoginResponseStruct {
+        access_token: put_token_helper(
+          client,
+          AuthType::Wallet,
+          &wallet_login_request.user_id,
+          &wallet_login_request.device_id,
+          rng,
+        )
+        .await?,
+      })),
+    }),
+    Err(e) => Err(e),
+  }
+}