OAuth with Spring Security
GitHub: github.com/mnafshin/oauth
What we are building
A minimal server-rendered Spring Boot web app that:
Redirects unauthenticated users to Keycloak
Completes Authorization Code + PKCE (Spring handles PKCE automatically)
Establishes a session and shows the logged-in user in Thymeleaf templates
Enforces business authorization on
/dashboardvia Keycloak client roles and@PreAuthorize
This uses the OAuth 2.0 Login support in Spring Security — the right default when your UI is rendered on the server (HTML forms, CSRF tokens, same-origin session cookies).
Building a React or Angular SPA? That is a different integration pattern — tokens stay on a backend-for-frontend (BFF). This post keeps Thymeleaf deliberately simple so you can compare both approaches in the same repo.
Repo: oauth-demo → modules/spring-boot
Prerequisites
Java 21+ (the demo repo targets Java 25 with Spring Boot 4.1 — any 21+ JDK works)
Docker (for local Keycloak) — full Keycloak setup is covered in the Keycloak integration post
Keycloak realm with a client configured (we use
orders-web) and a client role for app authorization (we useapp-user)
Dependencies
Add to modules/spring-boot/build.gradle:
plugins {
id 'java'
id 'org.springframework.boot' version '4.1.0'
id 'io.spring.dependency-management' version '1.1.7'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testImplementation 'org.springframework.security:spring-security-test'
}
spring-boot-starter-oauth2-client— OAuth2/OIDC client support, PKCE, JWT parsing for user infospring-boot-starter-thymeleaf— server-rendered views for/and/dashboard
Configuration
src/main/resources/application.properties:
spring.application.name=oauth-demo
# Keycloak OIDC (see the Keycloak integration post for realm setup)
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.registration.keycloak.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.keycloak.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8081/realms/oauth-demo
Important: issuer-uri triggers auto-discovery of authorize, token, and JWKS endpoints from:
http://localhost:8081/realms/oauth-demo/.well-known/openid-configuration
Never hard-code those URLs when Spring can discover them.
Security filter chain
SecurityConfig wires OAuth2 login, OIDC logout, method security, and a mapper that turns Keycloak client roles into Spring authorities. Application roles belong on the client (orders-web), not the realm — Keycloak puts them in resource_access.orders-web.roles on the ID token.
package info.mnafshin.oauth_demo;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, LogoutSuccessHandler oidcLogoutSuccessHandler)
throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.defaultSuccessUrl("/dashboard", true)
.userInfoEndpoint(userInfo -> userInfo
.userAuthoritiesMapper(keycloakRolesMapper())
)
)
.logout(logout -> logout
.logoutSuccessHandler(oidcLogoutSuccessHandler)
);
return http.build();
}
@Bean
LogoutSuccessHandler oidcLogoutSuccessHandler(ClientRegistrationRepository clientRegistrationRepository) {
OidcClientInitiatedLogoutSuccessHandler handler =
new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);
handler.setPostLogoutRedirectUri("{baseUrl}/");
return handler;
}
@Bean
GrantedAuthoritiesMapper keycloakRolesMapper() {
return authorities -> authorities.stream()
.flatMap(authority -> {
if (authority instanceof OidcUserAuthority oidc) {
@SuppressWarnings("unchecked")
Map<String, Object> resourceAccess = oidc.getIdToken().getClaim("resource_access");
if (resourceAccess == null) {
return Stream.of(authority);
}
@SuppressWarnings("unchecked")
Map<String, Object> clientAccess =
(Map<String, Object>) resourceAccess.get("orders-web");
if (clientAccess == null) {
return Stream.of(authority);
}
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) clientAccess.get("roles");
if (roles == null) {
return Stream.of(authority);
}
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role));
}
return Stream.of(authority);
})
.collect(Collectors.toSet());
}
}
What Spring does for you:
Generates PKCE
code_verifier/code_challengeSends
stateand validates it on callbackExchanges
codeat/tokenwith client secret (confidential client)Builds an
OAuth2AuthenticationTokenwith user attributes from ID token / userinfo
Controller
@PreAuthorize on /dashboard checks the mapped client role. The imported realm assigns app-user on orders-web to alice (see the Keycloak post).
package info.mnafshin.oauth_demo;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HomeController {
@GetMapping("/")
public String home() {
return "home";
}
@GetMapping("/dashboard")
@PreAuthorize("hasRole('app-user')")
public String dashboard(@AuthenticationPrincipal OAuth2User principal, Model model) {
model.addAttribute("name", principal.getAttribute("preferred_username"));
model.addAttribute("email", principal.getAttribute("email"));
return "dashboard";
}
}
For REST APIs (no server-side session), use oauth2ResourceServer with JWT instead — same series, different starter.
Templates
The controller returns view names; add Thymeleaf HTML under src/main/resources/templates/.
home.html:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>OAuth Demo</title>
</head>
<body>
<h1>OAuth Demo</h1>
<p>A minimal Spring Security OAuth 2.0 login with Keycloak.</p>
<p><a th:href="@{/dashboard}">Go to dashboard</a></p>
</body>
</html>
dashboard.html — include a CSRF token on the logout form (Spring Security enables CSRF by default):
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Dashboard</title>
</head>
<body>
<h1>Dashboard</h1>
<p>Hello, <span th:text="${name}">user</span>!</p>
<p>Email: <span th:text="${email}">email</span></p>
<form th:action="@{/logout}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<button type="submit">Logout</button>
</form>
</body>
</html>
Without the hidden _csrf field, POST /logout returns 403 Forbidden. With the OIDC logout handler below, logout also ends the Keycloak SSO session and returns the browser to /.
What Spring Security does not do
Even with oauth2Login working:
| Still your job | Why |
|---|---|
| Map roles from token claims | Spring loads authorities from resource_access only if you add a mapper (shown above) |
| Protect downstream APIs | Resource servers need separate JWT validation |
| Store refresh tokens safely | Configure OAuth2AuthorizedClientService / JDBC for persistence |
| Logout from Keycloak | Use OidcClientInitiatedLogoutSuccessHandler + valid post-logout redirect URI on the client |
| Business authorization rules | You define which roles/scopes guard which endpoints (@PreAuthorize, etc.) |
See the identity providers post — the IdP is not the complete solution.
Run locally
From the repo root:
# Start Keycloak with imported oauth-demo realm (see Keycloak post)
make start-keycloak
# or: docker compose -f modules/keycloak/docker-compose.yml up -d
export KEYCLOAK_CLIENT_SECRET=orders-web-secret
make start-spring-boot
# or: ./gradlew :spring-boot:bootRun
Each module also has its own Makefile — run make start from modules/spring-boot/.
Open
http://localhost:8080/dashboardRedirect to Keycloak login (user
alice/passwordif using the imported realm)After login, land on dashboard with username and email —
alicehas theorders-webclient roleapp-userrequired by@PreAuthorize
Go deeper
OAuth2 Authorized Client for calling APIs
To call a protected API with the user's access token:
@GetMapping("/api/proxy/orders")
OAuth2AuthorizedClient authorizedClient(
@RegisteredOAuth2AuthorizedClient("keycloak") OAuth2AuthorizedClient client) {
String accessToken = client.getAccessToken().getTokenValue();
// Use WebClient with Bearer header
}
Register an OAuth2AuthorizedClientManager bean (Spring Security 6+ — @EnableOAuth2Client was removed).
Multiple IdPs
Add another registration (e.g. google, okta) — each gets /login/oauth2/code/{registrationId}.
Testing
MockMvc + oauth2Login() from spring-security-test exercises controllers without a live IdP. Because /dashboard uses @PreAuthorize("hasRole('app-user')"), tests must supply ROLE_app-user — login alone is not enough:
@SpringBootTest
@AutoConfigureMockMvc
class HomeControllerTest {
@Autowired MockMvc mockMvc;
@Test
void dashboardRequiresAuthentication() throws Exception {
mockMvc.perform(get("/dashboard"))
.andExpect(status().is3xxRedirection());
}
@Test
void dashboardRequiresAppUserRole() throws Exception {
mockMvc.perform(get("/dashboard")
.with(oauth2Login()
.attributes(attrs -> attrs.put("preferred_username", "testuser"))))
.andExpect(status().isForbidden());
}
@Test
void dashboardShowsAuthenticatedUser() throws Exception {
mockMvc.perform(get("/dashboard")
.with(oauth2Login()
.authorities(new SimpleGrantedAuthority("ROLE_app-user"))
.attributes(attrs -> {
attrs.put("preferred_username", "testuser");
attrs.put("email", "test@example.com");
})))
.andExpect(status().isOk())
.andExpect(content().string(containsString("testuser")));
}
@Test
void logoutWithoutCsrfTokenIsRejected() throws Exception {
mockMvc.perform(post("/logout").with(oauth2Login()))
.andExpect(status().isForbidden());
}
}
For end-to-end login against Keycloak, use Testcontainers or the docker-compose setup from the Keycloak post.
What you should know after this post
[ ] You can configure
issuer-uriand a Keycloak client in Spring Boot[ ] You know what Spring auto-handles (PKCE, state, code exchange)
[ ] You can map Keycloak client roles to Spring authorities and guard endpoints with
@PreAuthorize[ ] You know what remains manual (API protection as resource server, refresh token storage)