Backend

Securing Spring Boot Apps with OAuth2 and Keycloak

Introduction

Modern applications require robust authentication and authorization. Instead of managing passwords and user sessions manually, developers increasingly rely on identity providers like Keycloak. Combined with Spring Boot and OAuth2, Keycloak makes securing APIs and web applications straightforward while providing enterprise features like single sign-on, social login, and multi-factor authentication. In this comprehensive guide, you’ll learn how to set up Keycloak, configure Spring Boot as an OAuth2 resource server, implement role-based access control, and secure your microservices architecture.

What Is OAuth2?

OAuth2 is an industry-standard protocol for authorization. It allows applications to delegate authentication to an identity provider and receive access tokens representing user identity and permissions.

Key Components

  • Authorization Server – Issues tokens (Keycloak)
  • Resource Server – Your API that validates tokens
  • Client Application – Web/mobile app requesting tokens
  • Access Token – JWT granting access to resources
  • Refresh Token – Long-lived token for getting new access tokens

Setting Up Keycloak

Docker Compose Setup

# docker-compose.yml
version: '3.8'
services:
  keycloak:
    image: quay.io/keycloak/keycloak:23.0
    environment:
      - KEYCLOAK_ADMIN=admin
      - KEYCLOAK_ADMIN_PASSWORD=admin
      - KC_DB=postgres
      - KC_DB_URL=jdbc:postgresql://postgres:5432/keycloak
      - KC_DB_USERNAME=keycloak
      - KC_DB_PASSWORD=keycloak
    command: start-dev
    ports:
      - "8180:8080"
    depends_on:
      - postgres

  postgres:
    image: postgres:15-alpine
    environment:
      - POSTGRES_DB=keycloak
      - POSTGRES_USER=keycloak
      - POSTGRES_PASSWORD=keycloak
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:
# Start Keycloak
docker compose up -d

# Access admin console
# http://localhost:8180

Configure Realm and Client

  1. Create a new realm: my-app
  2. Create a client: spring-boot-api
    • Client Protocol: openid-connect
    • Access Type: confidential (for backend)
    • Valid Redirect URIs: http://localhost:8080/*
  3. Create roles: user, admin
  4. Create a test user and assign roles

Spring Boot Configuration

<!-- pom.xml -->
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
</dependencies>
# application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8180/realms/my-app
          jwk-set-uri: http://localhost:8180/realms/my-app/protocol/openid-connect/certs

keycloak:
  realm: my-app
  auth-server-url: http://localhost:8180
  resource: spring-boot-api

Security Configuration

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
            );
        
        return http.build();
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
        grantedAuthoritiesConverter.setAuthoritiesClaimName("realm_access.roles");

        JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
        jwtConverter.setJwtGrantedAuthoritiesConverter(new KeycloakRoleConverter());
        return jwtConverter;
    }
}

Keycloak Role Converter

public class KeycloakRoleConverter implements Converter<Jwt, Collection<GrantedAuthority>> {

    @Override
    public Collection<GrantedAuthority> convert(Jwt jwt) {
        List<GrantedAuthority> authorities = new ArrayList<>();

        // Extract realm roles
        Map<String, Object> realmAccess = jwt.getClaim("realm_access");
        if (realmAccess != null) {
            @SuppressWarnings("unchecked")
            List<String> roles = (List<String>) realmAccess.get("roles");
            if (roles != null) {
                roles.forEach(role -> 
                    authorities.add(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
                );
            }
        }

        // Extract resource (client) roles
        Map<String, Object> resourceAccess = jwt.getClaim("resource_access");
        if (resourceAccess != null) {
            @SuppressWarnings("unchecked")
            Map<String, Object> clientAccess = (Map<String, Object>) resourceAccess.get("spring-boot-api");
            if (clientAccess != null) {
                @SuppressWarnings("unchecked")
                List<String> clientRoles = (List<String>) clientAccess.get("roles");
                if (clientRoles != null) {
                    clientRoles.forEach(role ->
                        authorities.add(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
                    );
                }
            }
        }

        return authorities;
    }
}

Protected Controllers

@RestController
@RequestMapping("/api")
public class ApiController {

    @GetMapping("/public/health")
    public Map<String, String> health() {
        return Map.of("status", "UP");
    }

    @GetMapping("/user/profile")
    @PreAuthorize("hasRole('USER')")
    public Map<String, Object> getUserProfile(@AuthenticationPrincipal Jwt jwt) {
        return Map.of(
            "userId", jwt.getSubject(),
            "email", jwt.getClaim("email"),
            "name", jwt.getClaim("name"),
            "roles", extractRoles(jwt)
        );
    }

    @GetMapping("/admin/users")
    @PreAuthorize("hasRole('ADMIN')")
    public List<Map<String, String>> getAllUsers() {
        // Admin-only endpoint
        return List.of(
            Map.of("id", "1", "name", "User 1"),
            Map.of("id", "2", "name", "User 2")
        );
    }

    @PostMapping("/admin/settings")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<Void> updateSettings(@RequestBody Map<String, Object> settings) {
        // Update system settings
        return ResponseEntity.ok().build();
    }

    private List<String> extractRoles(Jwt jwt) {
        Map<String, Object> realmAccess = jwt.getClaim("realm_access");
        if (realmAccess != null) {
            @SuppressWarnings("unchecked")
            List<String> roles = (List<String>) realmAccess.get("roles");
            return roles != null ? roles : List.of();
        }
        return List.of();
    }
}

Service-to-Service Authentication

For microservice communication, use client credentials flow:

@Configuration
public class WebClientConfig {

    @Value("${keycloak.auth-server-url}")
    private String keycloakUrl;

    @Value("${keycloak.realm}")
    private String realm;

    @Value("${spring.security.oauth2.client.registration.keycloak.client-id}")
    private String clientId;

    @Value("${spring.security.oauth2.client.registration.keycloak.client-secret}")
    private String clientSecret;

    @Bean
    public WebClient webClient() {
        return WebClient.builder()
            .filter(oauth2ClientCredentialsFilter())
            .build();
    }

    private ExchangeFilterFunction oauth2ClientCredentialsFilter() {
        return (request, next) -> getAccessToken()
            .flatMap(token -> {
                ClientRequest newRequest = ClientRequest.from(request)
                    .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
                    .build();
                return next.exchange(newRequest);
            });
    }

    private Mono<String> getAccessToken() {
        return WebClient.create()
            .post()
            .uri(keycloakUrl + "/realms/" + realm + "/protocol/openid-connect/token")
            .contentType(MediaType.APPLICATION_FORM_URLENCODED)
            .body(BodyInserters
                .fromFormData("grant_type", "client_credentials")
                .with("client_id", clientId)
                .with("client_secret", clientSecret))
            .retrieve()
            .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
            .map(response -> (String) response.get("access_token"));
    }
}

Custom Token Validation

@Component
public class CustomJwtValidator implements OAuth2TokenValidator<Jwt> {

    private static final OAuth2Error INVALID_AUDIENCE = 
        new OAuth2Error("invalid_token", "Invalid audience", null);
    
    private static final OAuth2Error TOKEN_EXPIRED = 
        new OAuth2Error("invalid_token", "Token expired", null);

    @Value("${app.jwt.audience}")
    private String expectedAudience;

    @Override
    public OAuth2TokenValidatorResult validate(Jwt jwt) {
        // Validate audience
        List<String> audiences = jwt.getAudience();
        if (!audiences.contains(expectedAudience)) {
            return OAuth2TokenValidatorResult.failure(INVALID_AUDIENCE);
        }

        // Validate expiration with clock skew
        Instant expiration = jwt.getExpiresAt();
        if (expiration != null && Instant.now().isAfter(expiration.plusSeconds(30))) {
            return OAuth2TokenValidatorResult.failure(TOKEN_EXPIRED);
        }

        return OAuth2TokenValidatorResult.success();
    }
}

@Configuration
public class JwtConfig {

    @Bean
    public JwtDecoder jwtDecoder(
            @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
            CustomJwtValidator customValidator) {
        
        NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
        
        OAuth2TokenValidator<Jwt> defaultValidators = JwtValidators.createDefault();
        OAuth2TokenValidator<Jwt> validators = new DelegatingOAuth2TokenValidator<>(
            defaultValidators, customValidator
        );
        
        decoder.setJwtValidator(validators);
        return decoder;
    }
}

Logout and Token Revocation

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    @Value("${keycloak.auth-server-url}")
    private String keycloakUrl;

    @Value("${keycloak.realm}")
    private String realm;

    private final WebClient webClient;

    public AuthController(WebClient.Builder webClientBuilder) {
        this.webClient = webClientBuilder.build();
    }

    @PostMapping("/logout")
    public Mono<ResponseEntity<Void>> logout(
            @RequestHeader("Authorization") String authHeader,
            @RequestBody LogoutRequest request) {
        
        String logoutUrl = keycloakUrl + "/realms/" + realm + "/protocol/openid-connect/logout";
        
        return webClient.post()
            .uri(logoutUrl)
            .contentType(MediaType.APPLICATION_FORM_URLENCODED)
            .body(BodyInserters
                .fromFormData("client_id", request.clientId())
                .with("client_secret", request.clientSecret())
                .with("refresh_token", request.refreshToken()))
            .retrieve()
            .toBodilessEntity()
            .map(response -> ResponseEntity.ok().<Void>build())
            .onErrorReturn(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
    }
}

public record LogoutRequest(String clientId, String clientSecret, String refreshToken) {}

Common Mistakes to Avoid

1. Not Validating Issuer

# Wrong - accepts tokens from any issuer
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: http://localhost:8180/realms/my-app/protocol/openid-connect/certs

# Correct - validates issuer claim
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8180/realms/my-app

2. Hardcoding Role Names

// Wrong - role names scattered in code
@PreAuthorize("hasRole('admin')")

// Correct - use constants
public final class Roles {
    public static final String ADMIN = "ADMIN";
    public static final String USER = "USER";
}

@PreAuthorize("hasRole(T(com.example.Roles).ADMIN)")

3. Exposing Client Secrets

# Wrong - secrets in application.yml
keycloak:
  credentials:
    secret: my-secret-key

# Correct - use environment variables
keycloak:
  credentials:
    secret: ${KEYCLOAK_CLIENT_SECRET}

4. Missing CORS Configuration

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.setAllowedOrigins(List.of("http://localhost:3000"));
    configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
    configuration.setAllowedHeaders(List.of("*"));
    configuration.setAllowCredentials(true);
    
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", configuration);
    return source;
}

Final Thoughts

Securing applications with Spring Boot, OAuth2, and Keycloak provides enterprise-grade authentication without building your own identity system. Keycloak handles user management, SSO, and token issuance while Spring Security validates tokens and enforces authorization rules. Start with basic JWT validation, add role-based access control, then expand to service-to-service authentication as your architecture grows. This combination scales from simple APIs to complex microservices architectures.

To continue learning about Spring security, read Advanced API Security: Scopes, Claims and Token Revocation and API Routing with Spring Cloud Gateway. For official documentation, visit the Keycloak Documentation and the Spring Security OAuth2 Reference.

Leave a Comment