Use whenever an app uses self-hosted Keycloak and needs integration tests: a throwaway Keycloak per test class via Testcontainers, realm JSON imports, OIDC discovery, token and introspection endpoints, the admin REST API, password, auth-code, client-credentials and token-exchange flows, and UMA.
94%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
Tests start their own Keycloak in a container, import a committed realm fixture, check the realm is up, then drive the real OIDC endpoints. A realm holds users, credentials, roles and groups; a client is an application that asks Keycloak to authenticate users (Server Admin Guide).
# tests/auth/conftest.py
import pytest
import requests
from testcontainers.keycloak import KeycloakContainer
@pytest.fixture(scope="session")
def keycloak():
kc = KeycloakContainer("quay.io/keycloak/keycloak:26.0") \
.with_realm_import_file("tests/auth/fixtures/test-realm.json")
with kc: # the context manager starts and stops the container
yield kcwith_realm_import_file() mounts the file at
/opt/keycloak/data/import/realm.json and adds --import-realm to the
start-dev command; start() waits for /health/ready. The admin
user comes from the container (test / test unless passed), not from
environment variables pointing at another server
(testcontainers-python Keycloak module). The default image is
latest, so always pass a tag.
Java (testcontainers-keycloak):
@Container
static KeycloakContainer keycloak =
new KeycloakContainer("quay.io/keycloak/keycloak:26.0")
.withRealmImportFile("/test-realm.json"); // classpath resourceEvery URL comes from the container (keycloak.get_url()); never a
literal localhost:8080, which can hit a different server on a
developer machine and nothing on CI.
Export the realm (Admin Console: Realm settings → Action → Partial export), then sanitise it with one repeatable command and commit the result:
jq '
del(.smtpServer, .identityProviders, .identityProviderMappers)
| del(.components["org.keycloak.keys.KeyProvider"])
| del(.components["org.keycloak.storage.UserStorageProvider"])
| (.clients[]? | select(.publicClient != true and .secret? != null) | .secret) = "test-secret"
' realm-export.json > tests/auth/fixtures/test-realm.jsonThis drops environment-coupled blocks (SMTP relay, external identity
providers, LDAP user federation) that an isolated container cannot
reach, drops signing keys so the container generates its own, and
replaces each confidential client's secret with a fixed test value
instead of deleting it (an imported confidential client still needs
one). Add local test users with fixed passwords under users if tests
log in. Then grep the output for anything that still looks live
(secret, password, bindCredential, privateKey) before
committing.
Before any flow test, assert the started server serves the imported realm, so a bad import fails here and not as a 404 in every test:
@pytest.fixture(scope="session")
def realm(keycloak):
base = keycloak.get_url()
r = requests.get(f"{base}/realms/test/.well-known/openid-configuration", timeout=5)
assert r.status_code == 200, f"realm 'test' not imported: {r.status_code}"
disco = r.json()
assert disco["token_endpoint"] == f"{base}/realms/test/protocol/openid-connect/token"
assert "introspection_endpoint" in disco
return discoIf it fails: check the container log for the import line, the realm
name inside the JSON, and that the file path exists. Fix the fixture,
not the paths: Keycloak 17+ serves /realms/... with no /auth
prefix (migrating to Quarkus).
def test_client_credentials(realm):
r = requests.post(realm["token_endpoint"], data={
"grant_type": "client_credentials",
"client_id": "service-account-client",
"client_secret": "test-secret",
}, timeout=5)
assert r.status_code == 200
body = r.json()
assert body["token_type"].lower() == "bearer"
assert body["expires_in"] > 0Pass the token to the app's protected endpoint and assert 200, and pass no token and an expired or tampered one and assert 401.
The password grant must not be used per RFC 9700 (RFC 9700). Keep an existing legacy password-grant test only while its client still allows it; write new user-login tests with authorization-code + PKCE (Step 6).
The resource server must authenticate the introspection call with its
own client credentials, not with the caller's token, and refuse the
request on any answer other than "active": true (non-200, timeout,
connection error). Recipe:
references/endpoint-recipes.md.
Each of these has a recipe in references/flow-recipes.md:
requests.Session that keeps the login page's cookies, submit the
form without following redirects, read code from the Location
header (the app's private-scheme callback is never followed), send
the same code_verifier in the exchange, then prove the token is
the expected user via introspection.uma-ticket grant with
response_mode=decision.For a custom browser flow (for example password-less), import the realm with the flow bound, drive it with Playwright through the login page, and assert the redirect carries a code.
Use the admin client the container provides (python-keycloak
KeycloakAdmin logged in with the container's admin user), and seed
realm state from the fixture rather than admin calls wherever
possible. Recipe:
references/endpoint-recipes.md.
jobs:
auth-integration:
runs-on: ubuntu-latest # Docker daemon available
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install "testcontainers[keycloak]" pytest requests
- run: pytest tests/auth -vNo services: block and no KC_URL / admin-password secrets: each run
starts and owns its server, so two builds cannot see each other's data.
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Shared dev / staging Keycloak | Builds interfere; people change data under the test | Per-run container (Step 1) |
latest image | Results change when nobody changed the suite | Pin a tag; upgrade on purpose |
Hard-coded localhost:8080 | Hits whatever runs there | get_url() (Step 1) |
| Gitignoring the realm, fetching it at run time | Clone + Docker no longer enough | Sanitised committed fixture (Step 2) |
| Realm provisioned by admin calls in fixtures | Slow, order-dependent | Import from JSON (Step 2) |
| Middleware falls back to local decode when introspection fails | Admits tokens the server did not vouch for | Refuse on anything but active: true (Step 5) |
k6-load-testing.with_realm_import_file, --import-realm, readiness probe, get_url, get_client, default admin.withRealmImportFile./auth removed from the default context path.oauth-flow-test-author (RFC 6749 / 7636 flow design), session-management-test-author, auth0-tests, okta-tests.