Spring OAuth2 Resource Server JWT setup
Setup Spring Boot sebagai OAuth2 Resource Server — verifikasi JWT dari Keycloak / Auth0 / IDP internal, role-based access control.
Dipublikasikan 7 Juli 2026
Spring Boot legacy biasanya pakai filter custom + parse JWT manual — repot dan rawan bug. Spring Security 6 punya OAuth2 Resource Server module yang clean. Tinggal config issuer URI, JWT divalidasi otomatis. Snippet ini setup lengkap dengan role mapping dari Keycloak realm role.
Kode
// pom.xml dependencies
// <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>
// SecurityConfig.java
package id.kodekarawaci.config;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
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.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// Disable CSRF — JWT bearer tidak vulnerable CSRF
.csrf(AbstractHttpConfigurer::disable)
// Stateless session — token di header, tidak ada server session
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/internal/**").hasAuthority("SCOPE_internal")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
);
return http.build();
}
/**
* Convert JWT claim -> Spring authority.
* Default Spring map "scope" claim. Kita extend untuk:
* - realm_access.roles (Keycloak realm role)
* - resource_access.{client_id}.roles (Keycloak client role)
*/
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter scopeConverter = new JwtGrantedAuthoritiesConverter();
// Default prefix "SCOPE_" untuk scope claim
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
// Merge scope authorities + realm role authorities
Collection<GrantedAuthority> authorities = scopeConverter.convert(jwt);
return Stream.concat(
authorities.stream(),
extractKeycloakRoles(jwt).stream()
).toList();
});
// Subject jadi principal name (default sub claim)
converter.setPrincipalClaimName("preferred_username");
return converter;
}
@SuppressWarnings("unchecked")
private List<GrantedAuthority> extractKeycloakRoles(Jwt jwt) {
// realm_access.roles -> ROLE_xxx
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null) return List.of();
Object roles = realmAccess.get("roles");
if (!(roles instanceof List<?> list)) return List.of();
return list.stream()
.filter(String.class::isInstance)
.map(String.class::cast)
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.map(GrantedAuthority.class::cast)
.toList();
}
}
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
# Keycloak realm — issuer untuk auto-config JWK
issuer-uri: https://auth.tangerang.id/realms/tokopedia
# Override jwk-set-uri kalau tidak pakai auto-discovery
# jwk-set-uri: https://auth.tangerang.id/realms/tokopedia/protocol/openid-connect/certs
jwt:
audience: api-tokopedia # Optional: validate aud claim manual
# Cache JWK 1 jam (default 5 menit) — kurangi load ke Authorization Server
spring.security.oauth2.resourceserver.jwt.cache-duration: 1h
// Sample controller dengan method security
package id.kodekarawaci.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api")
public class SampleController {
/**
* Setiap user authenticated bisa akses.
* Subject (sub claim) di-inject sebagai principal.
*/
@GetMapping("/me")
public Object me(@AuthenticationPrincipal Jwt jwt) {
return new MeResponse(
jwt.getSubject(),
jwt.getClaimAsString("email"),
jwt.getClaimAsString("name"),
jwt.getClaimAsStringList("groups")
);
}
/**
* Hanya user dengan role ADMIN (mapped dari realm_access.roles "admin").
*/
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/produk/{id}")
public void hapusProduk(@PathVariable Long id) {
// ...
}
/**
* Hanya pemilik resource atau admin.
*/
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.name")
@GetMapping("/user/{userId}/order")
public Object userOrders(@PathVariable String userId) {
return List.of();
}
record MeResponse(String sub, String email, String name, List<String> groups) {}
}
Pemakaian
# Get token dari Keycloak (test)
TOKEN=$(curl -s -X POST https://auth.tangerang.id/realms/tokopedia/protocol/openid-connect/token \
-d "grant_type=password" \
-d "client_id=api-tokopedia" \
-d "username=asti@tangerang.id" \
-d "password=secret123" \
| jq -r '.access_token')
# Call API dengan token
curl http://localhost:8080/api/me \
-H "Authorization: Bearer $TOKEN"
# Output:
# {"sub":"f47ac10b-...","email":"asti@tangerang.id","name":"Asti Larasati","groups":["koperasi-12"]}
// Test slice untuk security config
@WebMvcTest(SampleController.class)
@Import(SecurityConfig.class)
class SampleControllerTest {
@Autowired MockMvc mvc;
@Test
void publicEndpoint_tanpaToken_returns200() throws Exception {
mvc.perform(get("/actuator/health")).andExpect(status().isOk());
}
@Test
void protectedEndpoint_tanpaToken_returns401() throws Exception {
mvc.perform(get("/api/me")).andExpect(status().isUnauthorized());
}
@Test
@WithMockJwtAuth(authorities = {"ROLE_USER"}, claims = @OpenIdClaims(sub = "user-123"))
void protectedEndpoint_denganToken_returns200() throws Exception {
mvc.perform(get("/api/me")).andExpect(status().isOk());
}
}
Kapan dipakai
- API microservice dengan SSO terpusat (Keycloak, Auth0, Azure AD).
- Multi-tenant B2B yang user authenticate via enterprise IDP.
- API gateway pattern — token issued by central auth, validated di setiap service.
- Compliance regulated (banking, healthcare) yang butuh standardized auth.
Catatan
- issuer-uri auto-discover JWK endpoint, signing algorithm, dll dari
.well-known/openid-configuration. Lebih simple dari config manual. - cache-duration JWK — production set 1 jam. Auth server downtime tidak langsung impact resource server.
- JwtGrantedAuthoritiesConverter default map “scope” claim. Extend untuk role di claim custom (Keycloak realm_access).
- PreAuthorize SpEL —
authentication.nameadalah subject.principaladalah Jwt object lengkap, bisa akses claim apa pun. - CORS — kalau API call dari browser, set CORS config terpisah. CSRF disable OK karena bearer token.
- Audience validation — tambahkan
OAuth2TokenValidator<Jwt>custom untuk verify aud claim. Token issued untuk service lain tidak boleh accepted.
Refresh token flow di-handle client (frontend/mobile), bukan Resource Server. Resource Server cuma validate access token. Kalau access expired, client refresh dan retry.
# tags
springoauth2jwtauthresource-server
Ditulis oleh Asti Larasati · 7 Juli 2026