KNOX-2375 - Token state eviction should access the keystore file less frequently (#337)

diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java
index d47583b..18c3a4b 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/GatewayMessages.java
@@ -277,6 +277,9 @@
   @Message( level = MessageLevel.ERROR, text = "Failed to add credential for cluster {0}: {1}" )
   void failedToAddCredentialForCluster( String clusterName, @StackTrace( level = MessageLevel.DEBUG ) Exception e );
 
+  @Message( level = MessageLevel.ERROR, text = "Failed to add credentials for cluster {0}: {1}" )
+  void failedToAddCredentialsForCluster( String clusterName, @StackTrace( level = MessageLevel.DEBUG ) Exception e );
+
   @Message( level = MessageLevel.ERROR, text = "Failed to get key for Gateway {0}: {1}" )
   void failedToGetKeyForGateway( String alias, @StackTrace( level=MessageLevel.DEBUG ) Exception e );
 
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
index 6f690cc..d03deec 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
@@ -248,9 +248,11 @@
 
   private static final String KNOX_TOKEN_EVICTION_INTERVAL = GATEWAY_CONFIG_FILE_PREFIX + ".knox.token.eviction.interval";
   private static final String KNOX_TOKEN_EVICTION_GRACE_PERIOD = GATEWAY_CONFIG_FILE_PREFIX + ".knox.token.eviction.grace.period";
+  private static final String KNOX_TOKEN_ALIAS_PERSISTENCE_INTERVAL = GATEWAY_CONFIG_FILE_PREFIX + ".knox.token.state.alias.persistence.interval";
   private static final String KNOX_TOKEN_PERMISSIVE_VALIDATION_ENABLED = GATEWAY_CONFIG_FILE_PREFIX + ".knox.token.permissive.validation";
   private static final long KNOX_TOKEN_EVICTION_INTERVAL_DEFAULT = TimeUnit.MINUTES.toSeconds(5);
   private static final long KNOX_TOKEN_EVICTION_GRACE_PERIOD_DEFAULT = TimeUnit.HOURS.toSeconds(24);
+  private static final long KNOX_TOKEN_ALIAS_PERSISTENCE_INTERVAL_DEFAULT = TimeUnit.SECONDS.toSeconds(15);
   private static final boolean KNOX_TOKEN_PERMISSIVE_VALIDATION_ENABLED_DEFAULT = false;
 
   private static final String KNOX_HOMEPAGE_PINNED_TOPOLOGIES =  "knox.homepage.pinned.topologies";
@@ -1139,6 +1141,11 @@
   }
 
   @Override
+  public long getKnoxTokenStateAliasPersistenceInterval() {
+    return getLong(KNOX_TOKEN_ALIAS_PERSISTENCE_INTERVAL, KNOX_TOKEN_ALIAS_PERSISTENCE_INTERVAL_DEFAULT);
+  }
+
+  @Override
   public Set<String> getHiddenTopologiesOnHomepage() {
     final Set<String> hiddenTopologies = new HashSet<>(getTrimmedStringCollection(KNOX_HOMEPAGE_HIDDEN_TOPOLOGIES));
     return hiddenTopologies == null || hiddenTopologies.isEmpty() ? KNOX_HOMEPAGE_HIDDEN_TOPOLOGIES_DEFAULT : hiddenTopologies;
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultAliasService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultAliasService.java
index a3af2c5..c0a20ec 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultAliasService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultAliasService.java
@@ -24,6 +24,7 @@
 import java.util.Enumeration;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 import org.apache.knox.gateway.GatewayMessages;
 import org.apache.knox.gateway.config.GatewayConfig;
@@ -160,6 +161,15 @@
   }
 
   @Override
+  public void addAliasesForCluster(String clusterName, Map<String, String> aliases) throws AliasServiceException {
+    try {
+      keystoreService.addCredentialsForCluster(clusterName, aliases);
+    } catch (KeystoreServiceException e) {
+      LOG.failedToAddCredentialsForCluster(clusterName, e);
+    }
+  }
+
+  @Override
   public void removeAliasForCluster(String clusterName, String alias)
       throws AliasServiceException {
     try {
@@ -170,6 +180,15 @@
   }
 
   @Override
+  public void removeAliasesForCluster(String clusterName, Set<String> aliases) throws AliasServiceException {
+    try {
+      keystoreService.removeCredentialsForCluster(clusterName, aliases);
+    } catch (KeystoreServiceException e) {
+      throw new AliasServiceException(e);
+    }
+  }
+
+  @Override
   public char[] getPasswordFromAliasForGateway(String alias)
       throws AliasServiceException {
     return getPasswordFromAliasForCluster(NO_CLUSTER_NAME, alias);
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreService.java
index 021fb82..5a58029 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreService.java
@@ -59,8 +59,11 @@
 import java.security.cert.CertificateException;
 import java.security.cert.X509Certificate;
 import java.text.MessageFormat;
+import java.util.Collections;
+import java.util.HashSet;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.TimeUnit;
 
 import javax.crypto.spec.SecretKeySpec;
@@ -75,9 +78,10 @@
   private static GatewayMessages LOG = MessagesFactory.get(GatewayMessages.class);
   private static GatewayResources RES = ResourcesFactory.get(GatewayResources.class);
 
-  //let's configure the cache with hard-coded attributes now; we can introduce new gateway configuration later on if needed
-  // visible for testing
+  // Let's configure the cache with hard-coded attributes now; we can introduce new gateway configuration later on if
+  // needed visible for testing
   final Cache<CacheKey, String> cache = Caffeine.newBuilder().expireAfterAccess(10, TimeUnit.MINUTES).maximumSize(1000).build();
+
   private GatewayConfig config;
 
   private MasterService masterService;
@@ -287,18 +291,28 @@
   @Override
   public void addCredentialForCluster(String clusterName, String alias, String value)
       throws KeystoreServiceException {
+    addCredentialsForCluster(clusterName, Collections.singletonMap(alias, value));
+  }
+
+  @Override
+  public void addCredentialsForCluster(String clusterName, Map<String, String> credentials)
+      throws KeystoreServiceException {
     // Needed to prevent read then write synchronization issue where alias is not added
     synchronized (this) {
-      removeFromCache(clusterName, alias);
+      removeFromCache(clusterName, credentials.keySet());
       KeyStore ks = getCredentialStoreForCluster(clusterName);
       if (ks != null) {
         try {
-          final Key key = new SecretKeySpec(value.getBytes(StandardCharsets.UTF_8), "AES");
-          ks.setKeyEntry(alias, key, masterService.getMasterSecret(), null);
+          // Add all the credential keys to the keystore
+          for (Map.Entry<String, String> credential : credentials.entrySet()) {
+            final Key key = new SecretKeySpec(credential.getValue().getBytes(StandardCharsets.UTF_8), "AES");
+            ks.setKeyEntry(credential.getKey(), key, masterService.getMasterSecret(), null);
+          }
 
+          // Write all the changes once
           final Path keyStoreFilePath = keyStoreDirPath.resolve(clusterName + CREDENTIALS_SUFFIX);
           writeKeyStoreToFile(ks, keyStoreFilePath, masterService.getMasterSecret());
-          addToCache(clusterName, alias, value);
+          addToCache(clusterName, credentials);
         } catch (KeyStoreException | IOException | CertificateException | NoSuchAlgorithmException e) {
           LOG.failedToAddCredentialForCluster(clusterName, e);
         }
@@ -338,18 +352,27 @@
 
   @Override
   public void removeCredentialForCluster(String clusterName, String alias) throws KeystoreServiceException {
+    removeCredentialsForCluster(clusterName, Collections.singleton(alias));
+  }
+
+  @Override
+  public void removeCredentialsForCluster(String clusterName, Set<String> aliases) throws KeystoreServiceException {
     // Needed to prevent read then write synchronization issue where alias is not removed
     synchronized (this) {
       KeyStore ks = getCredentialStoreForCluster(clusterName);
       if (ks != null) {
         try {
-          if (ks.containsAlias(alias)) {
-            ks.deleteEntry(alias);
+          // Delete all the entries
+          for (String alias : aliases) {
+            if (ks.containsAlias(alias)) {
+              ks.deleteEntry(alias);
+            }
           }
+          removeFromCache(clusterName, aliases);
 
+          // Update the keystore file once to reflect all the alias deletions
           final Path keyStoreFilePath = keyStoreDirPath.resolve(clusterName + CREDENTIALS_SUFFIX);
           writeKeyStoreToFile(ks, keyStoreFilePath, masterService.getMasterSecret());
-          removeFromCache(clusterName, alias);
         } catch (KeyStoreException | IOException | CertificateException | NoSuchAlgorithmException e) {
           LOG.failedToRemoveCredentialForCluster(clusterName, e);
         }
@@ -375,10 +398,30 @@
   /**
    * Called only from within critical sections of other methods above.
    */
+  private void addToCache(String clusterName, Map<String, String> credentials) {
+    for (String alias : credentials.keySet()) {
+      cache.put(CacheKey.of(clusterName, alias), credentials.get(alias));
+    }
+  }
+
+  /**
+   * Called only from within critical sections of other methods above.
+   */
   private void removeFromCache(String clusterName, String alias) {
     cache.invalidate(CacheKey.of(clusterName, alias));
   }
 
+  /**
+   * Called only from within critical sections of other methods above.
+   */
+  private void removeFromCache(String clusterName, Set<String> aliases) {
+    Set<CacheKey> keys = new HashSet<>();
+    for (String alias : aliases) {
+      keys.add(CacheKey.of(clusterName, alias));
+    }
+    cache.invalidateAll(keys);
+  }
+
   @Override
   public String getKeystorePath() {
     return config.getIdentityKeystorePath();
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/RemoteAliasService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/RemoteAliasService.java
index 5182fd1..5f5b0ed 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/RemoteAliasService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/RemoteAliasService.java
@@ -29,10 +29,14 @@
 
 import java.security.cert.Certificate;
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.ServiceLoader;
+import java.util.Set;
 
 /**
  * An {@link AliasService} implementation based on remote service registry.
@@ -93,32 +97,46 @@
 
   @Override
   public void addAliasForCluster(final String clusterName,
-      final String givenAlias, final String value)
-      throws AliasServiceException {
+      final String givenAlias, final String value) throws AliasServiceException {
+    addAliasesForCluster(clusterName, Collections.singletonMap(givenAlias, value));
+  }
 
-    /* convert all alias names to lower case since JDK expects the same behaviour */
-    final String alias = givenAlias.toLowerCase(Locale.ROOT);
+  @Override
+  public void addAliasesForCluster(String clusterName, Map<String, String> credentials) throws AliasServiceException {
+    // Convert all alias names to lower case since JDK expects the same behaviour
+    Map<String, String> loweredCredentials = new HashMap<>();
+    for (Map.Entry<String, String> credential : credentials.entrySet()) {
+      loweredCredentials.put(credential.getKey().toLowerCase(Locale.ROOT), credential.getValue());
+    }
 
-    /* first add the alias to the local keystore */
-    localAliasService.addAliasForCluster(clusterName, alias, value);
+    // First add the alias to the local keystore
+    localAliasService.addAliasesForCluster(clusterName, loweredCredentials);
 
     if (remoteAliasServiceImpl != null) {
-      remoteAliasServiceImpl.addAliasForCluster(clusterName, alias, value);
+      remoteAliasServiceImpl.addAliasesForCluster(clusterName, loweredCredentials);
     }
   }
 
   @Override
   public void removeAliasForCluster(final String clusterName, final String givenAlias)
       throws AliasServiceException {
-    /* convert all alias names to lower case since JDK expects the same behaviour */
-    final String alias = givenAlias.toLowerCase(Locale.ROOT);
+    removeAliasesForCluster(clusterName, Collections.singleton(givenAlias));
+  }
 
-    /* first remove it from the local keystore */
-    localAliasService.removeAliasForCluster(clusterName, alias);
+  @Override
+  public void removeAliasesForCluster(String clusterName, Set<String> aliases) throws AliasServiceException {
+    // Convert all alias names to lower case since JDK expects the same behavior
+    Set<String> loweredAliases = new HashSet<>();
+    for (String alias : aliases) {
+      loweredAliases.add(alias.toLowerCase(Locale.ROOT));
+    }
 
-    /* If we have remote registry configured, query it */
+    // First, remove them from the local keystore
+    localAliasService.removeAliasesForCluster(clusterName, loweredAliases);
+
+    // If we have remote registry configured, remove them there also
     if (remoteAliasServiceImpl != null) {
-      remoteAliasServiceImpl.removeAliasForCluster(clusterName, alias);
+      remoteAliasServiceImpl.removeAliasesForCluster(clusterName, loweredAliases);
     }
   }
 
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/ZookeeperRemoteAliasService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/ZookeeperRemoteAliasService.java
index 4abc411..10bd1bf 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/ZookeeperRemoteAliasService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/security/impl/ZookeeperRemoteAliasService.java
@@ -40,6 +40,7 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * An {@link AliasService} implementation based on zookeeper remote service registry.
@@ -222,6 +223,13 @@
     }
 
     @Override
+    public void addAliasesForCluster(String clusterName, Map<String, String> credentials) throws AliasServiceException {
+        for (Map.Entry<String, String> credential : credentials.entrySet()) {
+            addAliasForCluster(clusterName, credential.getKey(), credential.getValue());
+        }
+    }
+
+    @Override
     public void removeAliasForCluster(final String clusterName, final String alias) throws AliasServiceException {
         /* If we have remote registry configured, query it */
         if (remoteClient != null) {
@@ -238,6 +246,13 @@
     }
 
     @Override
+    public void removeAliasesForCluster(String clusterName, Set<String> aliases) throws AliasServiceException {
+        for (String alias : aliases) {
+            removeAliasForCluster(clusterName, alias);
+        }
+    }
+
+    @Override
     public char[] getPasswordFromAliasForCluster(String clusterName, String alias) throws AliasServiceException {
         return getPasswordFromAliasForCluster(clusterName, alias, false);
     }
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateService.java
index 1178e87..dd457e5 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateService.java
@@ -23,8 +23,15 @@
 import org.apache.knox.gateway.services.security.token.UnknownTokenException;
 
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 
 /**
@@ -32,8 +39,15 @@
  */
 public class AliasBasedTokenStateService extends DefaultTokenStateService {
 
+  static final String TOKEN_MAX_LIFETIME_POSTFIX = "--max";
+
   private AliasService aliasService;
-  private static final String TOKEN_MAX_LIFETIME_POSTFIX = "--max";
+
+  private long statePersistenceInterval = TimeUnit.SECONDS.toSeconds(15);
+
+  private ScheduledExecutorService statePersistenceScheduler;
+
+  private final List<TokenState> unpersistedState = new ArrayList<>();
 
   public void setAliasService(AliasService aliasService) {
     this.aliasService = aliasService;
@@ -45,6 +59,66 @@
     if (aliasService == null) {
       throw new ServiceLifecycleException("The required AliasService reference has not been set.");
     }
+    statePersistenceInterval = config.getKnoxTokenStateAliasPersistenceInterval();
+    if (statePersistenceInterval > 0) {
+      statePersistenceScheduler = Executors.newScheduledThreadPool(1);
+    }
+  }
+
+  @Override
+  public void start() throws ServiceLifecycleException {
+    super.start();
+    if (statePersistenceScheduler != null) {
+      // Run token eviction task at configured interval
+      statePersistenceScheduler.scheduleAtFixedRate(this::persistTokenState,
+                                                    statePersistenceInterval,
+                                                    statePersistenceInterval,
+                                                    TimeUnit.SECONDS);
+    }
+  }
+
+  @Override
+  public void stop() throws ServiceLifecycleException {
+    super.stop();
+    if (statePersistenceScheduler != null) {
+      statePersistenceScheduler.shutdown();
+    }
+  }
+
+  protected void persistTokenState() {
+    Set<String> tokenIds = new HashSet<>(); // Collect the tokenIds for logging
+
+    List<TokenState> processing;
+    synchronized (unpersistedState) {
+      // Move unpersisted state to temp collection
+      processing = new ArrayList<>(unpersistedState);
+      unpersistedState.clear();
+    }
+
+    // Create a set of aliases based on the unpersisted TokenState objects
+    Map<String, String> aliases = new HashMap<>();
+    for (TokenState state : processing) {
+      tokenIds.add(state.getTokenId());
+      aliases.put(state.getAlias(), state.getAliasValue());
+      log.creatingTokenStateAliases(state.getTokenId());
+    }
+
+    // Write aliases in a batch
+    if (!aliases.isEmpty()) {
+      log.creatingTokenStateAliases();
+
+      try {
+        aliasService.addAliasesForCluster(AliasService.NO_CLUSTER_NAME, aliases);
+        for (String tokenId : tokenIds) {
+          log.createdTokenStateAliases(tokenId);
+        }
+      } catch (AliasServiceException e) {
+        log.failedToCreateTokenStateAliases(e);
+        synchronized (unpersistedState) {
+          unpersistedState.addAll(processing); // Restore the unpersisted state objects so they can be attempted later
+        }
+      }
+    }
   }
 
   @Override
@@ -52,53 +126,67 @@
                              long   issueTime,
                              long   expiration,
                              long   maxLifetimeDuration) {
-    isValidIdentifier(tokenId);
-    try {
-      aliasService.addAliasForCluster(AliasService.NO_CLUSTER_NAME, tokenId, String.valueOf(expiration));
-      setMaxLifetime(tokenId, issueTime, maxLifetimeDuration);
-      log.addedToken(tokenId, getTimestampDisplay(expiration));
-    } catch (AliasServiceException e) {
-      log.failedToSaveTokenState(tokenId, e);
+    super.addToken(tokenId, issueTime, expiration, maxLifetimeDuration);
+
+    synchronized (unpersistedState) {
+      unpersistedState.add(new TokenExpiration(tokenId, expiration));
     }
   }
 
   @Override
   protected void setMaxLifetime(final String tokenId, long issueTime, long maxLifetimeDuration) {
-    try {
-      aliasService.addAliasForCluster(AliasService.NO_CLUSTER_NAME,
-                                      tokenId + TOKEN_MAX_LIFETIME_POSTFIX,
-                                      String.valueOf(issueTime + maxLifetimeDuration));
-    } catch (AliasServiceException e) {
-      log.failedToSaveTokenState(tokenId, e);
+    super.setMaxLifetime(tokenId, issueTime, maxLifetimeDuration);
+    synchronized (unpersistedState) {
+      unpersistedState.add(new TokenMaxLifetime(tokenId, issueTime, maxLifetimeDuration));
     }
   }
 
   @Override
   protected long getMaxLifetime(final String tokenId) {
-    long result = 0;
-    try {
-      char[] maxLifetimeStr =
-                      aliasService.getPasswordFromAliasForCluster(AliasService.NO_CLUSTER_NAME,
-                                                                  tokenId + TOKEN_MAX_LIFETIME_POSTFIX);
-      if (maxLifetimeStr != null) {
-        result = Long.parseLong(new String(maxLifetimeStr));
+    long result = super.getMaxLifetime(tokenId);
+
+    // If there is no result from the in-memory collection, proceed to check the alias service
+    if (result < 1L) {
+      try {
+        char[] maxLifetimeStr =
+                aliasService.getPasswordFromAliasForCluster(AliasService.NO_CLUSTER_NAME,
+                        tokenId + TOKEN_MAX_LIFETIME_POSTFIX);
+        if (maxLifetimeStr != null) {
+          result = Long.parseLong(new String(maxLifetimeStr));
+        }
+      } catch (AliasServiceException e) {
+        log.errorAccessingTokenState(tokenId, e);
       }
-    } catch (AliasServiceException e) {
-      log.errorAccessingTokenState(tokenId, e);
     }
     return result;
   }
 
   @Override
-  public long getTokenExpiration(final String tokenId) throws UnknownTokenException {
+  public long getTokenExpiration(String tokenId, boolean validate) throws UnknownTokenException {
     long expiration = 0;
 
-    validateToken(tokenId);
+    if (!validate) {
+      // If validation is not required, then check the in-memory collection first
+      try {
+        expiration = super.getTokenExpiration(tokenId, validate);
+        return expiration;
+      } catch (UnknownTokenException e) {
+        // It's not in memory
+      }
+    }
+
+    // If validating, or there is no associated record in the in-memory collection, proceed to check the alias service
+
+    if (validate) {
+      validateToken(tokenId);
+    }
 
     try {
       char[] expStr = aliasService.getPasswordFromAliasForCluster(AliasService.NO_CLUSTER_NAME, tokenId);
       if (expStr != null) {
         expiration = Long.parseLong(new String(expStr));
+        // Update the in-memory record
+        super.updateExpiration(tokenId, expiration);
       }
     } catch (Exception e) {
       log.errorAccessingTokenState(tokenId, e);
@@ -109,30 +197,51 @@
 
   @Override
   protected boolean isUnknown(final String tokenId) {
-    boolean isUnknown = false;
-    try {
-      isUnknown = (aliasService.getPasswordFromAliasForCluster(AliasService.NO_CLUSTER_NAME, tokenId) == null);
-    } catch (AliasServiceException e) {
-      log.errorAccessingTokenState(tokenId, e);
+    boolean isUnknown = super.isUnknown(tokenId);
+
+    // If it's not in the cache, then check the underlying alias
+    if (isUnknown) {
+      try {
+        isUnknown = (aliasService.getPasswordFromAliasForCluster(AliasService.NO_CLUSTER_NAME, tokenId) == null);
+      } catch (AliasServiceException e) {
+        log.errorAccessingTokenState(tokenId, e);
+      }
     }
     return isUnknown;
   }
 
   @Override
   protected void removeToken(final String tokenId) throws UnknownTokenException {
-    validateToken(tokenId);
+    removeTokens(Collections.singleton(tokenId));
+  }
 
-    try {
-      aliasService.removeAliasForCluster(AliasService.NO_CLUSTER_NAME, tokenId);
-      aliasService.removeAliasForCluster(AliasService.NO_CLUSTER_NAME, tokenId + TOKEN_MAX_LIFETIME_POSTFIX);
-      log.removedTokenState(tokenId);
-    } catch (AliasServiceException e) {
-      log.failedToRemoveTokenState(tokenId, e);
+  @Override
+  protected void removeTokens(Set<String> tokenIds) throws UnknownTokenException {
+    // Add the max lifetime aliases to the list of aliases to remove
+    Set<String> aliasesToRemove = new HashSet<>(tokenIds);
+    for (String tokenId : tokenIds) {
+      aliasesToRemove.add(tokenId + TOKEN_MAX_LIFETIME_POSTFIX);
+      log.removingTokenStateAliases(tokenId);
     }
+
+    if (!aliasesToRemove.isEmpty()) {
+      log.removingTokenStateAliases();
+      try {
+        aliasService.removeAliasesForCluster(AliasService.NO_CLUSTER_NAME, aliasesToRemove);
+        for (String tokenId : tokenIds) {
+          log.removedTokenStateAliases(tokenId);
+        }
+      } catch (AliasServiceException e) {
+        log.failedToRemoveTokenStateAliases(e);
+      }
+    }
+
+    super.removeTokens(tokenIds);
   }
 
   @Override
   protected void updateExpiration(final String tokenId, long expiration) {
+    super.updateExpiration(tokenId, expiration);
     try {
       aliasService.removeAliasForCluster(AliasService.NO_CLUSTER_NAME, tokenId);
       aliasService.addAliasForCluster(AliasService.NO_CLUSTER_NAME, tokenId, String.valueOf(expiration));
@@ -143,15 +252,79 @@
 
   @Override
   protected List<String> getTokens() {
-    List<String> allAliases = new ArrayList<>();
+    List<String> tokenIds = null;
+
     try {
-      allAliases = aliasService.getAliasesForCluster(AliasService.NO_CLUSTER_NAME);
-      /* only get the aliases that represent tokens and extract the current list of tokens */
-      allAliases = allAliases.stream().filter(a -> a.contains(TOKEN_MAX_LIFETIME_POSTFIX)).map(a -> a.substring(0, a.indexOf(TOKEN_MAX_LIFETIME_POSTFIX)))
-          .collect(Collectors.toList());
+      List<String> allAliases = aliasService.getAliasesForCluster(AliasService.NO_CLUSTER_NAME);
+
+      // Filter for the token state aliases, and extract the token ID
+      tokenIds = allAliases.stream()
+                           .filter(a -> a.contains(TOKEN_MAX_LIFETIME_POSTFIX))
+                           .map(a -> a.substring(0, a.indexOf(TOKEN_MAX_LIFETIME_POSTFIX)))
+                           .collect(Collectors.toList());
     } catch (AliasServiceException e) {
-      log.errorEvictingTokens(e);
+      log.errorAccessingTokenState(e);
     }
-    return allAliases;
+
+    return (tokenIds != null ? tokenIds : Collections.emptyList());
   }
+
+  private interface TokenState {
+    String getTokenId();
+    String getAlias();
+    String getAliasValue();
+  }
+
+  private static final class TokenMaxLifetime implements TokenState {
+    private String tokenId;
+    private long   issueTime;
+    private long   maxLifetime;
+
+    TokenMaxLifetime(String tokenId, long issueTime, long maxLifetime) {
+      this.tokenId     = tokenId;
+      this.issueTime   = issueTime;
+      this.maxLifetime = maxLifetime;
+    }
+
+    @Override
+    public String getTokenId() {
+      return tokenId;
+    }
+
+    @Override
+    public String getAlias() {
+      return tokenId + TOKEN_MAX_LIFETIME_POSTFIX;
+    }
+
+    @Override
+    public String getAliasValue() {
+      return String.valueOf(issueTime + maxLifetime);
+    }
+  }
+
+  private static final class TokenExpiration implements TokenState {
+    private String tokenId;
+    private long   expiration;
+
+    TokenExpiration(String tokenId, long expiration) {
+      this.tokenId    = tokenId;
+      this.expiration = expiration;
+    }
+
+    @Override
+    public String getTokenId() {
+      return tokenId;
+    }
+
+    @Override
+    public String getAlias() {
+      return tokenId;
+    }
+
+    @Override
+    public String getAliasValue() {
+      return String.valueOf(expiration);
+    }
+  }
+
 }
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityService.java
index 50ffb62..465e370 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityService.java
@@ -77,6 +77,9 @@
   private KeystoreService ks;
   private GatewayConfig config;
 
+  private char[] cachedSigningKeyPassphrase;
+  private RSAPrivateKey signingKey;
+
   static {
       // Only standard RSA signature algorithms are accepted
       // https://tools.ietf.org/html/rfc7518
@@ -135,6 +138,20 @@
     return issueToken(p, audiences, algorithm, expires, null, null, null);
   }
 
+  private RSAPrivateKey getSigningKey(final String signingKeystoreName,
+                                      final String signingKeystoreAlias,
+                                      final char[] signingKeystorePassphrase)
+          throws KeystoreServiceException, TokenServiceException {
+
+    if (signingKeystorePassphrase != null) {
+      return (RSAPrivateKey) ks.getSigningKey(signingKeystoreName,
+              getSigningKeyAlias(signingKeystoreAlias),
+              getSigningKeyPassphrase(signingKeystorePassphrase));
+    }
+
+    return signingKey;
+  }
+
   @Override
   public JWT issueToken(Principal p, List<String> audiences, String algorithm, long expires,
                         String signingKeystoreName, String signingKeystoreAlias, char[] signingKeystorePassphrase)
@@ -153,15 +170,8 @@
     JWT token;
     if (SUPPORTED_SIG_ALGS.contains(algorithm)) {
       token = new JWTToken(algorithm, claimArray, audiences);
-      char[] passphrase;
       try {
-        passphrase = getSigningKeyPassphrase(signingKeystorePassphrase);
-      } catch (AliasServiceException e) {
-        throw new TokenServiceException(e);
-      }
-      try {
-        RSAPrivateKey key = (RSAPrivateKey) ks.getSigningKey(signingKeystoreName,
-            getSigningKeyAlias(signingKeystoreAlias), passphrase);
+        RSAPrivateKey key = getSigningKey(signingKeystoreName, signingKeystoreAlias, signingKeystorePassphrase);
         // allowWeakKey to not break existing 1024 bit certificates
         JWSSigner signer = new RSASSASigner(key, true);
         token.sign(signer);
@@ -176,12 +186,8 @@
     return token;
   }
 
-  private char[] getSigningKeyPassphrase(char[] signingKeyPassphrase) throws AliasServiceException {
-    if(signingKeyPassphrase != null) {
-      return signingKeyPassphrase;
-    }
-
-    return as.getSigningKeyPassphrase();
+  private char[] getSigningKeyPassphrase(char[] signingKeyPassphrase) {
+    return (signingKeyPassphrase != null) ? signingKeyPassphrase : cachedSigningKeyPassphrase;
   }
 
   private String getSigningKeyAlias() {
@@ -275,10 +281,9 @@
     }
 
     // Ensure that the password for the signing key is available
-    char[] passphrase;
     try {
-      passphrase = as.getSigningKeyPassphrase();
-      if (passphrase == null) {
+      cachedSigningKeyPassphrase = as.getSigningKeyPassphrase();
+      if (cachedSigningKeyPassphrase == null) {
         throw new ServiceLifecycleException(RESOURCES.signingKeyPassphraseNotAvailable(config.getSigningKeyPassphraseAlias()));
       }
     } catch (AliasServiceException e) {
@@ -306,13 +311,14 @@
 
     // Ensure that the private signing keys is available
     try {
-      Key key = keystore.getKey(signingKeyAlias, passphrase);
+      Key key = keystore.getKey(signingKeyAlias, cachedSigningKeyPassphrase);
       if (key == null) {
         throw new ServiceLifecycleException(RESOURCES.privateSigningKeyNotFound(signingKeyAlias));
       }
-      else if (! (key instanceof  RSAPrivateKey)) {
+      else if (! (key instanceof RSAPrivateKey)) {
         throw new ServiceLifecycleException(RESOURCES.privateSigningKeyWrongType(signingKeyAlias));
       }
+      signingKey = (RSAPrivateKey) key;
     } catch (KeyStoreException | NoSuchAlgorithmException | UnrecoverableKeyException e) {
       throw new ServiceLifecycleException(RESOURCES.privateSigningKeyNotFound(signingKeyAlias), e);
     }
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java
index ad1f892..2361269 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java
@@ -26,13 +26,16 @@
 import org.apache.knox.gateway.services.security.token.impl.JWTToken;
 
 import java.time.Instant;
-import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
 
 /**
  * In-Memory authentication token state management implementation.
@@ -146,11 +149,21 @@
 
   @Override
   public long getTokenExpiration(final String tokenId) throws UnknownTokenException {
+    return getTokenExpiration(tokenId, true);
+  }
+
+  @Override
+  public long getTokenExpiration(String tokenId, boolean validate) throws UnknownTokenException {
     long expiration;
 
-    validateToken(tokenId);
+    if (validate) {
+      validateToken(tokenId);
+    }
 
     synchronized (tokenExpirations) {
+      if (!tokenExpirations.containsKey(tokenId)) {
+        throw new UnknownTokenException(tokenId);
+      }
       expiration = tokenExpirations.get(tokenId);
     }
 
@@ -222,7 +235,7 @@
   }
 
   /**
-   * @param token token to check
+   * @param token
    * @return false, if the service has previously stored the specified token; Otherwise, true.
    */
   protected boolean isUnknown(final String token) {
@@ -237,23 +250,40 @@
 
   protected void updateExpiration(final String tokenId, long expiration) {
     synchronized (tokenExpirations) {
-      tokenExpirations.replace(tokenId, expiration);
+      tokenExpirations.put(tokenId, expiration);
     }
   }
 
   protected void removeToken(final String tokenId) throws UnknownTokenException {
     validateToken(tokenId);
+    removeTokens(Collections.singleton(tokenId));
+  }
+
+  /**
+   * Bulk removal of the specified tokens.
+   *
+   * @param tokenIds The unique identifiers of the tokens whose state should be removed.
+   *
+   * @throws UnknownTokenException
+   */
+  protected void removeTokens(final Set<String> tokenIds) throws UnknownTokenException {
+    removeTokenState(tokenIds);
+  }
+
+  private void removeTokenState(final Set<String> tokenIds) {
     synchronized (tokenExpirations) {
-      tokenExpirations.remove(tokenId);
+      tokenExpirations.keySet().removeAll(tokenIds);
     }
     synchronized (maxTokenLifetimes) {
-      maxTokenLifetimes.remove(tokenId);
+      maxTokenLifetimes.keySet().removeAll(tokenIds);
     }
-    log.removedTokenState(tokenId);
+    for (String tokenId : tokenIds) {
+      log.removedTokenState(tokenId);
+    }
   }
 
   protected boolean hasRemainingRenewals(final String tokenId, long renewInterval) {
-    // Is the current time + 30-second buffer + the renewal interval is less than the max lifetime for the token?
+    // If the current time + buffer + the renewal interval is less than the max lifetime for the token?
     return ((System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(30) + renewInterval) < getMaxLifetime(tokenId));
   }
 
@@ -297,16 +327,26 @@
    * Method that deletes expired tokens based on the token timestamp.
    */
   protected void evictExpiredTokens() {
+    Set<String> tokensToEvict = new HashSet<>();
+
     for (final String tokenId : getTokens()) {
       try {
         if (needsEviction(tokenId)) {
           log.evictToken(tokenId);
-          removeToken(tokenId);
+          tokensToEvict.add(tokenId); // Add the token to the set of tokens to evict
         }
       } catch (final Exception e) {
         log.failedExpiredTokenEviction(tokenId, e);
       }
     }
+
+    if (!tokensToEvict.isEmpty()) {
+      try {
+        removeTokens(tokensToEvict);
+      } catch (UnknownTokenException e) {
+        log.failedExpiredTokenEviction(e);
+      }
+    }
   }
 
   /**
@@ -319,16 +359,19 @@
    */
   protected boolean needsEviction(final String tokenId) throws UnknownTokenException {
     // If the expiration time(+ grace period) has already passed, it should be considered expired
-    long expirationWithGrace = getTokenExpiration(tokenId) + TimeUnit.SECONDS.toMillis(tokenEvictionGracePeriod);
+    long expirationWithGrace = getTokenExpiration(tokenId, false) + TimeUnit.SECONDS.toMillis(tokenEvictionGracePeriod);
     return (expirationWithGrace <= System.currentTimeMillis());
   }
 
   /**
    * Get a list of tokens
    *
-   * @return List of tokens
+   * @return
    */
   protected List<String> getTokens() {
-    return new ArrayList<>(tokenExpirations.keySet());
+    synchronized (tokenExpirations) {
+      return tokenExpirations.keySet().stream().collect(Collectors.toList());
+    }
   }
+
 }
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateServiceMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateServiceMessages.java
index aace46a..a85f3bf 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateServiceMessages.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateServiceMessages.java
@@ -45,6 +45,9 @@
   @Message(level = MessageLevel.ERROR, text = "Failed to save state for token {0} : {1}")
   void failedToSaveTokenState(String tokenId, @StackTrace(level = MessageLevel.DEBUG) Exception e);
 
+  @Message(level = MessageLevel.ERROR, text = "Error accessing token state : {0}")
+  void errorAccessingTokenState(@StackTrace(level = MessageLevel.DEBUG) Exception e);
+
   @Message(level = MessageLevel.ERROR, text = "Error accessing state for token {0} : {1}")
   void errorAccessingTokenState(String tokenId, @StackTrace(level = MessageLevel.DEBUG) Exception e);
 
@@ -52,19 +55,46 @@
            text = "Referencing the expiration in the token ({0}) because no state could not be found: {1}")
   void permissiveTokenHandling(String tokenId, String errorMessage);
 
-  @Message(level = MessageLevel.ERROR, text = "Failed to update expiration for token {1} : {1}")
+  @Message(level = MessageLevel.ERROR, text = "Failed to update expiration for token {0} : {1}")
   void failedToUpdateTokenExpiration(String tokenId, @StackTrace(level = MessageLevel.DEBUG) Exception e);
 
+  @Message(level = MessageLevel.ERROR, text = "Failed to create token state aliases : {0}")
+  void failedToCreateTokenStateAliases(@StackTrace(level = MessageLevel.DEBUG) Exception e);
+
   @Message(level = MessageLevel.ERROR, text = "Failed to remove state for token {0} : {1}")
-  void failedToRemoveTokenState(String tokenId, @StackTrace(level = MessageLevel.DEBUG) Exception e);
+  void failedToRemoveTokenStateAliases(String tokenId, @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
+  @Message(level = MessageLevel.ERROR, text = "Failed to remove token state aliases : {0}")
+  void failedToRemoveTokenStateAliases(@StackTrace(level = MessageLevel.DEBUG) Exception e);
 
   @Message(level = MessageLevel.ERROR, text = "Failed to evict expired token {0} : {1}")
   void failedExpiredTokenEviction(String tokenId, @StackTrace(level = MessageLevel.DEBUG) Exception e);
 
+  @Message(level = MessageLevel.ERROR, text = "Failed to evict expired tokens : {0}")
+  void failedExpiredTokenEviction(@StackTrace(level = MessageLevel.DEBUG) Exception e);
+
   @Message(level = MessageLevel.INFO, text = "Evicting expired token {0}")
   void evictToken(String tokenId);
 
   @Message(level = MessageLevel.ERROR, text = "Error occurred evicting token {0}")
   void errorEvictingTokens(@StackTrace(level = MessageLevel.DEBUG) Exception e);
 
+  @Message(level = MessageLevel.INFO, text = "Creating token state aliases")
+  void creatingTokenStateAliases();
+
+  @Message(level = MessageLevel.DEBUG, text = "Creating token state aliases for {0}")
+  void creatingTokenStateAliases(String tokenId);
+
+  @Message(level = MessageLevel.INFO, text = "Created token state aliases for {0}")
+  void createdTokenStateAliases(String tokenId);
+
+  @Message(level = MessageLevel.INFO, text = "Removing token state aliases")
+  void removingTokenStateAliases();
+
+  @Message(level = MessageLevel.DEBUG, text = "Removing token state aliases for {0}")
+  void removingTokenStateAliases(String tokenId);
+
+  @Message(level = MessageLevel.INFO, text = "Removed token state aliases for {0}")
+  void removedTokenStateAliases(String tokenId);
+
 }
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/CryptoServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/CryptoServiceTest.java
index c0dd1a1..dfcab8b 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/CryptoServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/CryptoServiceTest.java
@@ -33,6 +33,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 import static org.junit.Assert.assertEquals;
 
@@ -63,6 +64,13 @@
       }
 
       @Override
+      public void addAliasesForCluster(String clusterName, Map<String, String> credentials) throws AliasServiceException {
+        for (Map.Entry<String, String> credential : credentials.entrySet()) {
+          addAliasForCluster(clusterName, credential.getKey(), credential.getValue());
+        }
+      }
+
+      @Override
       public char[] getPasswordFromAliasForCluster(String clusterName,
           String alias) {
         return "password".toCharArray();
@@ -97,6 +105,10 @@
       }
 
       @Override
+      public void removeAliasesForCluster(String clusterName, Set<String> aliases) throws AliasServiceException {
+      }
+
+      @Override
       public List<String> getAliasesForCluster(String clusterName) {
         return null;
       }
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreServiceTest.java
index 8c2b1b5..dadb6e6 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/DefaultKeystoreServiceTest.java
@@ -71,8 +71,10 @@
 import java.security.cert.CertificateException;
 import java.security.cert.X509Certificate;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Locale;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutorService;
@@ -608,6 +610,59 @@
     verify(masterService);
   }
 
+  /**
+   * Test the bulk key removal method, which should only load the keystore file once, and subsequently write the
+   * keystore file only once, rather than once each per key.
+   */
+  @Test
+  public void testRemoveCredentialsForCluster() throws Exception {
+    char[] masterPassword = "master_password".toCharArray();
+
+    MasterService masterService = createMock(MasterService.class);
+    expect(masterService.getMasterSecret()).andReturn(masterPassword).anyTimes();
+
+    replay(masterService);
+
+    Path baseDir = testFolder.newFolder().toPath();
+    GatewayConfigImpl config = createGatewayConfig(baseDir);
+
+    CountingDefaultKeystoreService keystoreService = new CountingDefaultKeystoreService();
+    keystoreService.setMasterService(masterService);
+    keystoreService.init(config, Collections.emptyMap());
+
+    String clusterName = "cluster";
+
+    Map<String, String> testAliases = new HashMap<>();
+    testAliases.put("alias1", "value1");
+    testAliases.put("alias2", "value2");
+    testAliases.put("alias3", "value3");
+
+    Set<String> aliases = testAliases.keySet();
+
+    keystoreService.createCredentialStoreForCluster(clusterName);
+
+    for (String alias : aliases) {
+      keystoreService.addCredentialForCluster(clusterName, alias, testAliases.get(alias));
+      assertEquals(testAliases.get(alias), String.valueOf(keystoreService.getCredentialForCluster(clusterName, alias)));
+    }
+
+    // Clear the counts recorded from adding the credentials
+    keystoreService.clearCounts();
+
+    // Invoke the bulk removal method
+    keystoreService.removeCredentialsForCluster(clusterName, aliases);
+
+    // Validate the number of loads/writes of the keystore file
+    assertEquals("Expected only a single load of the keystore file.", 1, keystoreService.loadCount);
+    assertEquals("Expected only a single write to the keystore file.", 1, keystoreService.storeCount);
+
+    for (String alias : aliases) {
+      assertNull(keystoreService.getCredentialForCluster(clusterName, alias));
+    }
+
+    verify(masterService);
+  }
+
   private void testAddSelfSignedCertForGateway(String hostname) throws Exception {
     char[] masterPassword = "master_password".toCharArray();
 
@@ -732,4 +787,28 @@
 
     keystoreService.writeKeyStoreToFile(keystore, keystoreFilePath, password);
   }
+
+
+  private static class CountingDefaultKeystoreService extends DefaultKeystoreService {
+
+    int loadCount;
+    int storeCount;
+
+    void clearCounts() {
+      loadCount  = 0;
+      storeCount = 0;
+    }
+
+    @Override
+    synchronized KeyStore loadKeyStore(Path keyStoreFilePath, String storeType, char[] password) throws KeystoreServiceException {
+      loadCount++;
+      return super.loadKeyStore(keyStoreFilePath, storeType, password);
+    }
+
+    @Override
+    synchronized void writeKeyStoreToFile(KeyStore keyStore, Path path, char[] password) throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException {
+      storeCount++;
+      super.writeKeyStoreToFile(keyStore, path, password);
+    }
+  }
 }
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/RemoteAliasServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/RemoteAliasServiceTest.java
index a285c14..8675ced 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/RemoteAliasServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/RemoteAliasServiceTest.java
@@ -28,8 +28,10 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 import static org.apache.knox.gateway.services.security.impl.RemoteAliasService.REMOTE_ALIAS_SERVICE_TYPE;
 import static org.easymock.EasyMock.capture;
@@ -334,4 +336,88 @@
     Assert.assertTrue("Expected alias 'knox.test.alias' not found ",
         aliases.contains(testAutoGeneratedpasswordAlias));
   }
+
+  /**
+   * Test the bulk alias removal method.
+   */
+  @Test
+  public void testRemoveAliasesForCluster() throws Exception {
+    Map<String, String> remoteAliasConfigs = new HashMap<>();
+    remoteAliasConfigs.put(REMOTE_ALIAS_SERVICE_TYPE, "test");
+
+    GatewayConfig gc = EasyMock.createNiceMock(GatewayConfig.class);
+    EasyMock.expect(gc.isRemoteAliasServiceEnabled()).andReturn(true).anyTimes();
+    EasyMock.expect(gc.getRemoteAliasServiceConfiguration()).andReturn(remoteAliasConfigs).anyTimes();
+    EasyMock.replay(gc);
+
+    final String expectedClusterName = "sandbox";
+    final String expectedAlias = "knox.test.alias";
+    final String expectedPassword = "dummyPassword";
+
+    final int aliasCount = 5;
+    final Set<String> expectedAliases = new HashSet<>();
+    for (int i = 0; i < aliasCount ; i++) {
+      expectedAliases.add(expectedAlias + i);
+    }
+
+    final String expectedClusterNameDev = "development";
+    final String expectedAliasDev = "knox.test.alias.dev";
+    final String expectedPasswordDev = "otherDummyPassword";
+
+    final int devAliasCount = 3;
+    final Set<String> expectedDevAliases = new HashSet<>();
+    for (int i = 0; i < 3 ; i++) {
+      expectedDevAliases.add(expectedAliasDev + i);
+    }
+
+    final DefaultMasterService ms = EasyMock.createNiceMock(DefaultMasterService.class);
+    EasyMock.expect(ms.getMasterSecret()).andReturn("knox".toCharArray()).anyTimes();
+    EasyMock.replay(ms);
+
+    // Mock Alias Service
+    final DefaultAliasService defaultAlias = EasyMock.createNiceMock(DefaultAliasService.class);
+    // Captures for validating the alias creation for a generated topology
+    final Capture<String> capturedCluster = EasyMock.newCapture();
+    final Capture<String> capturedAlias = EasyMock.newCapture();
+    final Capture<String> capturedPwd = EasyMock.newCapture();
+
+    defaultAlias
+            .addAliasForCluster(capture(capturedCluster), capture(capturedAlias),
+                    capture(capturedPwd));
+    EasyMock.expectLastCall().anyTimes();
+
+    // defaultAlias.getAliasesForCluster() never returns null
+    EasyMock.expect(defaultAlias.getAliasesForCluster(expectedClusterName))
+            .andReturn(new ArrayList<>()).anyTimes();
+    EasyMock.expect(defaultAlias.getAliasesForCluster(expectedClusterNameDev))
+            .andReturn(new ArrayList<>()).anyTimes();
+
+    EasyMock.replay(defaultAlias);
+
+    final RemoteAliasService remoteAliasService = new RemoteAliasService(defaultAlias, ms);
+    remoteAliasService.init(gc, Collections.emptyMap());
+    remoteAliasService.start();
+
+    // Put
+    for (String alias : expectedAliases) {
+      remoteAliasService.addAliasForCluster(expectedClusterName, alias, expectedPassword);
+    }
+    for (String alias : expectedDevAliases) {
+      remoteAliasService.addAliasForCluster(expectedClusterNameDev, alias, expectedPasswordDev);
+    }
+
+    Assert.assertEquals(aliasCount, remoteAliasService.getAliasesForCluster(expectedClusterName).size());
+    Assert.assertEquals(devAliasCount, remoteAliasService.getAliasesForCluster(expectedClusterNameDev).size());
+
+    // Invoke the bulk removal method for the dev cluster
+    remoteAliasService.removeAliasesForCluster(expectedClusterNameDev, expectedDevAliases);
+    List<String> aliasesDev = remoteAliasService.getAliasesForCluster(expectedClusterNameDev);
+    Assert.assertEquals("Expected 'knox.test.alias.dev' aliases to have been removed.", 0, aliasesDev.size());
+
+    // Invoke the bulk removal method for the sandbox cluster
+    remoteAliasService.removeAliasesForCluster(expectedClusterName, expectedAliases);
+    List<String> aliases = remoteAliasService.getAliasesForCluster(expectedClusterName);
+    Assert.assertEquals("Expected 'knox.test.alias' aliases to have been removed.", 0, aliases.size());
+  }
+
 }
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/RemoteAliasServiceTestProvider.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/RemoteAliasServiceTestProvider.java
index 523c1e2..2a0dc05 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/RemoteAliasServiceTestProvider.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/RemoteAliasServiceTestProvider.java
@@ -30,6 +30,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 public class RemoteAliasServiceTestProvider implements RemoteAliasServiceProvider {
   @Override
@@ -60,11 +61,25 @@
     }
 
     @Override
+    public void addAliasesForCluster(String clusterName, Map<String, String> credentials) throws AliasServiceException {
+      for (Map.Entry<String, String> credential : credentials.entrySet()) {
+        addAliasForCluster(clusterName, credential.getKey(), credential.getValue());
+      }
+    }
+
+    @Override
     public void removeAliasForCluster(String clusterName, String alias) {
       aliases.getOrDefault(clusterName, new HashMap<>()).remove(alias);
     }
 
     @Override
+    public void removeAliasesForCluster(String clusterName, Set<String> aliases) throws AliasServiceException {
+      for (String alias : aliases) {
+        removeAliasForCluster(clusterName, alias);
+      }
+    }
+
+    @Override
     public char[] getPasswordFromAliasForCluster(String clusterName, String alias) {
       return aliases.getOrDefault(clusterName, new HashMap<>()).get(alias).toCharArray();
     }
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/ZookeeperRemoteAliasServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/ZookeeperRemoteAliasServiceTest.java
index 7a2a36e..bee945a 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/ZookeeperRemoteAliasServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/security/impl/ZookeeperRemoteAliasServiceTest.java
@@ -41,9 +41,11 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
 
 import static org.easymock.EasyMock.capture;
 
@@ -265,40 +267,116 @@
     }
   }
 
-    @Test
-    @Ignore("should be executed manually in case you'd like to measure how much time alias addition/fetch takes")
-    public void testPerformance() throws Exception {
-        final MasterService masterService = EasyMock.createNiceMock(MasterService.class);
-        EasyMock.expect(masterService.getMasterSecret()).andReturn("ThisIsMyM4sterP4sW0r!d".toCharArray()).anyTimes();
-        EasyMock.replay(masterService);
+  @Test
+  public void testRemoveAliasesForCluster() throws Exception {
+    final String expectedClusterName = "sandbox";
+    final String expectedAlias = "knox.test.alias";
+    final String expectedPassword = "dummyPassword";
 
-        final DefaultKeystoreService keystoreService = new DefaultKeystoreService();
-        keystoreService.init(gc, null);
-        keystoreService.setMasterService(masterService);
-
-        final int rounds = 11;
-        final int numOfAliases = 200;
-        final String cluster = "myTestCluster";
-        for (int round = 0; round < rounds; round++) {
-            //re-creating the alias service every time so that its cache is empty too
-            final DefaultAliasService aliasService = new DefaultAliasService();
-            aliasService.init(gc, null);
-            aliasService.setMasterService(masterService);
-            aliasService.setKeystoreService(keystoreService);
-
-            RemoteConfigurationRegistryClientService clientService = (new ZooKeeperClientServiceProvider()).newInstance();
-            clientService.setAliasService(aliasService);
-            clientService.init(gc, Collections.emptyMap());
-
-            final ZookeeperRemoteAliasService zkAlias = new ZookeeperRemoteAliasService(aliasService, masterService, clientService);
-            zkAlias.init(gc, Collections.emptyMap());
-            zkAlias.start();
-            final long start = System.currentTimeMillis();
-            for (int i = 0; i < numOfAliases; i++) {
-                zkAlias.addAliasForCluster(cluster, "alias" + i, "password" + i);
-                Assert.assertEquals("password" + i, new String(zkAlias.getPasswordFromAliasForCluster(cluster, "alias" + i)));
-            }
-            System.out.println(System.currentTimeMillis() - start);
-        }
+    final int aliasCount = 5;
+    final Set<String> expectedAliases = new HashSet<>();
+    for (int i = 0; i < aliasCount ; i++) {
+      expectedAliases.add(expectedAlias + i);
     }
+
+    final String expectedClusterNameDev = "development";
+    final String expectedAliasDev = "knox.test.alias.dev";
+    final String expectedPasswordDev = "otherDummyPassword";
+
+    final int devAliasCount = 3;
+    final Set<String> expectedDevAliases = new HashSet<>();
+    for (int i = 0; i < 3 ; i++) {
+      expectedDevAliases.add(expectedAliasDev + i);
+    }
+
+    // Mock Alias Service
+    final DefaultAliasService defaultAlias = EasyMock.createNiceMock(DefaultAliasService.class);
+    // Captures for validating the alias creation for a generated topology
+    final Capture<String> capturedCluster = EasyMock.newCapture();
+    final Capture<String> capturedAlias = EasyMock.newCapture();
+    final Capture<String> capturedPwd = EasyMock.newCapture();
+
+    defaultAlias.addAliasForCluster(capture(capturedCluster), capture(capturedAlias), capture(capturedPwd));
+    EasyMock.expectLastCall().anyTimes();
+
+    // defaultAlias.getAliasesForCluster() never returns null
+    EasyMock.expect(defaultAlias.getAliasesForCluster(expectedClusterName))
+            .andReturn(new ArrayList<>()).anyTimes();
+    EasyMock.expect(defaultAlias.getAliasesForCluster(expectedClusterNameDev))
+            .andReturn(new ArrayList<>()).anyTimes();
+
+    EasyMock.replay(defaultAlias);
+
+    final DefaultMasterService ms = EasyMock.createNiceMock(DefaultMasterService.class);
+    EasyMock.expect(ms.getMasterSecret()).andReturn("knox".toCharArray()).anyTimes();
+    EasyMock.replay(ms);
+
+    RemoteConfigurationRegistryClientService clientService = (new ZooKeeperClientServiceProvider()).newInstance();
+    clientService.setAliasService(defaultAlias);
+    clientService.init(gc, Collections.emptyMap());
+
+    final ZookeeperRemoteAliasService zkAlias = new ZookeeperRemoteAliasService(defaultAlias, ms, clientService);
+    zkAlias.init(gc, Collections.emptyMap());
+    zkAlias.start();
+
+    int originalSize = zkAlias.getAliasesForCluster(expectedClusterName).size();
+
+    // Put
+    for (String alias : expectedAliases) {
+      zkAlias.addAliasForCluster(expectedClusterName, alias, expectedPassword);
+    }
+    for (String alias : expectedDevAliases) {
+      zkAlias.addAliasForCluster(expectedClusterNameDev, alias, expectedPasswordDev);
+    }
+
+    Assert.assertEquals(originalSize + aliasCount, zkAlias.getAliasesForCluster(expectedClusterName).size());
+    Assert.assertEquals(devAliasCount, zkAlias.getAliasesForCluster(expectedClusterNameDev).size());
+
+    // Invoke the bulk removal method for the dev cluster
+    zkAlias.removeAliasesForCluster(expectedClusterNameDev, expectedDevAliases);
+    List<String> aliasesDev = zkAlias.getAliasesForCluster(expectedClusterNameDev);
+    Assert.assertEquals("Expected 'knox.test.alias.dev' aliases to have been removed.", 0, aliasesDev.size());
+
+    // Invoke the bulk removal method for the sandbox cluster
+    zkAlias.removeAliasesForCluster(expectedClusterName, expectedAliases);
+    List<String> aliases = zkAlias.getAliasesForCluster(expectedClusterName);
+    Assert.assertEquals("Expected 'knox.test.alias' aliases to have been removed.", originalSize, aliases.size());
+  }
+
+  @Test
+  @Ignore("should be executed manually in case you'd like to measure how much time alias addition/fetch takes")
+  public void testPerformance() throws Exception {
+      final MasterService masterService = EasyMock.createNiceMock(MasterService.class);
+      EasyMock.expect(masterService.getMasterSecret()).andReturn("ThisIsMyM4sterP4sW0r!d".toCharArray()).anyTimes();
+      EasyMock.replay(masterService);
+
+      final DefaultKeystoreService keystoreService = new DefaultKeystoreService();
+      keystoreService.init(gc, null);
+      keystoreService.setMasterService(masterService);
+
+      final int rounds = 11;
+      final int numOfAliases = 200;
+      final String cluster = "myTestCluster";
+      for (int round = 0; round < rounds; round++) {
+          //re-creating the alias service every time so that its cache is empty too
+          final DefaultAliasService aliasService = new DefaultAliasService();
+          aliasService.init(gc, null);
+          aliasService.setMasterService(masterService);
+          aliasService.setKeystoreService(keystoreService);
+
+          RemoteConfigurationRegistryClientService clientService = (new ZooKeeperClientServiceProvider()).newInstance();
+          clientService.setAliasService(aliasService);
+          clientService.init(gc, Collections.emptyMap());
+
+          final ZookeeperRemoteAliasService zkAlias = new ZookeeperRemoteAliasService(aliasService, masterService, clientService);
+          zkAlias.init(gc, Collections.emptyMap());
+          zkAlias.start();
+          final long start = System.currentTimeMillis();
+          for (int i = 0; i < numOfAliases; i++) {
+              zkAlias.addAliasForCluster(cluster, "alias" + i, "password" + i);
+              Assert.assertEquals("password" + i, new String(zkAlias.getPasswordFromAliasForCluster(cluster, "alias" + i)));
+          }
+          System.out.println(System.currentTimeMillis() - start);
+      }
+  }
 }
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateServiceTest.java
index d982186..8254843 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateServiceTest.java
@@ -21,15 +21,370 @@
 import org.apache.knox.gateway.services.security.AliasService;
 import org.apache.knox.gateway.services.security.AliasServiceException;
 import org.apache.knox.gateway.services.security.token.TokenStateService;
+import org.apache.knox.gateway.services.security.token.impl.JWTToken;
+import org.easymock.EasyMock;
+import org.junit.Test;
 
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
 import java.security.cert.Certificate;
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import static org.easymock.EasyMock.anyObject;
+import static org.easymock.EasyMock.anyString;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
 
 public class AliasBasedTokenStateServiceTest extends DefaultTokenStateServiceTest {
 
+  /**
+   * KNOX-2375
+   */
+  @Test
+  public void testBulkTokenStateEviction() throws Exception {
+    final long evictionInterval = TimeUnit.SECONDS.toMillis(3);
+    final long maxTokenLifetime = evictionInterval * 3;
+
+    final Set<JWTToken> testTokens = new HashSet<>();
+    for (int i = 0; i < 10 ; i++) {
+      testTokens.add(createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60)));
+    }
+
+    List<String> testTokenStateAliases = new ArrayList<>();
+    for (JWTToken token : testTokens) {
+      String tokenId = token.getClaim(JWTToken.KNOX_ID_CLAIM);
+      testTokenStateAliases.add(tokenId);
+      testTokenStateAliases.add(tokenId + AliasBasedTokenStateService.TOKEN_MAX_LIFETIME_POSTFIX);
+    }
+
+    // Create a mock AliasService so we can verify that the expected bulk removal method is invoked when the token state
+    // reaper runs.
+    AliasService aliasService = EasyMock.createNiceMock(AliasService.class);
+    EasyMock.expect(aliasService.getPasswordFromAliasForCluster(anyString(), anyString()))
+            .andReturn(String.valueOf(System.currentTimeMillis()).toCharArray())
+            .anyTimes();
+    EasyMock.expect(aliasService.getAliasesForCluster(AliasService.NO_CLUSTER_NAME)).andReturn(testTokenStateAliases).anyTimes();
+    // Expecting the bulk alias removal method to be invoked only once, rather than the individual alias removal method
+    // invoked twice for every expired token.
+    aliasService.removeAliasesForCluster(anyString(), anyObject());
+    EasyMock.expectLastCall().andVoid().once();
+
+    EasyMock.replay(aliasService);
+
+    AliasBasedTokenStateService tss = new AliasBasedTokenStateService();
+    tss.setAliasService(aliasService);
+    initTokenStateService(tss);
+
+    try {
+      tss.start();
+
+      // Add the expired tokens
+      for (JWTToken token : testTokens) {
+        tss.addToken(token.getClaim(JWTToken.KNOX_ID_CLAIM),
+                     System.currentTimeMillis(),
+                     token.getExpiresDate().getTime(),
+                     maxTokenLifetime);
+        assertTrue("Expected the token to have expired.", tss.isExpired(token));
+      }
+
+      // Sleep to allow the eviction evaluation to be performed
+      Thread.sleep(evictionInterval + (evictionInterval / 2));
+    } finally {
+      tss.stop();
+    }
+
+    // Verify that the expected method was invoked
+    EasyMock.verify(aliasService);
+  }
+
+  @Test
+  public void testAddAndRemoveTokenIncludesCache() throws Exception {
+    final Set<JWTToken> testTokens = new HashSet<>();
+    for (int i = 0; i < 10 ; i++) {
+      testTokens.add(createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60)));
+    }
+
+    List<String> testTokenStateAliases = new ArrayList<>();
+    for (JWTToken token : testTokens) {
+      String tokenId = token.getClaim(JWTToken.KNOX_ID_CLAIM);
+      testTokenStateAliases.add(tokenId);
+      testTokenStateAliases.add(tokenId + AliasBasedTokenStateService.TOKEN_MAX_LIFETIME_POSTFIX);
+    }
+
+    // Create a mock AliasService so we can verify that the expected bulk removal method is invoked (and that the
+    // individual removal method is NOT invoked) when the token state reaper runs.
+    AliasService aliasService = EasyMock.createMock(AliasService.class);
+    EasyMock.expect(aliasService.getAliasesForCluster(AliasService.NO_CLUSTER_NAME)).andReturn(testTokenStateAliases).anyTimes();
+    // Expecting the bulk alias removal method to be invoked only once, rather than the individual alias removal method
+    // invoked twice for every expired token.
+    aliasService.removeAliasesForCluster(anyString(), anyObject());
+    EasyMock.expectLastCall().andVoid().once();
+
+    EasyMock.replay(aliasService);
+
+    AliasBasedTokenStateService tss = new AliasBasedTokenStateService();
+    tss.setAliasService(aliasService);
+    initTokenStateService(tss);
+
+    Field tokenExpirationsField = tss.getClass().getSuperclass().getDeclaredField("tokenExpirations");
+    tokenExpirationsField.setAccessible(true);
+    Map<String, Long> tokenExpirations = (Map<String, Long>) tokenExpirationsField.get(tss);
+
+    Field maxTokenLifetimesField = tss.getClass().getSuperclass().getDeclaredField("maxTokenLifetimes");
+    maxTokenLifetimesField.setAccessible(true);
+    Map<String, Long> maxTokenLifetimes = (Map<String, Long>) maxTokenLifetimesField.get(tss);
+
+    final long evictionInterval = TimeUnit.SECONDS.toMillis(3);
+    final long maxTokenLifetime = evictionInterval * 3;
+
+    try {
+      tss.start();
+
+      // Add the expired tokens
+      for (JWTToken token : testTokens) {
+        tss.addToken(token.getClaim(JWTToken.KNOX_ID_CLAIM),
+                     System.currentTimeMillis(),
+                     token.getExpiresDate().getTime(),
+                     maxTokenLifetime);
+      }
+
+      assertEquals("Expected the tokens to have been added in the base class cache.", 10, tokenExpirations.size());
+      assertEquals("Expected the tokens lifetimes to have been added in the base class cache.",
+                   10,
+                   maxTokenLifetimes.size());
+
+      // Sleep to allow the eviction evaluation to be performed
+      Thread.sleep(evictionInterval + (evictionInterval / 4));
+    } finally {
+      tss.stop();
+    }
+
+    // Verify that the expected methods were invoked
+    EasyMock.verify(aliasService);
+
+    assertEquals("Expected the tokens to have been removed from the base class cache as a result of eviction.",
+                 0,
+                 tokenExpirations.size());
+    assertEquals("Expected the tokens lifetimes to have been removed from the base class cache as a result of eviction.",
+                 0,
+                 maxTokenLifetimes.size());
+  }
+
+  /**
+   * Verify that the token state reaper includes token state which has not been cached, so it's not left in the keystore
+   * forever.
+   */
+  @Test
+  public void testTokenEvictionIncludesUncachedAliases() throws Exception {
+    final long evictionInterval = TimeUnit.SECONDS.toMillis(3);
+    final long maxTokenLifetime = evictionInterval * 3;
+
+    final Set<JWTToken> testTokens = new HashSet<>();
+    for (int i = 0; i < 10 ; i++) {
+      testTokens.add(createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60)));
+    }
+
+    List<String> testTokenStateAliases = new ArrayList<>();
+    for (JWTToken token : testTokens) {
+      testTokenStateAliases.add(token.getClaim(JWTToken.KNOX_ID_CLAIM));
+      testTokenStateAliases.add(token.getClaim(JWTToken.KNOX_ID_CLAIM) + AliasBasedTokenStateService.TOKEN_MAX_LIFETIME_POSTFIX);
+    }
+
+    // Add aliases for an uncached test token
+    final JWTToken uncachedToken = createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60));
+    final String uncachedTokenId = uncachedToken.getClaim(JWTToken.KNOX_ID_CLAIM);
+    testTokenStateAliases.add(uncachedTokenId);
+    testTokenStateAliases.add(uncachedTokenId + AliasBasedTokenStateService.TOKEN_MAX_LIFETIME_POSTFIX);
+    final long uncachedTokenExpiration = System.currentTimeMillis();
+    System.out.println("Uncached token ID: " + uncachedTokenId);
+
+    final Set<String> expectedTokensToEvict = new HashSet<>();
+    expectedTokensToEvict.addAll(testTokenStateAliases);
+    expectedTokensToEvict.add(uncachedTokenId);
+    expectedTokensToEvict.add(uncachedTokenId + AliasBasedTokenStateService.TOKEN_MAX_LIFETIME_POSTFIX);
+
+    // Create a mock AliasService so we can verify that the expected bulk removal method is invoked (and that the
+    // individual removal method is NOT invoked) when the token state reaper runs.
+    AliasService aliasService = EasyMock.createMock(AliasService.class);
+    EasyMock.expect(aliasService.getAliasesForCluster(AliasService.NO_CLUSTER_NAME)).andReturn(testTokenStateAliases).anyTimes();
+    // Expecting the bulk alias removal method to be invoked only once, rather than the individual alias removal method
+    // invoked twice for every expired token.
+    aliasService.removeAliasesForCluster(anyString(), EasyMock.eq(expectedTokensToEvict));
+    EasyMock.expectLastCall().andVoid().once();
+    aliasService.getPasswordFromAliasForCluster(AliasService.NO_CLUSTER_NAME, uncachedTokenId);
+    EasyMock.expectLastCall().andReturn(String.valueOf(uncachedTokenExpiration).toCharArray()).once();
+
+    EasyMock.replay(aliasService);
+
+    AliasBasedTokenStateService tss = new AliasBasedTokenStateService();
+    tss.setAliasService(aliasService);
+    initTokenStateService(tss);
+
+    Field tokenExpirationsField = tss.getClass().getSuperclass().getDeclaredField("tokenExpirations");
+    tokenExpirationsField.setAccessible(true);
+    Map<String, Long> tokenExpirations = (Map<String, Long>) tokenExpirationsField.get(tss);
+
+    Field maxTokenLifetimesField = tss.getClass().getSuperclass().getDeclaredField("maxTokenLifetimes");
+    maxTokenLifetimesField.setAccessible(true);
+    Map<String, Long> maxTokenLifetimes = (Map<String, Long>) maxTokenLifetimesField.get(tss);
+
+    try {
+      tss.start();
+
+      // Add the expired tokens
+      for (JWTToken token : testTokens) {
+        tss.addToken(token.getClaim(JWTToken.KNOX_ID_CLAIM),
+                     System.currentTimeMillis(),
+                     token.getExpiresDate().getTime(),
+                     maxTokenLifetime);
+      }
+
+      assertEquals("Expected the tokens to have been added in the base class cache.", 10, tokenExpirations.size());
+      assertEquals("Expected the tokens lifetimes to have been added in the base class cache.",
+                   10,
+                   maxTokenLifetimes.size());
+
+      // Sleep to allow the eviction evaluation to be performed, but only one iteration
+      Thread.sleep(evictionInterval + (evictionInterval / 4));
+    } finally {
+      tss.stop();
+    }
+
+    // Verify that the expected methods were invoked
+    EasyMock.verify(aliasService);
+
+    assertEquals("Expected the tokens to have been removed from the base class cache as a result of eviction.",
+                 0,
+                 tokenExpirations.size());
+    assertEquals("Expected the tokens lifetimes to have been removed from the base class cache as a result of eviction.",
+                 0,
+                 maxTokenLifetimes.size());
+  }
+
+  @Test
+  public void testGetMaxLifetimeUsesCache() throws Exception {
+    AliasService aliasService = EasyMock.createMock(AliasService.class);
+
+    EasyMock.replay(aliasService);
+
+    AliasBasedTokenStateService tss = new AliasBasedTokenStateService();
+    tss.setAliasService(aliasService);
+    initTokenStateService(tss);
+
+    Field maxTokenLifetimesField = tss.getClass().getSuperclass().getDeclaredField("maxTokenLifetimes");
+    maxTokenLifetimesField.setAccessible(true);
+    Map<String, Long> maxTokenLifetimes = (Map<String, Long>) maxTokenLifetimesField.get(tss);
+
+    final long evictionInterval = TimeUnit.SECONDS.toMillis(3);
+    final long maxTokenLifetime = evictionInterval * 3;
+
+    final Set<JWTToken> testTokens = new HashSet<>();
+    for (int i = 0; i < 10 ; i++) {
+      testTokens.add(createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60)));
+    }
+
+    try {
+      tss.start();
+
+      // Add the expired tokens
+      for (JWTToken token : testTokens) {
+        tss.addToken(token.getClaim(JWTToken.KNOX_ID_CLAIM),
+                System.currentTimeMillis(),
+                token.getExpiresDate().getTime(),
+                maxTokenLifetime);
+
+      }
+
+      assertEquals("Expected the tokens lifetimes to have been added in the base class cache.",
+                   10,
+                   maxTokenLifetimes.size());
+
+      // Set the cache values to be different from the underlying alias value
+      final long updatedMaxLifetime = evictionInterval * 5;
+      for (Map.Entry<String, Long> entry : maxTokenLifetimes.entrySet()) {
+        entry.setValue(updatedMaxLifetime);
+      }
+
+      // Verify that we get the cache value back
+      for (String tokenId : maxTokenLifetimes.keySet()) {
+        assertEquals("Expected the cached max lifetime, rather than the alias value",
+                     updatedMaxLifetime,
+                     tss.getMaxLifetime(tokenId));
+      }
+    } finally {
+      tss.stop();
+    }
+
+    // Verify that the expected methods were invoked
+    EasyMock.verify(aliasService);
+  }
+
+  @Test
+  public void testUpdateExpirationUsesCache() throws Exception {
+    AliasService aliasService = EasyMock.createMock(AliasService.class);
+    aliasService.addAliasForCluster(anyString(), anyString(), anyString());
+    EasyMock.expectLastCall().andVoid().atLeastOnce();
+    aliasService.removeAliasForCluster(anyString(), anyObject());
+    EasyMock.expectLastCall().andVoid().atLeastOnce();
+
+    EasyMock.replay(aliasService);
+
+    AliasBasedTokenStateService tss = new AliasBasedTokenStateService();
+    tss.setAliasService(aliasService);
+    initTokenStateService(tss);
+
+    Field tokenExpirationsField = tss.getClass().getSuperclass().getDeclaredField("tokenExpirations");
+    tokenExpirationsField.setAccessible(true);
+    Map<String, Long> tokenExpirations = (Map<String, Long>) tokenExpirationsField.get(tss);
+
+    final long evictionInterval = TimeUnit.SECONDS.toMillis(3);
+    final long maxTokenLifetime = evictionInterval * 3;
+
+    final Set<JWTToken> testTokens = new HashSet<>();
+    for (int i = 0; i < 10 ; i++) {
+      testTokens.add(createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60)));
+    }
+
+    try {
+      tss.start();
+
+      // Add the expired tokens
+      for (JWTToken token : testTokens) {
+        tss.addToken(token.getClaim(JWTToken.KNOX_ID_CLAIM),
+                     System.currentTimeMillis(),
+                     token.getExpiresDate().getTime(),
+                     maxTokenLifetime);
+      }
+
+      assertEquals("Expected the tokens expirations to have been added in the base class cache.",
+                   10,
+                   tokenExpirations.size());
+
+      // Set the cache values to be different from the underlying alias value
+      final long updatedExpiration = System.currentTimeMillis();
+      for (String tokenId : tokenExpirations.keySet()) {
+        tss.updateExpiration(tokenId, updatedExpiration);
+      }
+
+      for (String tokenId : tokenExpirations.keySet()) {
+        assertEquals("Expected the cached expiration to have been updated.",
+                     updatedExpiration,
+                     tss.getTokenExpiration(tokenId, false));
+      }
+    } finally {
+      tss.stop();
+    }
+
+    // Verify that the expected methods were invoked
+    EasyMock.verify(aliasService);
+  }
+
   @Override
   protected TokenStateService createTokenStateService() {
     AliasBasedTokenStateService tss = new AliasBasedTokenStateService();
@@ -69,6 +424,13 @@
     }
 
     @Override
+    public void addAliasesForCluster(String clusterName, Map<String, String> credentials) throws AliasServiceException {
+      for (Map.Entry<String, String> credential : credentials.entrySet()) {
+        addAliasForCluster(clusterName, credential.getKey(), credential.getValue());
+      }
+    }
+
+    @Override
     public void removeAliasForCluster(String clusterName, String alias) throws AliasServiceException {
       if (clusterAliases.containsKey(clusterName)) {
         clusterAliases.get(clusterName).remove(alias);
@@ -76,6 +438,13 @@
     }
 
     @Override
+    public void removeAliasesForCluster(String clusterName, Set<String> aliases) throws AliasServiceException {
+      for (String alias : aliases) {
+        removeAliasForCluster(clusterName, alias);
+      }
+    }
+
+    @Override
     public char[] getPasswordFromAliasForCluster(String clusterName, String alias) throws AliasServiceException {
       char[] value = null;
       if (clusterAliases.containsKey(clusterName)) {
@@ -143,4 +512,31 @@
     }
   }
 
+  @Override
+  protected void addToken(TokenStateService tss, String tokenId, long issueTime, long expiration, long maxLifetime) {
+    super.addToken(tss, tokenId, issueTime, expiration, maxLifetime);
+
+    // Persist any unpersisted token state aliases
+    triggerAliasPersistence(tss);
+  }
+
+  @Override
+  protected void addToken(TokenStateService tss, JWTToken token, long issueTime) {
+    super.addToken(tss, token, issueTime);
+
+    // Persist any unpersisted token state aliases
+    triggerAliasPersistence(tss);
+  }
+
+  private void triggerAliasPersistence(TokenStateService tss) {
+    if (tss instanceof AliasBasedTokenStateService) {
+      try {
+        Method m = tss.getClass().getDeclaredMethod("persistTokenState");
+        m.setAccessible(true);
+        m.invoke(tss);
+      } catch (Exception e) {
+        e.printStackTrace();
+      }
+    }
+  }
 }
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityServiceTest.java
index 1cbdf9b..84817aa 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityServiceTest.java
@@ -80,6 +80,7 @@
     ta.setKeystoreService(ks);
 
     ta.init(config, new HashMap<>());
+    ta.start();
 
     JWT token = ta.issueToken(principal, "RS256");
     assertEquals("KNOXSSO", token.getIssuer());
@@ -126,6 +127,7 @@
     ta.setKeystoreService(ks);
 
     ta.init(config, new HashMap<>());
+    ta.start();
 
     JWT token = ta.issueToken(principal, "https://login.example.com", "RS256");
     assertEquals("KNOXSSO", token.getIssuer());
@@ -173,6 +175,7 @@
     ta.setKeystoreService(ks);
 
     ta.init(config, new HashMap<>());
+    ta.start();
 
     JWT token = ta.issueToken(principal, null, "RS256");
     assertEquals("KNOXSSO", token.getIssuer());
@@ -219,6 +222,7 @@
     ta.setKeystoreService(ks);
 
     ta.init(config, new HashMap<>());
+    ta.start();
 
     JWT token = ta.issueToken(principal, "RS512");
     assertEquals("KNOXSSO", token.getIssuer());
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java
index 1935da8..27d38bf 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java
@@ -61,7 +61,7 @@
     final JWTToken token = createMockToken(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(60));
     final TokenStateService tss = createTokenStateService();
 
-    tss.addToken(token, System.currentTimeMillis());
+    addToken(tss, token, System.currentTimeMillis());
     long expiration = tss.getTokenExpiration(TokenUtils.getTokenId(token));
     assertEquals(token.getExpiresDate().getTime(), expiration);
   }
@@ -91,7 +91,7 @@
     final JWTToken token = createMockToken(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(60));
     final TokenStateService tss = createTokenStateService();
 
-    tss.addToken(token, System.currentTimeMillis());
+    addToken(tss, token, System.currentTimeMillis());
     long expiration = tss.getTokenExpiration(TokenUtils.getTokenId(token));
     assertEquals(token.getExpiresDate().getTime(), expiration);
 
@@ -105,7 +105,7 @@
     final JWTToken token = createMockToken(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(60));
     final TokenStateService tss = createTokenStateService();
 
-    tss.addToken(token, System.currentTimeMillis());
+    addToken(tss, token, System.currentTimeMillis());
     assertFalse(tss.isExpired(token));
   }
 
@@ -114,38 +114,35 @@
     final JWTToken token = createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60));
     final TokenStateService tss = createTokenStateService();
 
-    tss.addToken(token, System.currentTimeMillis());
+    addToken(tss, token, System.currentTimeMillis());
     assertTrue(tss.isExpired(token));
   }
 
-
   @Test(expected = UnknownTokenException.class)
   public void testIsExpired_Revoked() throws Exception {
     final JWTToken token = createMockToken(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(60));
     final TokenStateService tss = createTokenStateService();
 
-    tss.addToken(token, System.currentTimeMillis());
+    addToken(tss, token, System.currentTimeMillis());
     assertFalse("Expected the token to be valid.", tss.isExpired(token));
 
     tss.revokeToken(token);
     tss.isExpired(token);
   }
 
-
   @Test
   public void testRenewal() throws Exception {
     final JWTToken token = createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60));
     final TokenStateService tss = createTokenStateService();
 
     // Add the expired token
-    tss.addToken(token, System.currentTimeMillis());
+    addToken(tss, token, System.currentTimeMillis());
     assertTrue("Expected the token to have expired.", tss.isExpired(token));
 
     tss.renewToken(token);
     assertFalse("Expected the token to have been renewed.", tss.isExpired(token));
   }
 
-
   @Test
   public void testRenewalBeyondMaxLifetime() throws Exception {
     long maxLifetimeDuration = TimeUnit.SECONDS.toMillis(5);
@@ -176,10 +173,11 @@
     final long maxTokenLifetime = TimeUnit.MINUTES.toMillis(2);
 
     // Add the expired token
-    tss.addToken(token.getClaim(JWTToken.KNOX_ID_CLAIM),
-                 System.currentTimeMillis(),
-                 token.getExpiresDate().getTime(),
-                 maxTokenLifetime);
+    addToken(tss,
+            token.getClaim(JWTToken.KNOX_ID_CLAIM),
+            System.currentTimeMillis(),
+            token.getExpiresDate().getTime(),
+            maxTokenLifetime);
     assertTrue("Expected the token to have expired.", tss.isExpired(token));
 
     // Sleep to allow the eviction evaluation to be performed prior to the maximum token lifetime
@@ -201,10 +199,11 @@
     try {
       tss.start();
       // Add the expired token
-      tss.addToken(token.getClaim(JWTToken.KNOX_ID_CLAIM),
-                                  System.currentTimeMillis(),
-                                  token.getExpiresDate().getTime(),
-                                  maxTokenLifetime);
+      addToken(tss,
+               token.getClaim(JWTToken.KNOX_ID_CLAIM),
+               System.currentTimeMillis(),
+               token.getExpiresDate().getTime(),
+               maxTokenLifetime);
       assertTrue("Expected the token to have expired.", tss.isExpired(token));
 
       // Sleep to allow the eviction evaluation to be performed
@@ -299,4 +298,12 @@
     token.sign(signer);
     return token;
   }
+
+  protected void addToken(TokenStateService tss, JWTToken token, long issueTime) {
+    tss.addToken(token, issueTime);
+  }
+
+  protected void addToken(TokenStateService tss, String tokenId, long issueTime, long expiration, long maxLifetime) {
+    tss.addToken(tokenId, issueTime, expiration, maxLifetime);
+  }
 }
diff --git a/gateway-service-hashicorp-vault/src/main/java/org/apache/knox/gateway/backend/hashicorp/vault/HashicorpVaultAliasService.java b/gateway-service-hashicorp-vault/src/main/java/org/apache/knox/gateway/backend/hashicorp/vault/HashicorpVaultAliasService.java
index e9e3851..05bc3dd 100644
--- a/gateway-service-hashicorp-vault/src/main/java/org/apache/knox/gateway/backend/hashicorp/vault/HashicorpVaultAliasService.java
+++ b/gateway-service-hashicorp-vault/src/main/java/org/apache/knox/gateway/backend/hashicorp/vault/HashicorpVaultAliasService.java
@@ -38,6 +38,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.ServiceLoader;
+import java.util.Set;
 
 public class HashicorpVaultAliasService implements AliasService {
   public static final String TYPE = "hashicorp.vault";
@@ -102,6 +103,13 @@
   }
 
   @Override
+  public void addAliasesForCluster(String clusterName, Map<String, String> credentials) throws AliasServiceException {
+    for (Map.Entry<String, String> credential : credentials.entrySet()) {
+      addAliasForCluster(clusterName, credential.getKey(), credential.getValue());
+    }
+  }
+
+  @Override
   public void removeAliasForCluster(String clusterName, String alias) throws AliasServiceException {
     // Delete is by default a soft delete with versioned KV in Vault
     // https://learn.hashicorp.com/vault/secrets-management/sm-versioned-kv#step-6-permanently-delete-data
@@ -120,6 +128,13 @@
   }
 
   @Override
+  public void removeAliasesForCluster(String clusterName, Set<String> aliases) throws AliasServiceException {
+    for (String alias : aliases) {
+      removeAliasForCluster(clusterName, alias);
+    }
+  }
+
+  @Override
   public char[] getPasswordFromAliasForCluster(String clusterName, String alias) throws AliasServiceException {
     try {
       Versioned<Map<String, Object>> mapVersioned = vault.get(getPath(clusterName, alias));
diff --git a/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java b/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java
index e10459b..afc6738 100644
--- a/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java
+++ b/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/TokenServiceResourceTest.java
@@ -158,7 +158,9 @@
     assertTrue(authority.verifyToken(parsedToken));
   }
 
-  // KNOX-2266
+  /**
+   * KNOX-2266
+   */
   @Test
   public void testConcurrentGetToken() throws Exception {
 
@@ -1254,6 +1256,11 @@
     }
 
     @Override
+    public long getTokenExpiration(String tokenId, boolean validate) throws UnknownTokenException {
+      return 0;
+    }
+
+    @Override
     public void init(GatewayConfig config, Map<String, String> options) throws ServiceLifecycleException {
     }
 
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java
index 2ebd9ae..7825e7b 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java
@@ -674,6 +674,12 @@
   long getKnoxTokenEvictionGracePeriod();
 
   /**
+   * Return the configured token state alias persistence interval (in seconds).
+   * @return Token state alias persistence interval in seconds.
+   */
+  long getKnoxTokenStateAliasPersistenceInterval();
+
+  /**
    * @return the list of topologies that should be hidden on Knox homepage
    */
   Set<String> getHiddenTopologiesOnHomepage();
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/AliasService.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/AliasService.java
index 79d69d1..730d932 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/AliasService.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/AliasService.java
@@ -19,6 +19,8 @@
 
 import java.security.cert.Certificate;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 import org.apache.knox.gateway.services.Service;
 
@@ -31,9 +33,15 @@
   void addAliasForCluster(String clusterName, String alias,
       String value) throws AliasServiceException;
 
+  void addAliasesForCluster(String clusterName,
+      Map<String, String> credentials) throws AliasServiceException;
+
   void removeAliasForCluster(String clusterName, String alias)
       throws AliasServiceException;
 
+  void removeAliasesForCluster(String clusterName, Set<String> aliases)
+      throws AliasServiceException;
+
   char[] getPasswordFromAliasForCluster(String clusterName,
       String alias) throws AliasServiceException;
 
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/KeystoreService.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/KeystoreService.java
index d16766d..95c1f71 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/KeystoreService.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/KeystoreService.java
@@ -21,6 +21,8 @@
 import java.security.KeyStore;
 import java.security.KeyStoreException;
 import java.security.cert.Certificate;
+import java.util.Map;
+import java.util.Set;
 
 public interface KeystoreService {
 
@@ -64,8 +66,12 @@
 
   void addCredentialForCluster(String clusterName, String alias, String key) throws KeystoreServiceException;
 
+  void addCredentialsForCluster(String clusterName, Map<String, String> credentials) throws KeystoreServiceException;
+
   void removeCredentialForCluster(String clusterName, String alias) throws KeystoreServiceException;
 
+  void removeCredentialsForCluster(String clusterName, Set<String> aliases) throws KeystoreServiceException;
+
   char[] getCredentialForCluster(String clusterName, String alias) throws KeystoreServiceException;
 
   String getKeystorePath();
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java
index d89607c..602cf53 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java
@@ -152,4 +152,14 @@
    * @return The token's expiration time in milliseconds.
    */
   long getTokenExpiration(String tokenId) throws UnknownTokenException;
+
+  /**
+    *
+    * @param tokenId  The token unique identifier.
+    * @param validate Flag indicating whether the token needs to be validated.
+    *
+    * @return The token's expiration time in milliseconds.
+    */
+  long getTokenExpiration(String tokenId, boolean validate) throws UnknownTokenException;
+
 }
diff --git a/gateway-test-release-utils/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java b/gateway-test-release-utils/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java
index 8c2a1da..e3dbeaa 100644
--- a/gateway-test-release-utils/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java
+++ b/gateway-test-release-utils/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java
@@ -791,6 +791,11 @@
   }
 
   @Override
+  public long getKnoxTokenStateAliasPersistenceInterval() {
+    return 0;
+  }
+
+  @Override
   public Set<String> getHiddenTopologiesOnHomepage() {
     return Collections.emptySet();
   }