Skip to main content

Command Palette

Search for a command to run...

OAuth with Spring Security

Updated
7 min readView as Markdown

GitHub: github.com/mnafshin/oauth


What we are building

A minimal server-rendered Spring Boot web app that:

  1. Redirects unauthenticated users to Keycloak

  2. Completes Authorization Code + PKCE (Spring handles PKCE automatically)

  3. Establishes a session and shows the logged-in user in Thymeleaf templates

  4. Enforces business authorization on /dashboard via 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-demomodules/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 use app-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 info

  • spring-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_challenge

  • Sends state and validates it on callback

  • Exchanges code at /token with client secret (confidential client)

  • Builds an OAuth2AuthenticationToken with 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/.

  1. Open http://localhost:8080/dashboard

  2. Redirect to Keycloak login (user alice / password if using the imported realm)

  3. After login, land on dashboard with username and email — alice has the orders-web client role app-user required 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-uri and 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)