Adding a login button to a Spring application is easy. Understanding what happens after someone clicks it takes a little more work. Who checks the password? Why are there several tokens? Why can a logged-in user still receive a 403? And why does an API need different security settings from a web page?
This guide builds a small invoice portal with Spring Boot and Keycloak, then tests the boundaries around it. You will implement browser login with OpenID Connect, protect an API with signed access tokens, map a narrowly defined API role, and coordinate logout. Along the way, we connect the configuration to UML diagrams and a BPMN process so that the behavior is understandable before it becomes a troubleshooting exercise.
You need basic Java and HTTP knowledge, Java 21, Maven 3.9 or newer, Docker Compose, and a browser. Python 3 is needed only for the optional automated live checks. If Spring itself is new to you, start with our Spring Boot beginner guide. The downloadable lab contains the complete project; the snippets below explain its important parts.
Why this topic matters now
For Homann Software's focus on Java engineering and explicit architectural responsibilities, identity is a useful next step after building a first REST API. It also has a current connection to enterprise automation: services and agents increasingly need an accountable identity and limited access to business systems.
The reviewed October releases show that direction. Keycloak 26.8.0, released on October 1, includes SCIM provisioning, supported stateless multi-cluster operation, and token-exchange delegation capabilities for agents and automation; its verifiable-credential issuance support is marked preview. These are release-specific developments, not features required by this tutorial. Our engineering interpretation is that identity infrastructure is expanding beyond interactive login. A sound OIDC foundation remains useful across those applications.
For this lab, we use Spring Boot 4.1.2 and Keycloak 26.8.1. Spring's current stable version listing identifies Boot 4.1.2, while the Keycloak 26.8.1 release announcement, dated October 13, documents security corrections. Research and testing were performed on October 28, 2026. Version numbers are a reproducibility baseline, not a promise that these will remain the latest releases.
One older tutorial pattern deserves particular attention: Keycloak removed its Spring and Spring Boot adapters in Keycloak 25. We use Spring Security's standard OAuth 2.0 and OIDC support rather than an old Keycloak-specific starter or KeycloakWebSecurityConfigurerAdapter.
1. Understand the vocabulary before writing configuration
Authentication answers, “Who is making this request?” Authorization answers, “May that identity perform this operation?” An application can authenticate Alice successfully and still refuse to let her read an invoice.
OpenID Connect Core defines an identity layer over OAuth 2.0. OAuth provides the framework for delegated access; OIDC adds standardized authentication information, including an ID token. Keycloak acts as the OpenID Provider and OAuth authorization server. Spring acts as an OIDC client when users sign in and as a resource server when it accepts API access tokens.
| Term | Meaning in this lab | Common misunderstanding |
|---|---|---|
| Realm | The homann-demo identity configuration in Keycloak |
The administrative master realm should hold your application users |
| Client | A registered application or service, such as spring-portal |
Every client represents a human |
| OIDC client / relying party | Spring's browser-facing portal | The browser itself must hold a client secret |
| Resource server | The /api/** routes in Spring |
Successful portal login automatically grants API access |
| Claim | A statement in a token, such as iss, sub, or aud |
Reading a claim proves that it is authentic |
| Role | An application-specific permission grouping | A role from any client should grant access everywhere |
| Scope | A requested or granted access label; openid requests OIDC |
Every scope is a Keycloak role |
A realm groups identity configuration, users, and clients. Keycloak's Docker getting-started guide explains the separation and reserves master for administration. A realm can be one part of tenant isolation, but creating a realm does not implement your application's customer-data isolation.
Three tokens, three jobs
An ID token tells the OIDC client about the authentication of an end user. Its audience identifies the client for which it was issued. An access token is presented to a resource server to request access. A refresh token can allow a client to obtain new access tokens under the provider's policy; it belongs at the token endpoint, never in an invoice API request.
In this lab, Keycloak issues JWT access tokens. A JWT is a structured, signed representation of claims. Its payload can ordinarily be decoded without a secret: signing protects integrity, not confidentiality. Do not put information in tokens merely because it would be inconvenient to retrieve elsewhere.
Use iss plus sub as the basis for a stable external identity mapping. A username or email address is a display or contact attribute and can change. For a machine token, the subject identifies the service account, not Alice.
2. Decide what we are building
Our fictional use case is deliberately small. Alice opens a portal and signs in. A separately registered worker calls an invoice API. The API exposes one illustrative approved invoice; it does not implement a database, payment processing, or customer-level invoice ownership.
The separation makes two contracts visible:
| Request | Credential expected | Expected behavior |
|---|---|---|
GET / |
None | Public landing page |
GET /portal |
Spring session created through OIDC login | Portal page; otherwise start login |
GET /api/invoices |
Bearer access token with aud=invoice-api and its invoice-reader client role |
Invoice JSON |
POST /logout |
Browser session and valid CSRF token | Local logout, then OIDC provider logout |
The portal and API share one application for learning, but have separate security filter chains. Keycloak owns credentials and identity issuance; Spring owns access enforcement and application behavior.
This portal does not forward a user's token to the API. Its purpose is to demonstrate login separately from machine-to-machine access. A backend-for-frontend that calls downstream APIs on a user's behalf needs an additional authorized-client flow and an explicit decision about the target audience and delegation.
3. Start Keycloak with a reproducible local realm
Unzip the example and open a terminal in examples. The supplied keycloak/homann-demo-realm.json creates the teaching configuration. Its generator, create_realm.py, makes the settings inspectable and repeatable.
services:
keycloak:
image: quay.io/keycloak/keycloak:26.8.1
command: ["start-dev", "--import-realm"]
ports: ["127.0.0.1:18081:8080"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: local-admin-only
volumes:
- ./keycloak:/opt/keycloak/data/import:ro
docker compose -p homann-oidc-20261028 up -d
docker compose -p homann-oidc-20261028 logs --tail 30
Wait for Keycloak to finish importing homann-demo and report that it has started. You can inspect the administration console at http://127.0.0.1:18081/admin/ with admin / local-admin-only. These are public, disposable demo credentials. Never reuse them for a deployed system.
The realm supplies Alice (alice / alice-local-only) and these clients:
| Client | Purpose | Important configuration |
|---|---|---|
spring-portal |
Confidential, server-side OIDC client | Standard flow enabled; PKCE S256 required; direct access grants disabled |
invoice-api |
Logical API identity and owner of the invoice-reader client role |
Bearer-only resource client |
reader-worker |
Positive machine-access example | Service account; API audience mapper; invoice reader role |
denied-worker |
Authorization failure example | Correct audience; no invoice reader role |
wrong-audience-worker |
Audience validation failure example | Reader role; token intended for other-api |
The portal secret is local-portal-secret; the worker secrets are local-worker-secret. These are teaching fixtures stored in the download so that a reader can reproduce the example. For a real deployment, inject managed secrets and remove development fallbacks.
The exact portal redirect URI is http://127.0.0.1:8090/login/oauth2/code/keycloak. Its allowed post-logout URI is http://127.0.0.1:8090/. Keep both exact. Do not replace them with a permissive wildcard to work around a mismatch.
The import also configures an audience mapper to add invoice-api to worker access tokens, and an explicit client-role mapper supplies the worker's invoice API roles. The lab avoids the default audience-resolve mapper so that the wrong-audience fixture remains a meaningful negative test. The Keycloak administration guide documents client roles, service accounts, role scopes and audience mappers. In a production realm, restrict role scope mappings explicitly; the worker fixtures use full scope for this isolated demonstration. That setting permits assigned roles into scope; it does not assign new roles by itself.
The container has no persistent database volume. Stopping and removing it discards lab changes. Realm import on startup skips an existing realm, so editing the JSON and restarting the same container does not necessarily replace its configuration.
4. Add the Spring dependencies and issuer configuration
The complete pom.xml pins Boot 4.1.2 and Java 21. The essential starters are:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
The client starter supports interactive login. The resource-server starter supports validating bearer tokens. Thymeleaf renders the small HTML pages, including an escaped username and a logout form.
server:
port: 8090
address: 127.0.0.1
servlet:
session:
cookie:
http-only: true
same-site: lax
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: spring-portal
client-secret: ${PORTAL_CLIENT_SECRET:local-portal-secret}
authorization-grant-type: authorization_code
scope: openid,profile
provider:
keycloak:
issuer-uri: http://127.0.0.1:18081/realms/homann-demo
resourceserver:
jwt:
issuer-uri: http://127.0.0.1:18081/realms/homann-demo
audiences: invoice-api
An issuer URI identifies the realm. It is not the authorization endpoint or the token endpoint. Through Keycloak's OIDC discovery endpoint, available at /realms/homann-demo/.well-known/openid-configuration, Spring obtains protocol metadata and signing-key information.
Use the same host consistently. localhost, 127.0.0.1, a container service name, and a public domain are different issuer strings and can resolve differently. This lab runs Spring on the host and Keycloak in Docker, so both the browser and Spring use 127.0.0.1:18081. Moving Spring into a container changes what loopback means and requires an intentional hostname design.
Start the application only after Keycloak is ready:
mvn -B clean test
mvn spring-boot:run
Open http://127.0.0.1:8090/ and select “Open the portal.” Sign in as Alice. The application keeps its authenticated browser state in a server-side session referenced by a cookie. It does not place tokens in browser JavaScript or local storage.
5. Follow the OIDC login round trip
Simplified successful login. The code travels through the browser; the token exchange occurs between Spring and Keycloak. Discovery, error paths and optional UserInfo requests are omitted for readability.
Spring's OAuth2 Login configuration supplies the protocol machinery. The portal's registration ID, keycloak, appears in two important local routes: /oauth2/authorization/keycloak initiates login, and /login/oauth2/code/keycloak receives the callback.
Three protections are easy to confuse. state correlates the response with the pending authorization request. The OIDC nonce links the returned ID token to that authentication attempt. PKCE adds a fresh verifier and its derived challenge, binding authorization-code redemption to the client instance that initiated the flow. None replaces TLS or correct client authentication.
The OAuth security best current practice, RFC 9700, supports authorization-code flows with PKCE and rejects the resource owner password credentials grant. We therefore keep Keycloak direct access grants disabled and avoid examples that collect Alice's password in the Spring application. The password entry belongs on Keycloak's login page.
Our browser filter chain explicitly enables PKCE even though this is a confidential server-side client:
@Bean
SecurityFilterChain browser(HttpSecurity http,
ClientRegistrationRepository registrations) throws Exception {
var resolver = new DefaultOAuth2AuthorizationRequestResolver(
registrations, "/oauth2/authorization");
resolver.setAuthorizationRequestCustomizer(
OAuth2AuthorizationRequestCustomizers.withPkce());
var logout = new OidcClientInitiatedLogoutSuccessHandler(registrations);
logout.setPostLogoutRedirectUri("{baseUrl}/");
return http.authorizeHttpRequests(a -> a
.requestMatchers("/", "/error").permitAll()
.anyRequest().authenticated())
.oauth2Login(o -> o.authorizationEndpoint(e ->
e.authorizationRequestResolver(resolver)))
.csrf(Customizer.withDefaults())
.logout(l -> l.logoutSuccessHandler(logout))
.build();
}
Spring's withPkce() customizer creates the challenge parameters and retains the verifier for the token request. The full class with imports is in the example project. You do not need to implement token parsing, callback correlation, or the code exchange yourself.
The controller receives the authenticated OidcUser; it does not see Alice's password:
@GetMapping("/portal")
String portal(@AuthenticationPrincipal OidcUser user, Model model) {
model.addAttribute("name", user.getPreferredUsername());
return "portal";
}
Render that attribute with th:text, which escapes the value. Avoid treating identity-provider profile text as trusted HTML. The lab tests a name containing angle brackets to make that distinction observable.
6. Protect an API with its own security contract
An API caller generally needs a 401 response when it lacks valid credentials, rather than an HTML login redirect. It should also be unable to reuse the portal session as an alternative API credential.
Our first filter chain matches /api/**. The @Order(1) annotation makes this specific chain run before the browser catch-all. Within that chain, session creation is stateless, only bearer JWT authentication is configured, and unrecognized API paths are denied.
@Bean
@Order(1)
SecurityFilterChain api(HttpSecurity http) throws Exception {
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(
jwt -> apiAuthorities(jwt.getClaims()));
return http.securityMatcher("/api/**")
.sessionManagement(s ->
s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(a -> a
.requestMatchers("/api/invoices")
.hasAuthority("ROLE_invoice-reader")
.anyRequest().denyAll())
.oauth2ResourceServer(o -> o.jwt(j ->
j.jwtAuthenticationConverter(converter)))
.build();
}
CSRF is disabled only for this bearer-only chain. The browser chain keeps it enabled. This choice depends on the credential transport: if you later accept automatically attached cookies or other ambient credentials on API routes, revisit the decision. Setting an application to “stateless” is not, by itself, a general solution to CSRF.
Validate the intended recipient as well as the signature
Spring's JWT resource-server support verifies signatures using the provider's public keys, validates timestamps and the configured issuer, and supports Boot's audiences setting. The lab sets that audience to invoice-api.
A genuine token from your Keycloak realm can still be unsuitable for this API. If it was issued for other-api, accepting it would cross an unintended boundary. A successful JWT decode or a recognizable username does not establish suitability.
Similarly, do not send the portal's ID token to the API. In this configuration, its client audience differs from the API audience and it is rejected. The general rule is to use the token type and validation policy defined for the receiving endpoint; JWT syntax alone does not identify its intended use.
Map only the API role you actually recognize
The worker's role appears in the Keycloak access token under resource_access.invoice-api.roles. It does not automatically become a Spring authority. The lab uses a deliberately small converter:
static Collection<GrantedAuthority> apiAuthorities(Map<String, Object> claims) {
if (!(claims.get("resource_access") instanceof Map<?, ?> resources)
|| !(resources.get("invoice-api") instanceof Map<?, ?> api)
|| !(api.get("roles") instanceof Collection<?> roles)) {
return List.of();
}
return roles.stream().filter("invoice-reader"::equals)
.map(r -> (GrantedAuthority)
new SimpleGrantedAuthority("ROLE_invoice-reader"))
.distinct().toList();
}
This is our application policy, not a universal Keycloak role-mapping recipe. The converter recognizes one role belonging to one client. Roles on other-api, unknown values, missing claims and malformed role structures grant no authority. It also replaces Spring's default scope-to-authority conversion; this lab does not simultaneously use SCOPE_... rules.
A simplified, non-executable BPMN process for the API boundary. Authentication failure ends with 401; a valid identity without the required permission ends with 403.
The diagram is accompanied by an editable .bpmn process model and UML .puml sources in the package. It documents the intended behavior; it is not an executable authorization engine.
7. Call the API as a machine
Client credentials authenticate the worker as itself. There is no human login and no implied permission to act on Alice's behalf. The example reader service account is assigned invoice-api's invoice-reader role, and its access token includes the API audience.
On PowerShell, request a token and use it without printing its contents:
$issuer = 'http://127.0.0.1:18081/realms/homann-demo'
$workerToken = Invoke-RestMethod -Method Post `
-Uri "$issuer/protocol/openid-connect/token" `
-Body @{
grant_type = 'client_credentials'
client_id = 'reader-worker'
client_secret = 'local-worker-secret'
}
Invoke-RestMethod -Uri 'http://127.0.0.1:8090/api/invoices' `
-Headers @{ Authorization = "Bearer $($workerToken.access_token)" }
The response contains INV-1001 with status APPROVED. The automated live checker exercises the same token and API endpoints through HTTP. Repeat with denied-worker to receive 403, or wrong-audience-worker to receive 401. These negative examples are as valuable as the successful one: they show that the API is enforcing both the recipient and permission contracts.
Do not paste bearer tokens into shared logs or public online JWT decoders. Anyone holding a usable bearer token may be able to exercise its granted access. For local inspection, decode a token offline and treat the decoded claims as unverified until the receiving application validates them.
8. Keep business authorization inside the application
The role check is only the outer boundary. A real invoice service might additionally require that an invoice belongs to the authenticated customer's organization, is in an allowed state, and falls within a business-defined approval limit. A role such as invoice-reader cannot answer all of those questions.
For a real application, define the use case first: “An authenticated customer representative may read invoices belonging to their own customer account.” Map the verified external identity to an internal account, retrieve the invoice under that account boundary, and test attempts to access another customer's identifier. Derive the account from trusted application state rather than a freely supplied query parameter.
Keep credential management with Keycloak and invoice ownership rules with the invoice domain. This preserves responsibilities and makes changes reviewable: a new identity provider should not require rewriting invoice state transitions, and a changed invoice policy should not require rebuilding the login system.
The teaching controller intentionally returns one fixed record. It demonstrates the security boundary, not object-level authorization or a production-ready invoice service.
9. Implement logout as a complete journey
There are two sessions to think about: the Spring application session and the Keycloak SSO session. Clearing only the first can leave Keycloak ready to sign the user back in without asking for credentials.
The portal uses Spring's OIDC client-initiated logout support. Its logout success handler redirects to Keycloak's discovered end-session endpoint and then back to the registered homepage.
<form th:action="@{/logout}" method="post">
<button type="submit">Sign out</button>
</form>
With Thymeleaf and Spring Security integration, the rendered POST form includes the CSRF token. Spring's logout architecture clears local authentication and invalidates the session. Its CSRF protection remains active for browser requests. Our tests verify that a logout POST without CSRF is refused.
Logout also does not instantly erase every issued JWT from the world. A resource server validating a self-contained access token locally can continue accepting it until expiration unless additional revocation or introspection mechanisms are designed into the system. Choose token lifetimes, revocation requirements and provider-session handling together. This lab implements the interactive logout journey, not global token revocation.
10. Test the boundaries, not just the greeting
The example includes two complementary test layers. Neither should be confused with a full production security assessment.
The Maven suite runs 18 Spring integration tests. It starts a local discovery/JWKS endpoint, signs test JWTs with RSA, and sends bearer headers through Spring's actual filter chain and decoder. The negative tests cover wrong issuer, wrong audience, expiration beyond clock skew, forged signature, malformed token, missing role, a role belonging to a different client and malformed role structure. Other checks cover public access, portal redirection, escaped profile text, PKCE/state/nonce, CSRF logout enforcement and unknown API routes.
For example, the signature test signs a token with a different private key while retaining the advertised key ID. Spring must reject it:
@Test
void forgedSignatureIs401() throws Exception {
mvc.perform(get("/api/invoices").header("Authorization", "Bearer " +
token(base, "invoice-api", Instant.now().plusSeconds(300),
roles(), new RSAKeyGenerator(2048).generate())))
.andExpect(status().isUnauthorized());
}
The helper methods and imports are in SecurityIntegrationTest.java. This test does not use jwt() to inject an already-authenticated principal. Spring's OAuth testing helpers are useful for controller tests, but a mocked security context alone does not prove signature, issuer or audience validation. We use oidcLogin() only where the test concerns the rendered portal principal.
The second layer, live_smoke.py, runs against the real Keycloak 26.8.1 container and the real Spring application. It obtains service-account tokens, checks API outcomes, performs the browser-style authorization-code round trip with cookies, follows the successful callback, and completes provider logout. It also verifies that a portal session alone cannot authenticate the API. Tokens, codes and cookies are not written to the evidence files. The test client's cookie policy narrowly emulates browser loopback trust for Keycloak's Secure cookies on the exact local HTTP origin; it does not change the application's or provider's cookie settings.
python live_smoke.py
On the recorded baseline, all 18 Java tests and 22 live HTTP checks passed. The PowerShell API snippet was also executed and returned the expected invoice. These results demonstrate the stated local behavior, including the failure paths; they do not measure security or reliability in an unrelated deployment.
This is an HTTP-level flow test, not a browser-engine test. It does not exercise JavaScript, browser cookie-policy variations, passkeys, MFA, load, key rotation, cluster failover, production TLS or reverse proxies. Keep those as explicit acceptance criteria when the application's scope expands.
11. Diagnose the common failures
| Symptom | Likely explanation | Check first |
|---|---|---|
| App fails during startup | Keycloak is unavailable or discovery cannot be resolved | Wait for the realm import; inspect the exact issuer discovery URL |
| “Invalid redirect URI” | Callback host, port or path differs from registration | Compare the complete URI, including 127.0.0.1 versus localhost |
| API returns 401 for a real token | Wrong issuer, audience, expiry or signature | Validate locally and examine server-side diagnostics without logging raw tokens |
| API returns 403 | Authentication succeeded but the recognized API role is missing | Inspect the service-account assignment and client-role token mapping |
| API returns an HTML login page | The browser filter chain matched the route | Check /api/**, chain order and bearer configuration |
| Browser login works, API call fails | The API deliberately expects a bearer token | Obtain an API access token; the portal cookie is insufficient |
| Logout returns 403 | Missing or invalid CSRF token | Inspect the rendered POST form and session continuity |
| Immediate sign-in after local logout | Provider SSO session is still active | Confirm the end-session redirect and registered post-logout URI |
| Container config edits do not appear | Startup import skipped an existing realm | Recreate only the disposable lab container, or deliberately update the realm |
| Port unexpectedly serves another app | Existing process or IPv4/IPv6 resolution differs | Use the lab's explicit loopback addresses and inspect the listening ports |
CORS and CSRF solve different problems. A redirect-based login is not repaired by allowing every CORS origin. If you later add a separate browser frontend, document its origin, credential transport and API behavior before changing those controls.
12. Move from the lab to an operated system
The lab uses loopback HTTP, start-dev, an embedded development database and known credentials. These choices reduce setup effort for local learning. They are not a deployment template. Keycloak's production configuration guide covers the operational transition.
Before release, define and verify a concrete operating contract:
- Deploy Keycloak in production mode with a supported persistent database, backups and a tested restore procedure.
- Configure stable public HTTPS names, trusted proxy handling and exact redirect/logout URIs. Set secure session cookies when the application is served over HTTPS.
- Inject real client secrets through your deployment's secret-management mechanism; remove the tutorial defaults and isolate administrative access.
- Review service-account assignments and role scope mappings. Use separate clients for separate trust relationships.
- Choose session and token lifetimes, MFA requirements, account recovery and logout behavior from the application's risks and user journeys.
- Test cross-customer invoice access, signing-key rotation, unavailable identity services, expired sessions and recovery paths.
- Record useful authentication and authorization events while redacting credentials, codes, tokens and unnecessary personal data.
- Patch both the identity provider and the Spring dependency stack, and rerun the acceptance tests after upgrades.
These are proposed engineering acceptance criteria. The local test results do not establish that a particular production installation satisfies them.
Where to go next
You now have two concrete, testable paths: a human signs in through OIDC and uses a Spring session, while a worker presents an API access token with an explicit audience and role. The next useful exercise is to add a real invoice ownership rule and its cross-customer denial tests, then trace that rule from the use case through the domain service to the API response.
That is the foundation worth keeping as the system grows: a clear owner for identity, a clear owner for business decisions, and evidence that each boundary behaves as intended.
Example project: Download the complete example project
The download includes the complete Java project, realm import, local Compose configuration, automated tests and editable diagram sources. All accounts, credentials and invoice data are fictional teaching fixtures.