diff --git a/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/MutableCredentials.java b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/MutableCredentials.java
new file mode 100644
index 00000000000..b37323eaaf1
--- /dev/null
+++ b/google-cloud-spanner/src/main/java/com/google/cloud/spanner/connection/MutableCredentials.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.cloud.spanner.connection;
+
+import com.google.auth.CredentialTypeForMetrics;
+import com.google.auth.Credentials;
+import com.google.auth.RequestMetadataCallback;
+import com.google.auth.oauth2.ServiceAccountCredentials;
+import java.io.IOException;
+import java.net.URI;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Executor;
+
+/**
+ * A mutable {@link Credentials} implementation that delegates authentication behavior to a scoped
+ * {@link ServiceAccountCredentials} instance.
+ *
+ *
This class is intended for scenarios where an application needs to replace the underlying
+ * service account credentials for a long running Spanner Client.
+ *
+ *
All operations inherited from {@link Credentials} are forwarded to the current delegate,
+ * including request metadata retrieval and token refresh. Calling {@link
+ * #updateCredentials(ServiceAccountCredentials)} replaces the delegate with a newly scoped
+ * credentials instance created from the same scopes that were provided when this object was
+ * constructed.
+ */
+public class MutableCredentials extends Credentials {
+ private volatile ServiceAccountCredentials delegate;
+ private final List scopes;
+
+ public MutableCredentials(ServiceAccountCredentials credentials, List scopes) {
+ if (scopes != null) {
+ this.scopes = new java.util.ArrayList<>(scopes);
+ } else {
+ this.scopes = Collections.emptyList();
+ }
+ delegate = (ServiceAccountCredentials) credentials.createScoped(this.scopes);
+ }
+
+ /**
+ * Replaces the current delegate with a newly scoped credentials instance.
+ *
+ * The provided {@link ServiceAccountCredentials} is scoped using the same scopes that were
+ * supplied when this {@link MutableCredentials} instance was created.
+ *
+ * @param credentials the new base service account credentials to scope and use for client
+ * authorization.
+ */
+ public void updateCredentials(ServiceAccountCredentials credentials) {
+ delegate = (ServiceAccountCredentials) credentials.createScoped(scopes);
+ }
+
+ @Override
+ public String getAuthenticationType() {
+ return delegate.getAuthenticationType();
+ }
+
+ @Override
+ public Map> getRequestMetadata(URI uri) throws IOException {
+ return delegate.getRequestMetadata(uri);
+ }
+
+ @Override
+ public boolean hasRequestMetadata() {
+ return delegate.hasRequestMetadata();
+ }
+
+ @Override
+ public boolean hasRequestMetadataOnly() {
+ return delegate.hasRequestMetadataOnly();
+ }
+
+ @Override
+ public void refresh() throws IOException {
+ delegate.refresh();
+ }
+
+ @Override
+ public void getRequestMetadata(URI uri, Executor executor, RequestMetadataCallback callback) {
+ delegate.getRequestMetadata(uri, executor, callback);
+ }
+
+ @Override
+ public String getUniverseDomain() throws IOException {
+ return delegate.getUniverseDomain();
+ }
+
+ @Override
+ public CredentialTypeForMetrics getMetricsCredentialType() {
+ return delegate.getMetricsCredentialType();
+ }
+}
diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/MutableCredentialsTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/MutableCredentialsTest.java
new file mode 100644
index 00000000000..e4bf0820a24
--- /dev/null
+++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/MutableCredentialsTest.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.spanner.connection;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.auth.CredentialTypeForMetrics;
+import com.google.auth.RequestMetadataCallback;
+import com.google.auth.oauth2.ServiceAccountCredentials;
+import java.io.IOException;
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Executor;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class MutableCredentialsTest {
+ ServiceAccountCredentials initialCredentials = mock(ServiceAccountCredentials.class);
+ ServiceAccountCredentials initialScopedCredentials = mock(ServiceAccountCredentials.class);
+ ServiceAccountCredentials updatedCredentials = mock(ServiceAccountCredentials.class);
+ ServiceAccountCredentials updatedScopedCredentials = mock(ServiceAccountCredentials.class);
+ List scopes = Arrays.asList("scope-a", "scope-b");
+ Map> initialMetadata =
+ Collections.singletonMap("Authorization", Collections.singletonList("v1"));
+ Map> updatedMetadata =
+ Collections.singletonMap("Authorization", Collections.singletonList("v2"));
+ String initialAuthType = "auth-1";
+ String updatedAuthType = "auth-2";
+ String initialUniverseDomain = "googleapis.com";
+ String updatedUniverseDomain = "abc.goog";
+ CredentialTypeForMetrics initialMetricsCredentialType =
+ CredentialTypeForMetrics.SERVICE_ACCOUNT_CREDENTIALS_JWT;
+ CredentialTypeForMetrics updatedMetricsCredentialType =
+ CredentialTypeForMetrics.SERVICE_ACCOUNT_CREDENTIALS_AT;
+
+ @Test
+ public void testCreateMutableCredentials() throws IOException {
+ setupInitialCredentials();
+
+ MutableCredentials credentials = new MutableCredentials(initialCredentials, scopes);
+ URI testUri = URI.create("https://spanner.googleapis.com");
+ Executor executor = mock(Executor.class);
+ RequestMetadataCallback callback = mock(RequestMetadataCallback.class);
+
+ validateInitialDelegatedCredentialsAreSet(credentials, testUri);
+
+ credentials.getRequestMetadata(testUri, executor, callback);
+
+ credentials.refresh();
+
+ verify(initialScopedCredentials, times(1)).getRequestMetadata(testUri, executor, callback);
+ verify(initialScopedCredentials, times(1)).refresh();
+ }
+
+ @Test
+ public void testUpdateMutableCredentials() throws IOException {
+ setupInitialCredentials();
+ setupUpdatedCredentials();
+
+ MutableCredentials credentials = new MutableCredentials(initialCredentials, scopes);
+ URI testUri = URI.create("https://example.com");
+ Executor executor = mock(Executor.class);
+ RequestMetadataCallback callback = mock(RequestMetadataCallback.class);
+
+ validateInitialDelegatedCredentialsAreSet(credentials, testUri);
+
+ credentials.updateCredentials(updatedCredentials);
+
+ assertEquals(updatedAuthType, credentials.getAuthenticationType());
+ assertFalse(credentials.hasRequestMetadata());
+ assertFalse(credentials.hasRequestMetadataOnly());
+ assertSame(updatedMetadata, credentials.getRequestMetadata(testUri));
+ assertEquals(updatedUniverseDomain, credentials.getUniverseDomain());
+ assertEquals(updatedMetricsCredentialType, credentials.getMetricsCredentialType());
+
+ credentials.getRequestMetadata(testUri, executor, callback);
+
+ credentials.refresh();
+
+ verify(updatedScopedCredentials, times(1)).getRequestMetadata(testUri, executor, callback);
+ verify(updatedScopedCredentials, times(1)).refresh();
+ }
+
+ @Test
+ public void testCreateMutableCredentialsNullScopes() throws IOException {
+ setupInitialCredentials();
+
+ MutableCredentials credentials = new MutableCredentials(initialCredentials, null);
+ URI testUri = URI.create("https://spanner.googleapis.com");
+
+ validateInitialDelegatedCredentialsAreSet(credentials, testUri);
+ }
+
+ private void validateInitialDelegatedCredentialsAreSet(
+ MutableCredentials credentials, URI testUri) throws IOException {
+ assertEquals(initialAuthType, credentials.getAuthenticationType());
+ assertTrue(credentials.hasRequestMetadata());
+ assertTrue(credentials.hasRequestMetadataOnly());
+ assertEquals(initialMetadata, credentials.getRequestMetadata(testUri));
+ assertEquals(initialUniverseDomain, credentials.getUniverseDomain());
+ assertEquals(initialMetricsCredentialType, credentials.getMetricsCredentialType());
+ }
+
+ private void setupInitialCredentials() throws IOException {
+ when(initialCredentials.createScoped(scopes)).thenReturn(initialScopedCredentials);
+ when(initialCredentials.createScoped(Collections.emptyList()))
+ .thenReturn(initialScopedCredentials);
+ when(initialScopedCredentials.getAuthenticationType()).thenReturn(initialAuthType);
+ when(initialScopedCredentials.getRequestMetadata(any(URI.class))).thenReturn(initialMetadata);
+ when(initialScopedCredentials.getUniverseDomain()).thenReturn(initialUniverseDomain);
+ when(initialScopedCredentials.getMetricsCredentialType())
+ .thenReturn(initialMetricsCredentialType);
+ when(initialScopedCredentials.hasRequestMetadata()).thenReturn(true);
+ when(initialScopedCredentials.hasRequestMetadataOnly()).thenReturn(true);
+ }
+
+ private void setupUpdatedCredentials() throws IOException {
+ when(updatedCredentials.createScoped(scopes)).thenReturn(updatedScopedCredentials);
+ when(updatedScopedCredentials.getAuthenticationType()).thenReturn(updatedAuthType);
+ when(updatedScopedCredentials.getRequestMetadata(any(URI.class))).thenReturn(updatedMetadata);
+ when(updatedScopedCredentials.getUniverseDomain()).thenReturn(updatedUniverseDomain);
+ when(updatedScopedCredentials.getMetricsCredentialType())
+ .thenReturn(updatedMetricsCredentialType);
+ when(updatedScopedCredentials.hasRequestMetadata()).thenReturn(false);
+ when(updatedScopedCredentials.hasRequestMetadataOnly()).thenReturn(false);
+ }
+}
diff --git a/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/it/ITMutableCredentialsTest.java b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/it/ITMutableCredentialsTest.java
new file mode 100644
index 00000000000..8ab0e283ea4
--- /dev/null
+++ b/google-cloud-spanner/src/test/java/com/google/cloud/spanner/connection/it/ITMutableCredentialsTest.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.spanner.connection.it;
+
+import static org.junit.Assert.*;
+
+import com.google.auth.oauth2.GoogleCredentials;
+import com.google.auth.oauth2.ServiceAccountCredentials;
+import com.google.cloud.spanner.*;
+import com.google.cloud.spanner.admin.database.v1.DatabaseAdminClient;
+import com.google.cloud.spanner.connection.MutableCredentials;
+import com.google.spanner.admin.database.v1.Database;
+import com.google.spanner.admin.database.v1.InstanceName;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collections;
+import java.util.List;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@Category(SerialIntegrationTest.class)
+@RunWith(JUnit4.class)
+public class ITMutableCredentialsTest {
+ private static final String MISSING_PERM_KEY =
+ "/com/google/cloud/spanner/connection/test-key-missing-permissions.json";
+
+ private static final String INVALID_KEY = "/com/google/cloud/spanner/connection/test-key.json";
+
+ @Test
+ public void testMutableCredentialsUpdateAuthorizationForRunningClient() throws IOException {
+
+ GoogleCredentials missingPermissionCredentials;
+ try (InputStream stream =
+ ITMutableCredentialsTest.class.getResourceAsStream(MISSING_PERM_KEY)) {
+ missingPermissionCredentials = GoogleCredentials.fromStream(stream);
+ }
+ ServiceAccountCredentials invalidCredentials;
+ try (InputStream stream = ITMutableCredentialsTest.class.getResourceAsStream(INVALID_KEY)) {
+ invalidCredentials = ServiceAccountCredentials.fromStream(stream);
+ }
+ List scopes =
+ Collections.singletonList("https://www.googleapis.com/auth/cloud-platform");
+ // create MutableCredentials first with missing permissions
+ MutableCredentials mutableCredentials =
+ new MutableCredentials((ServiceAccountCredentials) missingPermissionCredentials, scopes);
+
+ SpannerOptions options = SpannerOptions.newBuilder().setCredentials(mutableCredentials).build();
+ try (Spanner spanner = options.getService();
+ DatabaseAdminClient databaseAdminClient = spanner.createDatabaseAdminClient()) {
+ String project = "gcloud-devel";
+ String instance = "java-client-integration-tests";
+ try {
+ listDatabases(databaseAdminClient, project, instance);
+ fail("Expected PERMISSION_DENIED");
+ } catch (Exception e) {
+ // specifically validate the permission denied error message
+ System.out.println("exception " + e.getMessage());
+ assertTrue(e.getMessage().contains("PERMISSION_DENIED"));
+ assertFalse(e.getMessage().contains("UNAUTHENTICATED"));
+ }
+
+ // update mutableCredentials now to use an invalid credential
+ mutableCredentials.updateCredentials(invalidCredentials);
+ try {
+ listDatabases(databaseAdminClient, project, instance);
+ fail("Expected UNAUTHENTICATED after switching to invalid credentials");
+ } catch (Exception e) {
+ assertTrue(e.getMessage().contains("UNAUTHENTICATED"));
+ assertFalse(e.getMessage().contains("PERMISSION_DENIED"));
+ }
+ }
+ }
+
+ private static void listDatabases(
+ DatabaseAdminClient databaseAdminClient, String projectId, String instanceId) {
+ DatabaseAdminClient.ListDatabasesPagedResponse response =
+ databaseAdminClient.listDatabases(InstanceName.of(projectId, instanceId));
+
+ for (DatabaseAdminClient.ListDatabasesPage page : response.iteratePages()) {
+ for (Database database : page.iterateAll()) {
+ // no-op
+ }
+ }
+ }
+}
diff --git a/google-cloud-spanner/src/test/resources/com/google/cloud/spanner/connection/test-key-missing-permissions.json b/google-cloud-spanner/src/test/resources/com/google/cloud/spanner/connection/test-key-missing-permissions.json
new file mode 100644
index 00000000000..348e769f6fb
--- /dev/null
+++ b/google-cloud-spanner/src/test/resources/com/google/cloud/spanner/connection/test-key-missing-permissions.json
@@ -0,0 +1,13 @@
+{
+ "type": "service_account",
+ "project_id": "ldetmer-sanbox",
+ "private_key_id": "1f9be0fd206d51e759ab8577c32301333dda9103",
+ "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDgwaPePW0yK6Wg\nm0n0PgrmJbwbf0HgFQ9E5I5e+rZ+hl79pCxCkXcTVH6HsIgh+Lp1tyCTExjsE0cN\nDVRy9YD/BR4wKsxOhz5UKwmRhOg127EGvqMAwomEGOIyOuS+AMmdy710B/iBjc01\nLYTzNe1ldZ5SK69AJer9g1+mPn2H7AbZGQ8/ThKvzEAErmgd7PMATRwg6sAc5n6O\nIvcfQp4XApcYuCNH4KVmnXD3K9f0ySV5WKA14eHRIIxwjHagFvHaiMvU4/HXqCao\nClVoNmmoraUBfaDGj4JGMvj1JjTyz4VB0KGXxYTJJrViYhkKZs4zSo2k2qatPczn\nNHH6PkjLAgMBAAECggEAD+QWYPfjiOuQb1WWGgBqUYa+JmI4rHjwtm9D0wVTnTMv\nniwFu8MrMcuvPTVxupfVHyOOgwzo8zATEveBTrYlo7efQHVA3WsvMKZGVpVDZyNx\nqxy+y6agMHMjSGfk6mYwhclKS4eQviA3hRit0MBcWOhtruOgesmeNBnIy9PumOB+\nWVRp7uz8D/xaq55lFAShaH2DAEt316qetZW+LtNq473pPq9GlnFYsj+OPyWT84X2\nmeoWLxVwOpkx1RmmlAEOQCCK24H7GbZwsADiyHQ37cwQ/MfTs9qsib56TByVHSSb\nYD+lTMPT+5N/YY51AVq4op4kGPuVTHLE4D3uZduSEQKBgQDwmuSCjfvHr1LZxo/r\nVPR6+KiQC+o8qBzK0413D3rn+0pAWCcrkb9//PGwXxtRzjEodwbX9B+g8UGzkbfD\nH5scogl3Nd+3zUTsSp5D5IZUJsVv1lN2klv4y48zidey2qELOC8n6hPnrbOHfZqZ\nR22/o2/TeWxnWbmMUN2kx9r++QKBgQDvIyWePiJgFvlRLSLQQpZOiDV4z61Ixows\nDBrTeQyfAYG8gROA0LUS3zS4njA2Yr6xFj6M8rhUD9bLQ1+mGIJWi7ZI2cD+TDtH\ntdxTS7jBU8s26H2nisD8kvKpq61RxI1A2H7u+9gPzDweM0boBlERNqjyPUZhNbdD\n0+7AwmJC4wKBgB91kTVEzUvxr5qL7NtvUzwU8S1McYcW0BTxDkkn/AEDCVVacVyw\nBOL+NrfB57eNhz3sOjfYUp5fjSCmh+l6Y3Sd9zDgGW1V6JIgu4rTAYFVRHF4C5ew\nUVg5fXLWrh5TmcT2xquoXovnWVb45FLwVPg+rWtwL+1ffPRMyn42J3s5AoGAf6CR\ndigRLpl0THe7aczv7U/SwfyMrheRPfzj4FNtgftK43E8GHbK/Rx1RcbfUldXEKof\njhgIeozNhUQa60mPXmNIUQ8uakoDJV2RDj+OhleTUGW6kk2CfAptSlKeuNIe1Sn2\nbNOqV5wXxcJ2KGUepQI4HrjHNCB4A9I7TVMxICMCgYAPXO4/xTZJ/0Nmjd085yRo\nhDFBUTwWPHUTbUA1bBMd908F4RD0WnnLPzSC1hSxhhCGm119JGgusZfwL2Ey1nYh\n9B3b/EwArE/vC+Fl/tyILQR2G/D/f70dISuDut139cKEM8qBLJ2JRuYbKlEBPhGW\nw0x8SmTkNYepAG0SSaBu7g==\n-----END PRIVATE KEY-----\n",
+ "client_email": "test-mutable-credentials@ldetmer-sanbox.iam.gserviceaccount.com",
+ "client_id": "110488447517330409458",
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+ "token_uri": "https://oauth2.googleapis.com/token",
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
+ "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/test-mutable-credentials%40ldetmer-sanbox.iam.gserviceaccount.com",
+ "universe_domain": "googleapis.com"
+}