diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java
index 17dc5c497fe748ba55e55cadf76003d89563c03a..9384c2ffab837f216a6ca9eb119d729d090140f9 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java
@@ -379,7 +379,7 @@ public class AccountController {
   )
   @ApiResponse(responseCode = "200", description = "Account found for the given username.", useReturnTypeSchema = true)
   @ApiResponse(responseCode = "400", description = "Request must not be authenticated.")
-  @ApiResponse(responseCode = "404", description = "Account not fount for the given username.")
+  @ApiResponse(responseCode = "404", description = "Account not found for the given username.")
   public CompletableFuture<AccountIdentifierResponse> lookupUsernameHash(
       @Auth final Optional<AuthenticatedAccount> maybeAuthenticatedAccount,
       @PathParam("usernameHash") final String usernameHash) {
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AccountsAnonymousGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AccountsAnonymousGrpcService.java
new file mode 100644
index 0000000000000000000000000000000000000000..8936cb89d6cfeb50cf931f2ebfa6eaadee18cc3d
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AccountsAnonymousGrpcService.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.grpc;
+
+import com.google.protobuf.ByteString;
+import io.grpc.Status;
+import org.signal.chat.account.CheckAccountExistenceRequest;
+import org.signal.chat.account.CheckAccountExistenceResponse;
+import org.signal.chat.account.LookupUsernameHashRequest;
+import org.signal.chat.account.LookupUsernameHashResponse;
+import org.signal.chat.account.LookupUsernameLinkRequest;
+import org.signal.chat.account.LookupUsernameLinkResponse;
+import org.signal.chat.account.ReactorAccountsAnonymousGrpc;
+import org.whispersystems.textsecuregcm.controllers.AccountController;
+import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
+import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
+import org.whispersystems.textsecuregcm.limits.RateLimiters;
+import org.whispersystems.textsecuregcm.storage.Account;
+import org.whispersystems.textsecuregcm.storage.AccountsManager;
+import org.whispersystems.textsecuregcm.util.UUIDUtil;
+import reactor.core.publisher.Mono;
+import java.util.Optional;
+import java.util.UUID;
+
+public class AccountsAnonymousGrpcService extends ReactorAccountsAnonymousGrpc.AccountsAnonymousImplBase {
+
+  private final AccountsManager accountsManager;
+  private final RateLimiters rateLimiters;
+
+  public AccountsAnonymousGrpcService(final AccountsManager accountsManager, final RateLimiters rateLimiters) {
+    this.accountsManager = accountsManager;
+    this.rateLimiters = rateLimiters;
+  }
+
+  @Override
+  public Mono<CheckAccountExistenceResponse> checkAccountExistence(final CheckAccountExistenceRequest request) {
+    final ServiceIdentifier serviceIdentifier =
+        ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getServiceIdentifier());
+
+    return RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.getCheckAccountExistenceLimiter())
+        .then(Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(serviceIdentifier)))
+        .map(Optional::isPresent)
+        .map(accountExists -> CheckAccountExistenceResponse.newBuilder()
+            .setAccountExists(accountExists)
+            .build());
+  }
+
+  @Override
+  public Mono<LookupUsernameHashResponse> lookupUsernameHash(final LookupUsernameHashRequest request) {
+    if (request.getUsernameHash().size() != AccountController.USERNAME_HASH_LENGTH) {
+      throw Status.INVALID_ARGUMENT
+          .withDescription(String.format("Illegal username hash length; expected %d bytes, but got %d bytes",
+              AccountController.USERNAME_HASH_LENGTH, request.getUsernameHash().size()))
+          .asRuntimeException();
+    }
+
+    return RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.getUsernameLookupLimiter())
+        .then(Mono.fromFuture(() -> accountsManager.getByUsernameHash(request.getUsernameHash().toByteArray())))
+        .map(maybeAccount -> maybeAccount.orElseThrow(Status.NOT_FOUND::asRuntimeException))
+        .map(account -> LookupUsernameHashResponse.newBuilder()
+            .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
+            .build());
+  }
+
+  @Override
+  public Mono<LookupUsernameLinkResponse> lookupUsernameLink(final LookupUsernameLinkRequest request) {
+    final UUID linkHandle;
+
+    try {
+      linkHandle = UUIDUtil.fromByteString(request.getUsernameLinkHandle());
+    } catch (final IllegalArgumentException e) {
+      throw Status.INVALID_ARGUMENT
+          .withDescription("Could not interpret link handle as UUID")
+          .withCause(e)
+          .asRuntimeException();
+    }
+
+    return RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.getUsernameLinkLookupLimiter())
+        .then(Mono.fromFuture(() -> accountsManager.getByUsernameLinkHandle(linkHandle)))
+        .map(maybeAccount -> maybeAccount
+            .flatMap(Account::getEncryptedUsername)
+            .orElseThrow(Status.NOT_FOUND::asRuntimeException))
+        .map(usernameCiphertext -> LookupUsernameLinkResponse.newBuilder()
+            .setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext))
+            .build());
+  }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java
index bb6f8f23a3a9e908a239ba3f3dcf32105c52fa21..37cee6c9eead385f63d135ffa9ffe2eb5aa158bf 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java
@@ -163,6 +163,10 @@ public class RateLimiters extends BaseRateLimiters<RateLimiters.For> {
     return forDescriptor(For.USERNAME_LOOKUP);
   }
 
+  public RateLimiter getUsernameLinkLookupLimiter() {
+    return forDescriptor(For.USERNAME_LINK_LOOKUP_PER_IP);
+  }
+
   public RateLimiter getUsernameSetLimiter() {
     return forDescriptor(For.USERNAME_SET);
   }
diff --git a/service/src/main/proto/org/signal/chat/account.proto b/service/src/main/proto/org/signal/chat/account.proto
new file mode 100644
index 0000000000000000000000000000000000000000..d71e93878c42199df9a2c4731c33656c46e776f1
--- /dev/null
+++ b/service/src/main/proto/org/signal/chat/account.proto
@@ -0,0 +1,76 @@
+syntax = "proto3";
+
+option java_multiple_files = true;
+
+package org.signal.chat.account;
+
+import "org/signal/chat/common.proto";
+
+/**
+ * Provides methods for looking up Signal accounts. Callers must not provide
+ * identifying credentials when calling methods in this service.
+ */
+service AccountsAnonymous {
+  /**
+   * Checks whether an account with the given service identifier exists.
+   */
+  rpc CheckAccountExistence(CheckAccountExistenceRequest) returns (CheckAccountExistenceResponse) {}
+
+  /**
+   * Finds the service identifier of the account associated with the given
+   * username hash. This method will return a `NOT_FOUND` status if no account
+   * was found for the given username hash.
+   */
+  rpc LookupUsernameHash(LookupUsernameHashRequest) returns (LookupUsernameHashResponse) {}
+
+  /**
+   * Finds the encrypted username identified by a given username link handle.
+   * This method will return a `NOT_FOUND` status if no username was found for
+   * the given link handle.
+   */
+  rpc LookupUsernameLink(LookupUsernameLinkRequest) returns (LookupUsernameLinkResponse) {}
+}
+
+message CheckAccountExistenceRequest {
+  /**
+   * The service identifier of an account that may or may not exist.
+   */
+  common.ServiceIdentifier service_identifier = 1;
+}
+
+message CheckAccountExistenceResponse {
+  /**
+   * True if an account exists with the given service identifier or false if no
+   * account was found.
+   */
+  bool account_exists = 1;
+}
+
+message LookupUsernameHashRequest {
+  /**
+   * A 32-byte username hash for which to find an account.
+   */
+  bytes username_hash = 1;
+}
+
+message LookupUsernameHashResponse {
+  /**
+   * The service identifier associated with a given username hash.
+   */
+  common.ServiceIdentifier service_identifier = 1;
+}
+
+message LookupUsernameLinkRequest {
+  /**
+   * The link handle for which to find an encrypted username. Link handles are
+   * 16-byte representations of UUIDs.
+   */
+  bytes username_link_handle = 1;
+}
+
+message LookupUsernameLinkResponse {
+  /**
+   * The ciphertext of the username identified by the given link handle.
+   */
+  bytes username_ciphertext = 1;
+}
diff --git a/service/src/main/proto/org/signal/chat/common.proto b/service/src/main/proto/org/signal/chat/common.proto
index 7d4af557d2066fa3a97273303bdbbc9c74b619ef..1266ae5bc3eeffbc09a92301f9ef5ad529a016c5 100644
--- a/service/src/main/proto/org/signal/chat/common.proto
+++ b/service/src/main/proto/org/signal/chat/common.proto
@@ -27,6 +27,12 @@ message ServiceIdentifier {
   bytes uuid = 2;
 }
 
+message AccountIdentifiers {
+  repeated ServiceIdentifier service_identifiers = 1;
+  string e164 = 2;
+  bytes username_hash = 3;
+}
+
 message EcPreKey {
   /**
    * A locally-unique identifier for this key.
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/AccountsAnonymousGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/AccountsAnonymousGrpcServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..fb4911b203a114aeae9db36579be65dd14cc802d
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/AccountsAnonymousGrpcServiceTest.java
@@ -0,0 +1,268 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.grpc;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.protobuf.ByteString;
+import io.grpc.Status;
+import java.net.InetSocketAddress;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.Mock;
+import org.signal.chat.account.AccountsAnonymousGrpc;
+import org.signal.chat.account.CheckAccountExistenceRequest;
+import org.signal.chat.account.LookupUsernameHashRequest;
+import org.signal.chat.account.LookupUsernameLinkRequest;
+import org.signal.chat.common.IdentityType;
+import org.signal.chat.common.ServiceIdentifier;
+import org.whispersystems.textsecuregcm.controllers.AccountController;
+import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
+import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
+import org.whispersystems.textsecuregcm.limits.RateLimiter;
+import org.whispersystems.textsecuregcm.limits.RateLimiters;
+import org.whispersystems.textsecuregcm.storage.Account;
+import org.whispersystems.textsecuregcm.storage.AccountsManager;
+import org.whispersystems.textsecuregcm.util.UUIDUtil;
+import reactor.core.publisher.Mono;
+
+class AccountsAnonymousGrpcServiceTest extends
+    SimpleBaseGrpcTest<AccountsAnonymousGrpcService, AccountsAnonymousGrpc.AccountsAnonymousBlockingStub> {
+
+  @Mock
+  private AccountsManager accountsManager;
+
+  @Mock
+  private RateLimiters rateLimiters;
+
+  @Mock
+  private RateLimiter rateLimiter;
+
+  @Override
+  protected AccountsAnonymousGrpcService createServiceBeforeEachTest() {
+    when(accountsManager.getByServiceIdentifierAsync(any()))
+        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));
+
+    when(accountsManager.getByUsernameHash(any()))
+        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));
+
+    when(accountsManager.getByUsernameLinkHandle(any()))
+        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));
+
+    when(rateLimiters.getCheckAccountExistenceLimiter()).thenReturn(rateLimiter);
+    when(rateLimiters.getUsernameLookupLimiter()).thenReturn(rateLimiter);
+    when(rateLimiters.getUsernameLinkLookupLimiter()).thenReturn(rateLimiter);
+
+    when(rateLimiter.validateReactive(anyString())).thenReturn(Mono.empty());
+
+    getMockRemoteAddressInterceptor().setRemoteAddress(new InetSocketAddress("127.0.0.1", 12345));
+
+    return new AccountsAnonymousGrpcService(accountsManager, rateLimiters);
+  }
+
+  @Test
+  void checkAccountExistence() {
+    final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());
+
+    when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier))
+        .thenReturn(CompletableFuture.completedFuture(Optional.of(mock(Account.class))));
+
+    assertTrue(unauthenticatedServiceStub().checkAccountExistence(CheckAccountExistenceRequest.newBuilder()
+        .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier))
+        .build()).getAccountExists());
+
+    assertFalse(unauthenticatedServiceStub().checkAccountExistence(CheckAccountExistenceRequest.newBuilder()
+        .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(UUID.randomUUID())))
+        .build()).getAccountExists());
+  }
+
+  @ParameterizedTest
+  @MethodSource
+  void checkAccountExistenceIllegalRequest(final CheckAccountExistenceRequest request) {
+    //noinspection ResultOfMethodCallIgnored
+    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,
+        () -> unauthenticatedServiceStub().checkAccountExistence(request));
+  }
+
+  private static Stream<Arguments> checkAccountExistenceIllegalRequest() {
+    return Stream.of(
+        // No service identifier
+        Arguments.of(CheckAccountExistenceRequest.newBuilder().build()),
+
+        // Bad service identifier
+        Arguments.of(CheckAccountExistenceRequest.newBuilder()
+            .setServiceIdentifier(ServiceIdentifier.newBuilder()
+                .setIdentityType(IdentityType.IDENTITY_TYPE_ACI)
+                .setUuid(ByteString.copyFrom(new byte[15]))
+                .build())
+            .build())
+    );
+  }
+
+  @Test
+  void checkAccountExistenceRateLimited() {
+    final Duration retryAfter = Duration.ofSeconds(11);
+
+    when(rateLimiter.validateReactive(anyString()))
+        .thenReturn(Mono.error(new RateLimitExceededException(retryAfter, false)));
+
+    //noinspection ResultOfMethodCallIgnored
+    GrpcTestUtils.assertRateLimitExceeded(retryAfter,
+        () -> unauthenticatedServiceStub().checkAccountExistence(CheckAccountExistenceRequest.newBuilder()
+            .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(UUID.randomUUID())))
+            .build()),
+        accountsManager);
+  }
+
+  @Test
+  void lookupUsernameHash() {
+    final UUID accountIdentifier = UUID.randomUUID();
+
+    final byte[] usernameHash = new byte[AccountController.USERNAME_HASH_LENGTH];
+    new SecureRandom().nextBytes(usernameHash);
+
+    final Account account = mock(Account.class);
+    when(account.getUuid()).thenReturn(accountIdentifier);
+
+    when(accountsManager.getByUsernameHash(usernameHash))
+        .thenReturn(CompletableFuture.completedFuture(Optional.of(account)));
+
+    assertEquals(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(accountIdentifier)),
+        unauthenticatedServiceStub().lookupUsernameHash(LookupUsernameHashRequest.newBuilder()
+                .setUsernameHash(ByteString.copyFrom(usernameHash))
+                .build())
+            .getServiceIdentifier());
+
+    //noinspection ResultOfMethodCallIgnored
+    GrpcTestUtils.assertStatusException(Status.NOT_FOUND,
+        () -> unauthenticatedServiceStub().lookupUsernameHash(LookupUsernameHashRequest.newBuilder()
+            .setUsernameHash(ByteString.copyFrom(new byte[AccountController.USERNAME_HASH_LENGTH]))
+            .build()));
+  }
+
+  @ParameterizedTest
+  @MethodSource
+  void lookupUsernameHashIllegalHash(final LookupUsernameHashRequest request) {
+    //noinspection ResultOfMethodCallIgnored
+    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,
+        () -> unauthenticatedServiceStub().lookupUsernameHash(request));
+  }
+
+  private static Stream<Arguments> lookupUsernameHashIllegalHash() {
+    return Stream.of(
+        // No username hash
+        Arguments.of(LookupUsernameHashRequest.newBuilder().build()),
+
+        // Hash too long
+        Arguments.of(LookupUsernameHashRequest.newBuilder()
+            .setUsernameHash(ByteString.copyFrom(new byte[AccountController.USERNAME_HASH_LENGTH + 1]))
+            .build()),
+
+        // Hash too short
+        Arguments.of(LookupUsernameHashRequest.newBuilder()
+            .setUsernameHash(ByteString.copyFrom(new byte[AccountController.USERNAME_HASH_LENGTH - 1]))
+            .build())
+    );
+  }
+
+  @Test
+  void lookupUsernameHashRateLimited() {
+    final Duration retryAfter = Duration.ofSeconds(13);
+
+    when(rateLimiter.validateReactive(anyString()))
+        .thenReturn(Mono.error(new RateLimitExceededException(retryAfter, false)));
+
+    //noinspection ResultOfMethodCallIgnored
+    GrpcTestUtils.assertRateLimitExceeded(retryAfter,
+        () -> unauthenticatedServiceStub().lookupUsernameHash(LookupUsernameHashRequest.newBuilder()
+            .setUsernameHash(ByteString.copyFrom(new byte[AccountController.USERNAME_HASH_LENGTH]))
+            .build()),
+        accountsManager);
+  }
+
+  @Test
+  void lookupUsernameLink() {
+    final UUID linkHandle = UUID.randomUUID();
+
+    final byte[] usernameCiphertext = new byte[32];
+    new SecureRandom().nextBytes(usernameCiphertext);
+
+    final Account account = mock(Account.class);
+    when(account.getEncryptedUsername()).thenReturn(Optional.of(usernameCiphertext));
+
+    when(accountsManager.getByUsernameLinkHandle(linkHandle))
+        .thenReturn(CompletableFuture.completedFuture(Optional.of(account)));
+
+    assertEquals(ByteString.copyFrom(usernameCiphertext),
+        unauthenticatedServiceStub().lookupUsernameLink(LookupUsernameLinkRequest.newBuilder()
+                .setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle))
+                .build())
+            .getUsernameCiphertext());
+
+    when(account.getEncryptedUsername()).thenReturn(Optional.empty());
+
+    //noinspection ResultOfMethodCallIgnored
+    GrpcTestUtils.assertStatusException(Status.NOT_FOUND,
+        () -> unauthenticatedServiceStub().lookupUsernameLink(LookupUsernameLinkRequest.newBuilder()
+            .setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle))
+            .build()));
+
+    //noinspection ResultOfMethodCallIgnored
+    GrpcTestUtils.assertStatusException(Status.NOT_FOUND,
+        () -> unauthenticatedServiceStub().lookupUsernameLink(LookupUsernameLinkRequest.newBuilder()
+            .setUsernameLinkHandle(UUIDUtil.toByteString(UUID.randomUUID()))
+            .build()));
+  }
+
+  @ParameterizedTest
+  @MethodSource
+  void lookupUsernameLinkIllegalHandle(final LookupUsernameLinkRequest request) {
+    //noinspection ResultOfMethodCallIgnored
+    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,
+        () -> unauthenticatedServiceStub().lookupUsernameLink(request));
+  }
+
+  private static Stream<Arguments> lookupUsernameLinkIllegalHandle() {
+    return Stream.of(
+        // No handle
+        Arguments.of(LookupUsernameLinkRequest.newBuilder().build()),
+
+        // Bad handle length
+        Arguments.of(LookupUsernameLinkRequest.newBuilder()
+            .setUsernameLinkHandle(ByteString.copyFrom(new byte[15]))
+            .build())
+    );
+  }
+
+  @Test
+  void lookupUsernameLinkRateLimited() {
+    final Duration retryAfter = Duration.ofSeconds(17);
+
+    when(rateLimiter.validateReactive(anyString()))
+        .thenReturn(Mono.error(new RateLimitExceededException(retryAfter, false)));
+
+    //noinspection ResultOfMethodCallIgnored
+    GrpcTestUtils.assertRateLimitExceeded(retryAfter,
+        () -> unauthenticatedServiceStub().lookupUsernameLink(LookupUsernameLinkRequest.newBuilder()
+            .setUsernameLinkHandle(UUIDUtil.toByteString(UUID.randomUUID()))
+            .build()),
+        accountsManager);
+  }
+}