Skip to main content

Command Palette

Search for a command to run...

Keycloak Integration End to End

Updated
8 min readView as Markdown

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-web on port 8080)

  • Plain Java (orders-web on port 8082)

  • Shared SPA BFF (spa-bff on 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

  1. Admin console → Create realm

  2. Name: oauth-demo

  3. Enabled: 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 secretexport 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: alice

  • Email verified: ON
    Credentials tab → set password (e.g. password) → Temporary: OFF
    Role mapping → Client roles → select orders-web → assign app-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_endpoint

  • token_endpoint

  • jwks_uri

  • issuer = 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:read

  • Include 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 scope claim contains orders:read

  • Or 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

  • start with optimized profile, PostgreSQL backend

  • Reverse 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