Keycloak Testcontainers: Automated Auth Testing in CI/CD

Guilliano Molaire Guilliano Molaire Updated April 17, 2026 12 min read

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


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.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman