Keycloak Testcontainers: Automated Auth Testing in CI/CD
Last updated: March 2026
Authentication logic is notoriously difficult to test. Mocking OAuth 2.0 token endpoints, faking OIDC discovery documents, and stubbing JWT validation might get your unit tests passing, but they leave a dangerous gap: you never actually verify that your application works with a real identity provider.
Testcontainers solves this problem by spinning up a real Keycloak instance inside a Docker container for each test run. Your integration tests talk to an actual Keycloak server, issue real tokens, and validate real authentication flows. When the tests finish, the container is destroyed.
This guide covers Testcontainers setups for Java (JUnit 5), Python (pytest), and Node.js (Jest), along with realm configuration imports, test user creation, and GitHub Actions CI configuration.
Why Test Against a Real Keycloak Instance
Mocking authentication endpoints introduces several risks:
- Token format drift. Keycloak’s JWT structure can change between versions. Mocks based on old token formats pass tests but fail in production.
- Discovery document mismatches. OIDC discovery endpoints return configuration that your libraries use for validation. A mock that returns the wrong signing algorithm or issuer URL hides real bugs.
- Flow logic errors. Authorization code flows, token exchanges, and refresh token rotation involve multiple HTTP round-trips. Mocking each step is fragile and error-prone.
- Upgrade confidence. When you upgrade Keycloak (or Skycloak handles it for you), Testcontainers-based tests catch breaking changes before they reach production.
Testing against a real Keycloak instance eliminates these issues. The trade-off is slightly longer test execution times, which Testcontainers mitigates through container reuse and parallel execution.
Prerequisites
You need Docker installed and running on your development machine and CI environment. Testcontainers communicates with the Docker daemon to manage container lifecycle.
For Keycloak-specific setup, prepare a realm export JSON file that defines your test realm, clients, roles, and optionally test users. You can export this from any running Keycloak instance:
# Export realm configuration from a running Keycloak instance
# Using the Keycloak Admin CLI (kcadm.sh)
/opt/keycloak/bin/kc.sh export
--dir /tmp/export
--realm my-test-realm
--users realm_file
Or if you are using Skycloak, you can export realm configuration directly from the admin console. For local development and testing, our Keycloak Docker Compose Generator can help you get a local instance running quickly.
Java with JUnit 5
The Keycloak Testcontainers module provides first-class support for Java testing. Start by adding the dependencies to your Maven project:
<dependencies>
<!-- Testcontainers core and Keycloak module -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.20.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.dasniko</groupId>
<artifactId>testcontainers-keycloak</artifactId>
<version>3.5.1</version>
<scope>test</scope>
</dependency>
<!-- HTTP client for token acquisition -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
<scope>test</scope>
</dependency>
<!-- JWT parsing -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
</dependencies>
Realm Import JSON
Create a test realm configuration file at src/test/resources/test-realm.json:
{
"realm": "test-realm",
"enabled": true,
"sslRequired": "none",
"registrationAllowed": false,
"clients": [
{
"clientId": "test-app",
"enabled": true,
"publicClient": false,
"secret": "test-secret",
"directAccessGrantsEnabled": true,
"redirectUris": ["http://localhost:8080/*"],
"protocol": "openid-connect",
"standardFlowEnabled": true,
"serviceAccountsEnabled": true
}
],
"users": [
{
"username": "testuser",
"enabled": true,
"email": "[email protected]",
"firstName": "Test",
"lastName": "User",
"credentials": [
{
"type": "password",
"value": "testpassword",
"temporary": false
}
],
"realmRoles": ["default-roles-test-realm"]
}
],
"roles": {
"realm": [
{
"name": "app-user",
"description": "Application user role"
},
{
"name": "app-admin",
"description": "Application admin role"
}
]
}
}
Test Class with Container Lifecycle
package com.example.auth;
import com.auth0.jwt.JWT;
import com.auth0.jwt.interfaces.DecodedJWT;
import dasniko.testcontainers.keycloak.KeycloakContainer;
import okhttp3.*;
import org.junit.jupiter.api.*;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.io.IOException;
import java.util.Objects;
import static org.junit.jupiter.api.Assertions.*;
@Testcontainers
class KeycloakAuthenticationTest {
@Container
static KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:26.1.0")
.withRealmImportFile("test-realm.json");
private static final OkHttpClient httpClient = new OkHttpClient();
private static final String REALM = "test-realm";
private static final String CLIENT_ID = "test-app";
private static final String CLIENT_SECRET = "test-secret";
private String getTokenEndpoint() {
return keycloak.getAuthServerUrl() + "/realms/" + REALM
+ "/protocol/openid-connect/token";
}
private String acquireToken(String username, String password) throws IOException {
RequestBody formBody = new FormBody.Builder()
.add("grant_type", "password")
.add("client_id", CLIENT_ID)
.add("client_secret", CLIENT_SECRET)
.add("username", username)
.add("password", password)
.build();
Request request = new Request.Builder()
.url(getTokenEndpoint())
.post(formBody)
.build();
try (Response response = httpClient.newCall(request).execute()) {
assertEquals(200, response.code(),
"Token request failed: " + response.body().string());
String body = Objects.requireNonNull(response.body()).string();
// Simple JSON parsing - use a proper library in production
String token = body.split(""access_token":"")[1].split(""")[0];
return token;
}
}
@Test
void shouldAcquireAccessToken() throws IOException {
String token = acquireToken("testuser", "testpassword");
assertNotNull(token);
assertFalse(token.isEmpty());
// Decode and verify token structure
DecodedJWT decoded = JWT.decode(token);
assertEquals("test-realm", decoded.getClaim("azp").isNull()
? null : decoded.getIssuer().contains("test-realm") ? "test-realm" : null);
assertNotNull(decoded.getExpiresAt());
}
@Test
void shouldRejectInvalidCredentials() {
assertThrows(AssertionError.class, () -> {
acquireToken("testuser", "wrongpassword");
});
}
@Test
void shouldIncludeCorrectIssuer() throws IOException {
String token = acquireToken("testuser", "testpassword");
DecodedJWT decoded = JWT.decode(token);
String expectedIssuer = keycloak.getAuthServerUrl() + "/realms/" + REALM;
assertEquals(expectedIssuer, decoded.getIssuer());
}
@Test
void shouldAcquireClientCredentialsToken() throws IOException {
RequestBody formBody = new FormBody.Builder()
.add("grant_type", "client_credentials")
.add("client_id", CLIENT_ID)
.add("client_secret", CLIENT_SECRET)
.build();
Request request = new Request.Builder()
.url(getTokenEndpoint())
.post(formBody)
.build();
try (Response response = httpClient.newCall(request).execute()) {
assertEquals(200, response.code());
String body = Objects.requireNonNull(response.body()).string();
assertTrue(body.contains("access_token"));
}
}
@Test
void shouldValidateOidcDiscoveryDocument() throws IOException {
String discoveryUrl = keycloak.getAuthServerUrl()
+ "/realms/" + REALM
+ "/.well-known/openid-configuration";
Request request = new Request.Builder()
.url(discoveryUrl)
.get()
.build();
try (Response response = httpClient.newCall(request).execute()) {
assertEquals(200, response.code());
String body = Objects.requireNonNull(response.body()).string();
assertTrue(body.contains(""issuer""));
assertTrue(body.contains(""authorization_endpoint""));
assertTrue(body.contains(""token_endpoint""));
assertTrue(body.contains(""jwks_uri""));
}
}
}
You can decode and inspect tokens generated during testing using our JWT Token Analyzer to verify claims are structured correctly.
Running the Tests
mvn test -Dtest=KeycloakAuthenticationTest
The first run downloads the Keycloak Docker image, which takes a minute or two. Subsequent runs reuse the cached image and start the container in roughly 10-15 seconds.
Python with pytest
For Python projects, use the testcontainers package with a custom Keycloak container setup:
pip install testcontainers requests pytest python-jose
Conftest Setup
Create a conftest.py with a Keycloak fixture:
# conftest.py
import json
import time
from pathlib import Path
import pytest
import requests
from testcontainers.keycloak import KeycloakContainer
@pytest.fixture(scope="session")
def keycloak_container():
"""Start a Keycloak container for the test session."""
realm_path = Path(__file__).parent / "test-realm.json"
kc = KeycloakContainer("quay.io/keycloak/keycloak:26.1.0")
kc.with_env("KC_HEALTH_ENABLED", "true")
kc.with_volume_mapping(
str(realm_path),
"/opt/keycloak/data/import/test-realm.json",
"ro",
)
kc.with_command(
"start-dev --import-realm"
)
kc.start()
# Wait for Keycloak to be ready
base_url = kc.get_url()
_wait_for_keycloak(base_url)
yield {
"container": kc,
"base_url": base_url,
"realm": "test-realm",
"client_id": "test-app",
"client_secret": "test-secret",
}
kc.stop()
def _wait_for_keycloak(base_url: str, timeout: int = 120):
"""Wait for Keycloak to become responsive."""
start_time = time.time()
while time.time() - start_time < timeout:
try:
resp = requests.get(
f"{base_url}/realms/master/.well-known/openid-configuration",
timeout=5,
)
if resp.status_code == 200:
return
except requests.ConnectionError:
pass
time.sleep(2)
raise TimeoutError("Keycloak did not start within timeout")
@pytest.fixture
def keycloak_token(keycloak_container):
"""Acquire an access token for the test user."""
token_url = (
f"{keycloak_container['base_url']}"
f"/realms/{keycloak_container['realm']}"
f"/protocol/openid-connect/token"
)
response = requests.post(
token_url,
data={
"grant_type": "password",
"client_id": keycloak_container["client_id"],
"client_secret": keycloak_container["client_secret"],
"username": "testuser",
"password": "testpassword",
},
)
response.raise_for_status()
return response.json()["access_token"]
Test File
# test_keycloak_auth.py
import requests
from jose import jwt
def test_token_acquisition(keycloak_container, keycloak_token):
"""Test that we can acquire a valid access token."""
assert keycloak_token is not None
assert len(keycloak_token) > 0
def test_token_has_correct_issuer(keycloak_container, keycloak_token):
"""Test that the token issuer matches our realm."""
# Decode without verification to inspect claims
claims = jwt.get_unverified_claims(keycloak_token)
expected_issuer = (
f"{keycloak_container['base_url']}"
f"/realms/{keycloak_container['realm']}"
)
assert claims["iss"] == expected_issuer
def test_token_contains_expected_claims(keycloak_container, keycloak_token):
"""Test that the token includes standard OIDC claims."""
claims = jwt.get_unverified_claims(keycloak_token)
assert "sub" in claims
assert "exp" in claims
assert "iat" in claims
assert claims["preferred_username"] == "testuser"
def test_invalid_credentials_rejected(keycloak_container):
"""Test that wrong credentials are rejected."""
token_url = (
f"{keycloak_container['base_url']}"
f"/realms/{keycloak_container['realm']}"
f"/protocol/openid-connect/token"
)
response = requests.post(
token_url,
data={
"grant_type": "password",
"client_id": keycloak_container["client_id"],
"client_secret": keycloak_container["client_secret"],
"username": "testuser",
"password": "wrong-password",
},
)
assert response.status_code == 401
def test_userinfo_endpoint(keycloak_container, keycloak_token):
"""Test the userinfo endpoint returns correct user data."""
userinfo_url = (
f"{keycloak_container['base_url']}"
f"/realms/{keycloak_container['realm']}"
f"/protocol/openid-connect/userinfo"
)
response = requests.get(
userinfo_url,
headers={"Authorization": f"Bearer {keycloak_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["preferred_username"] == "testuser"
assert data["email"] == "[email protected]"
Node.js with Jest
For Node.js projects, use the testcontainers npm package:
npm install --save-dev testcontainers jose node-fetch@2 jest
Test Setup
// keycloak.test.js
const { GenericContainer, Wait } = require("testcontainers");
const path = require("path");
const fetch = require("node-fetch");
const { decodeJwt } = require("jose");
let keycloakContainer;
let baseUrl;
const REALM = "test-realm";
const CLIENT_ID = "test-app";
const CLIENT_SECRET = "test-secret";
beforeAll(async () => {
// Start Keycloak container with realm import
keycloakContainer = await new GenericContainer(
"quay.io/keycloak/keycloak:26.1.0"
)
.withCopyFilesToContainer([
{
source: path.join(__dirname, "test-realm.json"),
target: "/opt/keycloak/data/import/test-realm.json",
},
])
.withCommand(["start-dev", "--import-realm"])
.withExposedPorts(8080)
.withWaitStrategy(Wait.forHttp("/realms/master", 8080).forStatusCode(200))
.withStartupTimeout(120000)
.start();
const host = keycloakContainer.getHost();
const port = keycloakContainer.getMappedPort(8080);
baseUrl = `http://${host}:${port}`;
}, 180000);
afterAll(async () => {
if (keycloakContainer) {
await keycloakContainer.stop();
}
});
async function acquireToken(username, password) {
const tokenUrl = `${baseUrl}/realms/${REALM}/protocol/openid-connect/token`;
const response = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "password",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
username,
password,
}),
});
if (!response.ok) {
throw new Error(`Token request failed: ${response.status}`);
}
const data = await response.json();
return data.access_token;
}
test("should acquire valid access token", async () => {
const token = await acquireToken("testuser", "testpassword");
expect(token).toBeDefined();
expect(token.split(".")).toHaveLength(3);
});
test("should reject invalid credentials", async () => {
await expect(
acquireToken("testuser", "wrongpassword")
).rejects.toThrow("Token request failed: 401");
});
test("should include correct issuer in token", async () => {
const token = await acquireToken("testuser", "testpassword");
const claims = decodeJwt(token);
expect(claims.iss).toBe(`${baseUrl}/realms/${REALM}`);
});
test("should include user claims in token", async () => {
const token = await acquireToken("testuser", "testpassword");
const claims = decodeJwt(token);
expect(claims.preferred_username).toBe("testuser");
expect(claims.email).toBe("[email protected]");
});
test("should validate OIDC discovery document", async () => {
const discoveryUrl = `${baseUrl}/realms/${REALM}/.well-known/openid-configuration`;
const response = await fetch(discoveryUrl);
const config = await response.json();
expect(config.issuer).toBe(`${baseUrl}/realms/${REALM}`);
expect(config.authorization_endpoint).toBeDefined();
expect(config.token_endpoint).toBeDefined();
expect(config.jwks_uri).toBeDefined();
});
GitHub Actions CI Configuration
Here is a complete GitHub Actions workflow that runs Keycloak Testcontainers tests. Docker is pre-installed on GitHub Actions runners, so no additional setup is needed:
# .github/workflows/auth-tests.yml
name: Authentication Integration Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
java-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: "21"
distribution: "temurin"
cache: maven
- name: Run integration tests
run: mvn verify -pl auth-service -Dtest="*IntegrationTest"
# Testcontainers automatically pulls the Keycloak image
# Docker is pre-installed on GitHub Actions runners
python-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install dependencies
run: pip install -r requirements-test.txt
- name: Run integration tests
run: pytest tests/integration/ -v --timeout=300
node-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install dependencies
run: npm ci
- name: Run integration tests
run: npx jest --testPathPattern=integration --timeout=180000
Performance Tips for CI
Running Keycloak containers in CI adds time to your pipeline. Here are strategies to keep it manageable:
Container Reuse Across Tests
In Java, use @Container with static to share one container across all tests in a class. For a shared container across multiple test classes:
public class SharedKeycloakContainer {
private static final KeycloakContainer INSTANCE =
new KeycloakContainer("quay.io/keycloak/keycloak:26.1.0")
.withRealmImportFile("test-realm.json")
.withReuse(true); // Reuse container across test runs
static {
INSTANCE.start();
}
public static KeycloakContainer getInstance() {
return INSTANCE;
}
}
Enable reuse in ~/.testcontainers.properties:
testcontainers.reuse.enable=true
Parallel Test Execution
Structure tests so they do not interfere with each other. Each test should create its own users or use unique identifiers:
@Test
void testUserSpecificFlow() throws IOException {
String uniqueUsername = "user-" + UUID.randomUUID().toString().substring(0, 8);
// Create user via Keycloak Admin API, run test, user is discarded with container
}
Docker Layer Caching in CI
Cache Docker layers in GitHub Actions to speed up subsequent runs:
- name: Set up Docker layer caching
uses: satackey/[email protected]
continue-on-error: true
Testing RBAC and Authorization
Beyond basic token acquisition, test your role-based access control logic. Add roles to your test realm and verify that tokens include the correct role claims:
@Test
void shouldIncludeRealmRolesInToken() throws IOException {
// Assuming testuser has "app-admin" role assigned in realm config
String token = acquireToken("testuser", "testpassword");
DecodedJWT decoded = JWT.decode(token);
String realmAccessClaim = decoded.getClaim("realm_access").toString();
assertTrue(realmAccessClaim.contains("app-admin"),
"Token should include app-admin realm role");
}
For more complex authorization scenarios like fine-grained authorization or ABAC, you can configure authorization services in your test realm JSON and validate policy decisions through the Keycloak Authorization API.
Testing Multi-Factor Authentication Flows
Testing MFA is more complex because it involves interactive flows. For OTP-based MFA, you can configure TOTP in your test realm and use a TOTP library to generate codes:
// Add TOTP to a test user programmatically via the Admin API
// Then generate valid TOTP codes in your tests
import dev.samstevens.totp.code.DefaultCodeGenerator;
import dev.samstevens.totp.code.HashingAlgorithm;
import dev.samstevens.totp.time.SystemTimeProvider;
String generateTotp(String secret) {
DefaultCodeGenerator generator = new DefaultCodeGenerator(HashingAlgorithm.SHA1, 6);
SystemTimeProvider timeProvider = new SystemTimeProvider();
long currentBucket = Math.floorDiv(timeProvider.getTime(), 30);
return generator.generate(secret, currentBucket);
}
Monitoring and Debugging Tests
When tests fail, you need visibility into what happened inside the Keycloak container. Enable container logging:
@Container
static KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:26.1.0")
.withRealmImportFile("test-realm.json")
.withLogConsumer(outputFrame ->
System.out.print("[KEYCLOAK] " + outputFrame.getUtf8String()));
For production systems, Skycloak provides built-in audit logging and insights dashboards that give you similar visibility without manual log parsing.
When to Use Testcontainers vs a Shared Dev Instance
Testcontainers is ideal for:
- CI/CD pipelines where you need a clean, reproducible environment for every run.
- Local development where each developer needs their own isolated Keycloak instance.
- Upgrade testing where you want to verify your application works with a new Keycloak version before deploying.
A shared development Keycloak instance (like one managed by Skycloak) is better for:
- Frontend development where developers need a persistent instance with stable configuration.
- Manual QA testing where testers need a long-running environment.
- Cross-service integration where multiple services need to share a single identity provider.
For most teams, the answer is both. Use Testcontainers in CI and for backend integration tests. Use a shared managed instance for development and QA environments.
Further Reading
- Testcontainers official documentation
- dasniko/testcontainers-keycloak on GitHub
- Keycloak Server Administration Guide
- GitOps for Keycloak with GitHub Actions
- How Keycloak Secures Node.js Microservices
- Keycloak Token Validation for APIs
Tired of managing Keycloak infrastructure for your test and production environments? Skycloak handles the operational heavy lifting so you can focus on building your application. See pricing to find the right plan for your team.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.