This guide shows how to integrate the Keycloak Testcontainer into Spring Boot tests, covering the most common patterns for dynamic property injection and OAuth2/OIDC resource server configuration.
- Spring Boot 2.7+ (Spring Boot 3.x / 4.x recommended)
- Java 17+ (required by Spring Boot 4.0)
- Testcontainers Keycloak dependency (see Quick Start)
- Spring Security OAuth2 Resource Server or Spring Security OAuth2 Client on the classpath
| Spring Boot | Java | Notes |
|---|---|---|
| 2.7.x | 11+ | @DynamicPropertySource and ApplicationContextInitializer patterns work |
| 3.x | 17+ | Same patterns; @ServiceConnection introduced but no Keycloak support built in |
| 4.x | 17+ | @ServiceConnection preferred where available; no built-in Keycloak/OIDC support, so @DynamicPropertySource remains the right approach |
Spring Boot 4.x note:
@ServiceConnectionis now the preferred pattern for containers that Spring Boot knows about natively (databases, Redis, etc.), but there is no built-in@ServiceConnectionsupport for Keycloak or generic OIDC providers.@DynamicPropertySourceis therefore still the canonical approach for Keycloak integration tests across all Spring Boot versions.
Add the Keycloak Testcontainer to your test dependencies alongside Spring Boot's testing support:
Maven:
<dependency>
<groupId>com.github.dasniko</groupId>
<artifactId>testcontainers-keycloak</artifactId>
<version>VERSION</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>Gradle (Kotlin DSL):
testImplementation("com.github.dasniko:testcontainers-keycloak:VERSION")
testImplementation("org.testcontainers:junit-jupiter")@DynamicPropertySource injects the container's dynamic URLs into the Spring Environment before the application context is created. It works identically across Spring Boot 2.7, 3.x, and 4.x.
A typical Spring Boot resource server configured with spring-security-oauth2-resource-server uses the issuer URI to fetch the OIDC discovery document. Set it to Keycloak's realm URL:
import dasniko.testcontainers.keycloak.KeycloakContainer;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class ResourceServerIntegrationTest {
@Container
static KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:26.4")
.withRealmImportFile("/test-realm.json");
@DynamicPropertySource
static void keycloakProperties(DynamicPropertyRegistry registry) {
registry.add("spring.security.oauth2.resourceserver.jwt.issuer-uri",
() -> keycloak.getIssuerUrl("test"));
}
@Autowired
MockMvc mockMvc;
@Test
void shouldReturnUnauthorizedWithoutToken() throws Exception {
mockMvc.perform(get("/api/secured"))
.andExpect(status().isUnauthorized());
}
@Test
void shouldAcceptValidBearerToken() throws Exception {
String token = obtainAccessToken();
mockMvc.perform(get("/api/secured")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
}
private String obtainAccessToken() {
// Use Keycloak admin client or a direct token request
// to obtain a token from the running container.
// See "Obtaining tokens in tests" section below.
return "...";
}
}Your application.properties / application.yml in src/test/resources can still contain a placeholder — the @DynamicPropertySource value takes precedence:
# src/test/resources/application-test.properties
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8080/realms/testFor applications acting as an OAuth2 client (e.g., calling a downstream API with a client credentials token):
@DynamicPropertySource
static void keycloakProperties(DynamicPropertyRegistry registry) {
String issuerUri = keycloak.getIssuerUrl("test");
registry.add("spring.security.oauth2.client.provider.keycloak.issuer-uri", () -> issuerUri);
registry.add("spring.security.oauth2.client.registration.keycloak.client-id", () -> "my-client");
registry.add("spring.security.oauth2.client.registration.keycloak.client-secret", () -> "my-secret");
registry.add("spring.security.oauth2.client.registration.keycloak.authorization-grant-type",
() -> "client_credentials");
}Before @DynamicPropertySource was introduced, the canonical Spring approach was a custom ApplicationContextInitializer. This pattern is still useful when you need to share a single container instance across multiple test classes via a base class or a Spring @TestConfiguration. Both ApplicationContextInitializer and TestPropertyValues are available in all Spring Boot versions including 4.x.
import dasniko.testcontainers.keycloak.KeycloakContainer;
import org.springframework.boot.test.util.TestPropertyValues;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.test.context.ContextConfiguration;
@SpringBootTest
@ContextConfiguration(initializers = ResourceServerTest.Initializer.class)
class ResourceServerTest {
static KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:26.4")
.withRealmImportFile("/test-realm.json");
static {
keycloak.start();
}
static class Initializer
implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(ConfigurableApplicationContext ctx) {
TestPropertyValues.of(
"spring.security.oauth2.resourceserver.jwt.issuer-uri=" +
keycloak.getIssuerUrl("test")
).applyTo(ctx.getEnvironment());
}
}
}Note: When managing the container lifecycle manually (without
@Testcontainers/@Container), callkeycloak.start()explicitly (e.g., in astaticblock) and ensurekeycloak.stop()is called on teardown. Using astaticfield on a base class shared across test classes is a common pattern for container reuse.
When multiple test classes need the same Keycloak configuration, extract the container into a shared base:
import dasniko.testcontainers.keycloak.KeycloakContainer;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
abstract class AbstractKeycloakIntegrationTest {
@Container
static final KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:26.4")
.withRealmImportFile("/test-realm.json");
@DynamicPropertySource
static void keycloakProperties(DynamicPropertyRegistry registry) {
registry.add("spring.security.oauth2.resourceserver.jwt.issuer-uri",
() -> keycloak.getIssuerUrl("test"));
}
}Testcontainers will reuse the single container across all subclasses within the same JVM (Ryuk will stop it after the JVM exits).
@SpringBootTest
@AutoConfigureMockMvc
class MyFirstTest extends AbstractKeycloakIntegrationTest {
// ...
}
@SpringBootTest
class MySecondTest extends AbstractKeycloakIntegrationTest {
// ...
}The container provides built-in token helpers that cover the most common OAuth2 grant types with no extra dependencies or boilerplate:
// ROPC grant — returns the access token string directly
String token = keycloak.getAccessToken("test", "my-client", "testuser", "testpass");
// ROPC grant for confidential clients (include client secret)
String token = keycloak.getAccessToken("test", "my-client", "my-secret", "testuser", "testpass");
// Client Credentials grant
String token = keycloak.getClientCredentialsToken("test", "my-client", "my-secret");
// Full token response (access token, refresh token, expiry, token type)
TokenResponse response = keycloak.getTokenResponse("test", "my-client", "my-secret", "testuser", "testpass");
TokenResponse response = keycloak.getClientCredentialsTokenResponse("test", "my-client", "my-secret");These helpers use only the JDK HTTP client and respect the container's TLS configuration automatically.
If you need more control, the Keycloak Admin Client (a transitive dependency) is also available:
import org.keycloak.admin.client.Keycloak;
private String obtainAccessToken(String realm, String clientId, String username, String password) {
try (Keycloak client = Keycloak.getInstance(
keycloak.getAuthServerUrl(),
realm,
username,
password,
clientId)) {
return client.tokenManager().getAccessTokenString();
}
}If your production configuration enforces HTTPS, enable TLS on the test container and point Spring Security at the HTTPS issuer URI:
@Container
static KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:26.4")
.useTls()
.withRealmImportFile("/test-realm.json");
@DynamicPropertySource
static void keycloakProperties(DynamicPropertyRegistry registry) {
// getIssuerUrl() returns an HTTPS URL when TLS is enabled
registry.add("spring.security.oauth2.resourceserver.jwt.issuer-uri",
() -> keycloak.getIssuerUrl("test"));
// Trust the built-in self-signed certificate
registry.add("spring.ssl.bundle.jks.keycloak.truststore.location",
() -> "classpath:tls.jks");
registry.add("spring.ssl.bundle.jks.keycloak.truststore.password", () -> "changeit");
}The built-in tls.jks truststore (password: changeit) is available from the Keycloak Testcontainer JAR on the classpath.
Spring Boot 4.0 deprecated @MockBean and @SpyBean in favour of the Spring Framework native @MockitoBean and @MockitoSpyBean. Update any test classes that mock security-related beans:
// Spring Boot 3.x (still works but deprecated in 4.x)
@MockBean
JwtDecoder jwtDecoder;
// Spring Boot 4.x
@MockitoBean
JwtDecoder jwtDecoder;Spring Boot 4.0 tightened JWT validation: the typ header can no longer be disabled via a JwkSetUriJwtDecoderBuilderCustomizer. Keycloak issues access tokens with typ: Bearer by default, which passes this check without any extra configuration. If you have a custom JWT decoder that previously suppressed type validation, you will need to re-evaluate that configuration when migrating to Spring Boot 4.x.
For fast unit-style tests that only need a mocked OAuth2 principal (no container required), Spring Security's MockMvc support works unchanged in Boot 4.x:
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
mockMvc.perform(get("/api/secured")
.with(jwt()
.jwt(j -> j.claim("realm_access",
Map.of("roles", List.of("user"))))))
.andExpect(status().isOk());Use this pattern to complement, not replace, the full-stack integration tests that use a real Keycloak container.
- Export a realm from a running Keycloak instance via Realm Settings → Action → Partial export (include clients and roles).
- Place the JSON file in
src/test/resources/and reference it with a leading slash:.withRealmImportFile("/my-realm.json"). - If the realm export contains a master-realm admin user definition, call
.withBootstrapAdminDisabled()to prevent conflicts.
- Quick Start
- Quarkus integration guide
- Full README
- Spring Security OAuth2 Resource Server docs
- Spring TestContext Framework — Dynamic Property Sources
- Spring Boot 4.0 Migration Guide
This guide was created with the assistance of Claude (Anthropic).