Keycloak Integration End to End
GitHub: github.com/mnafshin/oauth
Goal
Stand up Keycloak locally, create realm clients, and connect the demos from posts 6–9:
Thymeleaf Spring Boot (
orders-webon port 8080)Plain Java (
orders-webon port 8082)Shared SPA BFF (
spa-bffon port 8083) for React and Angular
Okta and Auth0 follow the same OIDC steps with different admin UIs — application configuration changes only issuer-uri, client-id, and secrets.
Quick start (demo repo)
If you cloned oauth-demo, skip manual realm setup — import runs on first start:
make start-keycloak
# or:
cd modules/keycloak && docker compose up -d
This starts Keycloak 26.6.3 on port 8081 and imports realms/oauth-demo-realm.json:
| Resource | Value |
|---|---|
| Admin console | http://localhost:8081/admin (admin / admin) |
| Realm | oauth-demo |
Client orders-web |
confidential — Thymeleaf + plain Java |
Client spa-bff |
confidential — shared BFF for React + Angular |
Client secret orders-web |
orders-web-secret (local demo only) |
Client secret spa-bff |
spa-bff-secret (local demo only) |
| Test user | alice / password |
| Client role | app-user on both clients (assigned to alice) |
Redirect URIs (must match exactly)
| App | Redirect URI |
|---|---|
| Thymeleaf Spring Boot (:8080) | http://localhost:8080/login/oauth2/code/keycloak |
| Plain Java (:8082) | http://localhost:8082/callback |
| SPA BFF (:8083) | http://localhost:8083/login/oauth2/code/keycloak |
Run the Thymeleaf demo
export KEYCLOAK_CLIENT_SECRET=orders-web-secret
make start-spring-boot
Visit http://localhost:8080/dashboard and log in as alice.
Run the SPA stack
export KEYCLOAK_SPA_BFF_SECRET=spa-bff-secret
make start-bff # terminal 1
make start-react # or: make start-angular
Open http://localhost:5173 or http://localhost:4200 — details in SPA post.
start-dev is for local development only — not production topology.
Manual setup (understand what the import contains)
Use this path if you are not using the repo import, or you want to learn the admin console steps behind the JSON file.
Start Keycloak with Docker
docker run --name keycloak -p 8081:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.6.3 start-dev
| URL | Purpose |
|---|---|
http://localhost:8081/admin |
Admin console |
http://localhost:8081/realms/oauth-demo/... |
Realm endpoints (after creation) |
Create realm
Admin console → Create realm
Name:
oauth-demoEnabled: ON
Realms isolate users, clients, and keys. Use separate realms per environment (or separate Keycloak instances).
Create client for Spring Boot (orders-web)
Clients → Create client
| Setting | Value |
|---|---|
| Client ID | orders-web |
| Client type | OpenID Connect |
| Client authentication | ON (confidential — has secret) |
Capability config:
Client authentication: ON
Authorization: OFF (we use simple OIDC login first)
Standard flow: ON (Authorization Code)
Direct access grants: OFF (no password grant)
Login settings:
| Field | Value |
|---|---|
| Root URL | http://localhost:8080 |
| Valid redirect URIs | http://localhost:8080/login/oauth2/code/keycloak, http://localhost:8082/callback |
| Valid post logout redirect URIs | http://localhost:8080/ |
| Web origins | http://localhost:8080 |
Save → Credentials tab → copy Client secret → export KEYCLOAK_CLIENT_SECRET=...
Clients → orders-web → Roles → Create app-user (application role for the Thymeleaf dashboard).
Create client for the SPA BFF (spa-bff)
Repeat the same steps with a second client for the shared BFF (post 9):
| Setting | Value |
|---|---|
| Client ID | spa-bff |
| Client authentication | ON (confidential) |
| Standard flow | ON |
| Root URL | http://localhost:8083 |
| Valid redirect URIs | http://localhost:8083/login/oauth2/code/keycloak |
| Valid post logout redirect URIs | http://localhost:5173/*, http://localhost:4200/* |
| Web origins | http://localhost:8083, http://localhost:5173, http://localhost:4200 |
Credentials → copy secret → export KEYCLOAK_SPA_BFF_SECRET=...
Roles → Create app-user on spa-bff. Assign it to alice under Users → Role mapping → Client roles.
Why a separate client? The BFF runs on a different port, uses REST instead of Thymeleaf, and serves multiple SPA origins. Keeping clients separate mirrors how teams split server-rendered apps from API gateways in production.
Create a test user
Users → Create user
Username:
aliceEmail verified: ON
Credentials tab → set password (e.g.password) → Temporary: OFF
Role mapping → Client roles → selectorders-web→ assignapp-user
Without app-user, alice can log in but the app returns 403 on protected routes (@PreAuthorize("hasRole('app-user')") in posts 6 and 9).
Verify OIDC discovery
curl -s http://localhost:8081/realms/oauth-demo/.well-known/openid-configuration | jq .
Confirm:
authorization_endpointtoken_endpointjwks_uriissuer=http://localhost:8081/realms/oauth-demo
Spring issuer-uri must match issuer exactly (including host and port).
Spring configuration (recap)
Thymeleaf app (modules/spring-boot):
spring.security.oauth2.client.registration.keycloak.client-id=orders-web
spring.security.oauth2.client.registration.keycloak.client-secret=${KEYCLOAK_CLIENT_SECRET:orders-web-secret}
spring.security.oauth2.client.registration.keycloak.scope=openid,profile,email
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8081/realms/oauth-demo
SPA BFF (modules/frontend/bff):
spring.security.oauth2.client.registration.keycloak.client-id=spa-bff
spring.security.oauth2.client.registration.keycloak.client-secret=${KEYCLOAK_SPA_BFF_SECRET:spa-bff-secret}
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8081/realms/oauth-demo
app.cors-allowed-origins[0]=http://localhost:5173
app.cors-allowed-origins[1]=http://localhost:4200
app.allowed-return-urls[0]=http://localhost:5173
app.allowed-return-urls[1]=http://localhost:4200
Run from the repo root:
export KEYCLOAK_CLIENT_SECRET=orders-web-secret
make start-spring-boot
export KEYCLOAK_SPA_BFF_SECRET=spa-bff-secret
make start-bff
Custom scopes and API audience
When your API validates access tokens:
1. Create client scope (optional)
Client scopes → Create
Name:
orders:readInclude in token scope: ON
Assign to orders-web under Client scopes tab.
2. Create resource server client (API)
Some teams use a separate audience client or Keycloak authorization services. Minimal approach:
API validates JWTs minted by the same realm
Check
scopeclaim containsorders:readOr map client roles to service permissions
3. Spring resource server (snippet)
For a stateless API module:
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8081/realms/oauth-demo
http.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders:read")
.anyRequest().authenticated()
).oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
Client roles in tokens
The manual steps above create app-user on orders-web and assign it to alice. The imported realm JSON does the same under roles.client and users[].clientRoles.
Assigned client roles show up under resource_access.<client-id>.roles in the access token by default. Keycloak does not always copy them into the ID token unless you enable Add to ID token on the client-roles protocol mapper (the imported realm JSON does this for both orders-web and spa-bff). The plain Java demo checks the access token; Spring maps from the ID token — enable the mapper if protected routes return 403 after login.
Logout (complete the story)
The demo repo configures RP-initiated OIDC logout in SecurityConfig:
@Bean
LogoutSuccessHandler oidcLogoutSuccessHandler(
ClientRegistrationRepository clientRegistrationRepository) {
OidcClientInitiatedLogoutSuccessHandler handler =
new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);
handler.setPostLogoutRedirectUri("{baseUrl}/");
return handler;
}
Wire it on the filter chain with .logout(logout -> logout.logoutSuccessHandler(oidcLogoutSuccessHandler)).
Keycloak must allow the post-logout redirect URI on the orders-web client (http://localhost:8080/). The imported realm JSON sets this via the client attribute post.logout.redirect.uris.
Under the hood, Spring redirects the browser to:
http://localhost:8081/realms/oauth-demo/protocol/openid-connect/logout
?client_id=orders-web
&post_logout_redirect_uri=http://localhost:8080/
&id_token_hint=...
Without this, Alice logs out of your app but her Keycloak SSO session remains.
Export realm for reproducibility
The demo repo already ships modules/keycloak/realms/oauth-demo-realm.json. To regenerate after UI changes:
Realm settings → Action → Partial export (or use Keycloak Terraform provider for teams).
Check into git under modules/keycloak/realms/ — exclude production secrets; inject via env or CI. The imported demo file includes a fixed local secret for convenience; treat that as dev-only.
Keycloak vs Okta (same integration, different ops)
| Task | Keycloak | Okta |
|---|---|---|
| Create app | Clients → Create | Applications → App integrations |
| Issuer | /realms/{realm} |
https://{org}.okta.com/oauth2/default |
| Get secret | Credentials tab | Client secret in app settings |
| Local dev | Docker on laptop | Okta developer org (cloud) |
Spring application.properties structure is identical; only URLs and IDs change.
What Keycloak still does not do
Even with a working realm:
Your business authorization rules
Session storage scaling (Redis, JDBC)
Production HA, backups, upgrades
User onboarding emails and product UX
Re-read the identity providers post before go-live.
Go deeper
Identity brokering
Keycloak can front Google or Azure AD — users still see Keycloak realm URLs; brokers handle federation.
Organizations and multi-tenancy
Multiple realms vs single realm with attributes — choose based on isolation requirements.
Harderning for production
startwith optimized profile, PostgreSQL backendReverse proxy TLS termination
Disable admin console on public internet
Brute force detection enabled (Realm settings → Security defenses)
What you should know after this post
[ ] You can run Keycloak locally and create confidential OIDC clients with client roles
[ ] Thymeleaf and BFF apps log in via discovered OIDC endpoints
[ ] You know how to assign client roles (e.g.
app-user) and map them in Spring[ ] You know how to add scopes and resource server JWT validation
[ ] You have a path to export realm config and plan production hardening