skip to content

How does Spring Cloud Vault handle lease renewal and rotation for dynamic secrets, and what must your beans do to pick up rotated values?

level: seniorimportance: must knowfreq 40%

answer

  1. dynamic secret = value + lease (TTL)
  2. SecretLeaseContainer: renew before expiry, else rotate
  3. config.lifecycle.enabled=true
  4. SecretLeaseCreated/Expired/Rotated events
  5. @RefreshScope or LeaseListener to consume new values

basics

~20 s

Dynamic secrets come with a lease (a TTL). A SecretLeaseContainer renews the lease before it expires and, when it can't renew, requests fresh secrets (rotation). To use rotated values, your beans must be in @RefreshScope so they're recreated with the new values.

solid answer

~50 s

Dynamic secret engines (database, PKI) return secrets *plus a lease* — an ID and a TTL after which the secret is revoked. Spring Cloud Vault's `SecretLeaseContainer` tracks each lease and, on a scheduler, **renews** it before expiry up to the lease's max TTL. When renewal is no longer possible (non-renewable, or max TTL reached), it **rotates**: it re-reads the engine to obtain brand-new credentials and publishes a `SecretLeaseCreatedEvent`. This lifecycle is enabled by `spring.cloud.vault.config.lifecycle.enabled=true`. Crucially, rotation updates the backing `LeaseAwareVaultPropertySource`, but a plain singleton bean captured the old value at construction. To actually use rotated credentials you put the affected beans (e.g., a `DataSource` wrapper or `@ConfigurationProperties` holder) in `@RefreshScope`; a rotation event triggers a context refresh so those beans are recreated. For a `DataSource`, teams often use HikariCP with a wrapper that rebuilds credentials, or listen to the lease events directly.

code

java · 25 lines
java
// React to dynamic-secret rotation by re-configuring a pool.
@Configuration
class VaultLeaseConfig {

    @Bean
    SmartInitializingSingleton databaseCredentialListener(
            SecretLeaseContainer leaseContainer,
            HikariDataSource dataSource) {
        return () -> {
            String path = "database/creds/my-role"; // dynamic DB engine
            leaseContainer.addLeaseListener(event -> {
                if (event.getSource().getPath().equals(path)
                        && event instanceof SecretLeaseCreatedEvent created) {
                    Map<String, Object> secret = created.getSecrets();
                    dataSource.getHikariConfigMXBean()
                              .setUsername((String) secret.get("username"));
                    dataSource.getHikariConfigMXBean()
                              .setPassword((String) secret.get("password"));
                    dataSource.getHikariPoolMXBean().softEvictConnections();
                }
            });
            leaseContainer.requestRotatingSecret(path);
        };
    }
}

go deeper

for a junior

Know dynamic secrets have a TTL/lease that must be kept alive.

for a middle

Explain SecretLeaseContainer renewal vs rotation and the lifecycle flag.

for a senior

Explain the singleton/refresh problem and how to consume rotated values via @RefreshScope or lease events.

for a principal

Design pool lifetimes and event-driven credential updates around lease TTLs; reason about failure windows and scheduler reliability.

**Static vs dynamic secrets** — the distinction drives everything here: - **Static (KV) secrets** are values you wrote; they have no meaningful lease and don't rotate on their own. - **Dynamic secrets** are *generated by Vault on demand* by engines like **database** (creates a unique DB user/password per app instance) and **PKI** (issues a short-lived X.509 certificate). Each dynamic secret is issued with a **lease**: a `lease_id` plus a `lease_duration` (TTL). When the TTL expires, Vault **revokes** the secret (e.g., drops the generated DB user). So the app must keep the lease alive or obtain a new secret before expiry — otherwise its credentials suddenly stop working. **`SecretLeaseContainer`** is the Spring Cloud Vault component that manages this. For each requested secret path it: 1. Reads the secret and captures its lease. 2. Schedules **renewal** ahead of expiry (governed by a *minimum renewal* and *expiry threshold* window). Renewal extends the lease without changing the secret value, up to the lease's **max TTL**. 3. When the secret is **non-renewable**, or max TTL is hit, or renewal fails, it performs **rotation** — it re-reads the engine to mint a *new* secret with a *new* lease, and fires lifecycle events: - `SecretLeaseCreatedEvent` — new secret obtained (initial read or rotation). - `SecretLeaseExpiredEvent` — lease expired. - `SecretLeaseRotatedEvent` — value rotated (newer API). - `SecretLeaseErrorEvent` — error during lifecycle. **Enabling the lifecycle:** `spring.cloud.vault.config.lifecycle.enabled=true` (on by default for the config integration in recent versions). You can tune `min-renewal` and `expiry-threshold` under `spring.cloud.vault.config.lifecycle`. **The refresh problem — the key senior insight:** renewing/rotating updates the `LeaseAwareVaultPropertySource` in the `Environment`, but Spring **singletons are created once**. A bean that did `@Value("${spring.datasource.password}")` at construction still holds the *old* password after rotation. Two ways to actually consume rotated values: 1. **`@RefreshScope`**: put the credential-holding bean in refresh scope. On rotation the property source changes and a `RefreshEvent`/`/actuator/refresh` recreates the bean with new values. This is the common pattern for `@ConfigurationProperties`-style holders. 2. **Listen to lease events directly**: implement a `LeaseListener`/`ApplicationListener<SecretLeaseCreatedEvent>` and push the new credentials into a mutable holder (e.g., re-configure a Hikari pool). This is needed for a `DataSource`, because you can't simply recreate an in-flight connection pool without care. **Database engine specifics:** because each app instance gets its *own* generated user, connection pools must be sized/managed with lease TTL in mind; if the lease expires and isn't renewed, existing pooled connections may keep working until the DB closes them, but *new* connections with the revoked user fail. A common approach: short pool max-lifetime aligned below the credential TTL, plus event-driven credential updates. **Gotchas:** - Forgetting `@RefreshScope` — the classic bug: rotation "works" in Vault and in the property source, but the app keeps using stale creds until restart. - Lease TTL shorter than app needs and renewal not enabled → sudden auth failures at TTL. - Non-renewable leases silently rotate rather than renew — plan for value changes, not just TTL extension. - Clock/scheduler starvation: the renewal thread must run; a saturated scheduler can miss the renewal window.

  • Rotation happens in Vault and the property source updates, but the app keeps using old credentials. Why?
    The credential-holding bean is a singleton created once and captured the old value. It must be `@RefreshScope` (recreated on refresh) or you must listen to `SecretLeaseCreatedEvent` and push new values into a mutable holder / connection pool.
  • What is the difference between renewing and rotating a lease?
    Renewing extends the TTL of the *same* secret without changing its value (up to max TTL). Rotating discards the old secret and obtains a brand-new one (new value + new lease), done when the secret is non-renewable or max TTL is reached.
  • How would you keep a HikariCP DataSource working across database-engine credential rotation?
    Listen to lease events, update the Hikari username/password via the config MXBean, and soft-evict idle connections so new connections use fresh creds; align pool max-lifetime below the credential TTL.

saying these in an interview costs you the question

  • Assuming rotated secrets reach beans without @RefreshScope or event handling
  • Thinking renewal changes the secret value (it extends TTL, value unchanged)
  • Treating KV static secrets as if they rotate on a lease

context