Spring Security 6: Configuração do OAuth2 Resource Server
Guia prático para configurar um OAuth2 Resource Server com Spring Security 6. Validação JWT, configuração do issuer, gerenciamento de scopes e integração com Keycloak.

Um OAuth2 Resource Server protege APIs validando tokens JWT emitidos por um Authorization Server externo como Keycloak, Auth0 ou Okta. Diferentemente da autenticação JWT customizada onde a aplicação gera seus próprios tokens, o Resource Server delega completamente o gerenciamento de identidade a um Identity Provider (IdP) dedicado.
Um Resource Server nunca gera tokens. Apenas os valida. Essa separação de responsabilidades fortalece a segurança e simplifica a arquitetura ao centralizar o gerenciamento de identidade.
Arquitetura do OAuth2 Resource Server
O fluxo OAuth2 envolve três atores principais. O cliente (aplicação frontend ou móvel) obtém um access token do Authorization Server. Em seguida inclui esse token em cada requisição ao Resource Server. O Resource Server valida o token verificando sua assinatura através da chave pública do Authorization Server.
Essa arquitetura oferece várias vantagens. O Resource Server permanece sem estado já que todas as informações necessárias estão contidas no token JWT. A validação ocorre sem chamadas de rede ao IdP graças à verificação de assinatura assimétrica. Múltiplas APIs podem compartilhar o mesmo Authorization Server, simplificando o gerenciamento de usuários.
┌──────────────┐ 1. Login ┌─────────────────────┐
│ Client │ ───────────────► │ Authorization │
│ (SPA/App) │ │ Server (Keycloak) │
│ │ ◄─────────────────│ │
└──────────────┘ 2. JWT Token └─────────────────────┘
│ │
│ 3. Request + Bearer Token │
▼ ▼
┌──────────────────────┐ ┌─────────────────────┐
│ Resource Server │ ◄─────── │ JWKS Endpoint │
│ (Spring Boot API) │ 4. Public Keys (cached) │
└──────────────────────┘ └─────────────────────┘O Resource Server faz o download das chaves públicas na inicialização e as armazena em cache, evitando chamadas de rede para cada requisição.
Dependências Maven para OAuth2 Resource Server
A configuração de um Resource Server requer duas dependências específicas. O starter oauth2-resource-server fornece a infraestrutura base, enquanto oauth2-jose contém as classes para decodificar e validar os JWTs.
<!-- pom.xml -->
<dependencies>
<!-- OAuth2 Resource Server support -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- Spring Web for REST endpoints -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- DTO validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>Dependências adicionais como JJWT não são necessárias. O Spring Security utiliza a biblioteca Nimbus JOSE+JWT incluída em spring-security-oauth2-jose para o tratamento de tokens.
Configuração básica com issuer-uri
A configuração mínima do Resource Server requer apenas duas linhas. O Spring Security descobre automaticamente os endpoints do IdP através do protocolo OpenID Connect Discovery.
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
# URI do Authorization Server
# Spring obtém automaticamente as chaves públicas via /.well-known/openid-configuration
issuer-uri: https://keycloak.example.com/realms/myrealmA partir do issuer-uri, o Spring Security faz uma requisição a {issuer-uri}/.well-known/openid-configuration para obter os metadados do Authorization Server. Em seguida extrai a URL do JWKS (JSON Web Key Set) que contém as chaves públicas usadas para validar as assinaturas dos tokens.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
// Disable CSRF for stateless APIs
.csrf(csrf -> csrf.disable())
// Authorization rules
.authorizeHttpRequests(auth -> auth
// Public endpoints
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
// All other requests require a valid token
.anyRequest().authenticated()
)
// Enable OAuth2 JWT validation
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
)
// Stateless mode required for Resource Servers
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.build();
}
}Essa configuração é suficiente para proteger uma API. O Spring Security valida automaticamente a assinatura do token, verifica os timestamps (exp, nbf, iat) e garante que o claim iss corresponda ao issuer-uri configurado.
Por padrão, a aplicação não inicia se o Authorization Server estiver indisponível. Para permitir uma inicialização independente, deve-se configurar jwk-set-uri explicitamente em vez de issuer-uri.
Configuração avançada com jwk-set-uri
Quando o Authorization Server não suporta OpenID Connect Discovery ou quando a aplicação deve iniciar sem dependência de rede ao IdP, a configuração explícita do JWKS torna-se preferível.
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
# Direct URL to JWKS (public keys)
jwk-set-uri: https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs
# Expected value of the "iss" claim in tokens
issuer-uri: https://keycloak.example.com/realms/myrealm
# Allowed audiences ("aud" claim)
audiences:
- my-api
- accountEssa abordagem oferece mais controle. A validação de audiência impede o uso de tokens destinados a outros serviços. Um token emitido para a aplicação frontend-app será rejeitado se a API aceitar apenas as audiências my-api e account.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
private String issuerUri;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
// Custom JWT configuration
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.build();
}
// Custom converter to extract authorities from the token
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
// SCOPE_ prefix for authorities (e.g., SCOPE_read, SCOPE_write)
grantedAuthoritiesConverter.setAuthorityPrefix("SCOPE_");
// Claim containing scopes (OAuth2 standard)
grantedAuthoritiesConverter.setAuthoritiesClaimName("scope");
JwtAuthenticationConverter jwtAuthenticationConverter =
new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(
grantedAuthoritiesConverter
);
return jwtAuthenticationConverter;
}
}O JwtAuthenticationConverter transforma os claims do token em objetos GrantedAuthority utilizáveis em expressões de segurança.
Pronto para mandar bem nas entrevistas de Spring Boot?
Pratique com nossos simuladores interativos, flashcards e testes tecnicos.
Extração de roles do Keycloak
O Keycloak estrutura as roles de forma diferente do padrão OAuth2. As roles encontram-se em realm_access.roles (roles do realm) ou resource_access.{client}.roles (roles específicas do cliente). Um converter customizado extrai essas informações.
@Component
public class KeycloakJwtAuthenticationConverter
implements Converter<Jwt, AbstractAuthenticationToken> {
private static final String REALM_ACCESS_CLAIM = "realm_access";
private static final String RESOURCE_ACCESS_CLAIM = "resource_access";
private static final String ROLES_CLAIM = "roles";
@Value("${keycloak.client-id}")
private String clientId;
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
// Combine realm and client roles
Collection<GrantedAuthority> authorities = Stream.concat(
extractRealmRoles(jwt).stream(),
extractClientRoles(jwt).stream()
).collect(Collectors.toSet());
// Return authentication token with extracted authorities
return new JwtAuthenticationToken(jwt, authorities, extractUsername(jwt));
}
// Extract realm-level roles
private Collection<GrantedAuthority> extractRealmRoles(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaim(REALM_ACCESS_CLAIM);
if (realmAccess == null) {
return Collections.emptyList();
}
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) realmAccess.get(ROLES_CLAIM);
if (roles == null) {
return Collections.emptyList();
}
// ROLE_ prefix for compatibility with hasRole()
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.collect(Collectors.toList());
}
// Extract client-specific roles
private Collection<GrantedAuthority> extractClientRoles(Jwt jwt) {
Map<String, Object> resourceAccess = jwt.getClaim(RESOURCE_ACCESS_CLAIM);
if (resourceAccess == null) {
return Collections.emptyList();
}
@SuppressWarnings("unchecked")
Map<String, Object> clientAccess =
(Map<String, Object>) resourceAccess.get(clientId);
if (clientAccess == null) {
return Collections.emptyList();
}
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) clientAccess.get(ROLES_CLAIM);
if (roles == null) {
return Collections.emptyList();
}
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.collect(Collectors.toList());
}
// Extract username from token
private String extractUsername(Jwt jwt) {
// Keycloak uses "preferred_username" by default
String username = jwt.getClaimAsString("preferred_username");
if (username != null) {
return username;
}
// Fallback to subject (usually the user UUID)
return jwt.getSubject();
}
}A integração na configuração de segurança é feita através do parâmetro jwtAuthenticationConverter.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final KeycloakJwtAuthenticationConverter keycloakConverter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
// Protection by Keycloak role
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
// Use Keycloak converter
.jwtAuthenticationConverter(keycloakConverter)
)
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.build();
}
}As roles extraídas já podem ser utilizadas com anotações @PreAuthorize("hasRole('ADMIN')") ou em expressões requestMatchers().hasRole().
Validação customizada de tokens
Validações adicionais podem ser necessárias dependendo das necessidades de negócio: verificação de claims customizados, validação de audiência ou controle do tempo de vida máximo.
@Component
public class CustomJwtValidator implements OAuth2TokenValidator<Jwt> {
private static final OAuth2Error AUDIENCE_ERROR =
new OAuth2Error("invalid_token", "Token audience is not valid", null);
private static final OAuth2Error CUSTOM_CLAIM_ERROR =
new OAuth2Error("invalid_token", "Required custom claim missing", null);
@Value("${app.jwt.required-audience}")
private String requiredAudience;
@Value("${app.jwt.required-tenant-claim:tenant_id}")
private String tenantClaimName;
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
List<OAuth2Error> errors = new ArrayList<>();
// Audience validation
if (!validateAudience(jwt)) {
errors.add(AUDIENCE_ERROR);
}
// Custom business claim validation
if (!validateTenantClaim(jwt)) {
errors.add(CUSTOM_CLAIM_ERROR);
}
if (!errors.isEmpty()) {
return OAuth2TokenValidatorResult.failure(errors);
}
return OAuth2TokenValidatorResult.success();
}
private boolean validateAudience(Jwt jwt) {
List<String> audiences = jwt.getAudience();
return audiences != null && audiences.contains(requiredAudience);
}
private boolean validateTenantClaim(Jwt jwt) {
// Check for required business claim
String tenantId = jwt.getClaimAsString(tenantClaimName);
return tenantId != null && !tenantId.isBlank();
}
}O validador customizado se integra na configuração do JwtDecoder.
@Configuration
@RequiredArgsConstructor
public class JwtConfig {
private final CustomJwtValidator customValidator;
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
private String issuerUri;
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
private String jwkSetUri;
@Bean
public JwtDecoder jwtDecoder() {
// Create decoder with JWKS URL
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder
.withJwkSetUri(jwkSetUri)
.build();
// Combine default validators with custom validator
OAuth2TokenValidator<Jwt> defaultValidators =
JwtValidators.createDefaultWithIssuer(issuerUri);
OAuth2TokenValidator<Jwt> combinedValidator =
new DelegatingOAuth2TokenValidator<>(defaultValidators, customValidator);
jwtDecoder.setJwtValidator(combinedValidator);
return jwtDecoder;
}
}Validações síncronas bloqueiam a thread de processamento. Para validações que requerem chamadas externas (banco de dados, serviços de terceiros), é preferível um filtro assíncrono ou uma verificação posterior.
Acesso às informações do token nos controllers
O Spring Security oferece vários métodos para acessar as informações do token JWT nos controllers.
@RestController
@RequestMapping("/api/users")
public class UserController {
// Direct JWT injection via @AuthenticationPrincipal
@GetMapping("/me")
public ResponseEntity<UserInfoResponse> getCurrentUser(
@AuthenticationPrincipal Jwt jwt
) {
String userId = jwt.getSubject();
String email = jwt.getClaimAsString("email");
String username = jwt.getClaimAsString("preferred_username");
List<String> roles = extractRoles(jwt);
return ResponseEntity.ok(new UserInfoResponse(
userId, email, username, roles
));
}
// Alternative with JwtAuthenticationToken to access authorities
@GetMapping("/profile")
public ResponseEntity<ProfileResponse> getProfile(
JwtAuthenticationToken authentication
) {
Jwt jwt = authentication.getToken();
Collection<String> authorities = authentication.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.toList();
return ResponseEntity.ok(new ProfileResponse(
jwt.getSubject(),
jwt.getClaimAsString("name"),
authorities
));
}
// Access scopes for conditional business logic
@GetMapping("/data")
@PreAuthorize("hasAuthority('SCOPE_read')")
public ResponseEntity<DataResponse> getData(
@AuthenticationPrincipal Jwt jwt
) {
boolean canWrite = hasScope(jwt, "write");
// Business logic adapted according to permissions
DataResponse response = buildDataResponse(jwt.getSubject(), canWrite);
return ResponseEntity.ok(response);
}
private List<String> extractRoles(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
if (realmAccess == null) {
return Collections.emptyList();
}
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) realmAccess.get("roles");
return roles != null ? roles : Collections.emptyList();
}
private boolean hasScope(Jwt jwt, String scope) {
String scopes = jwt.getClaimAsString("scope");
return scopes != null && scopes.contains(scope);
}
}public record UserInfoResponse(
String userId,
String email,
String username,
List<String> roles
) {}
// ProfileResponse.java
public record ProfileResponse(
String userId,
String name,
Collection<String> authorities
) {}A anotação @AuthenticationPrincipal evita a recuperação manual da autenticação a partir do SecurityContextHolder.
Tratamento de erros de autenticação OAuth2
Um tratamento específico de erros melhora a experiência do desenvolvedor para os consumidores da API.
@RestControllerAdvice
public class OAuth2SecurityExceptionHandler {
private static final Logger log =
LoggerFactory.getLogger(OAuth2SecurityExceptionHandler.class);
// Missing token or invalid format
@ExceptionHandler(AuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ErrorResponse handleAuthenticationException(AuthenticationException ex) {
log.warn("Authentication failed: {}", ex.getMessage());
return new ErrorResponse(
"UNAUTHORIZED",
"Authentication required. Provide a valid Bearer token.",
Map.of("error", ex.getMessage())
);
}
// Access denied despite valid token
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ErrorResponse handleAccessDeniedException(AccessDeniedException ex) {
log.warn("Access denied: {}", ex.getMessage());
return new ErrorResponse(
"FORBIDDEN",
"Insufficient permissions for this resource",
null
);
}
// OAuth2-specific error (expired token, invalid signature, etc.)
@ExceptionHandler(OAuth2AuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ErrorResponse handleOAuth2Exception(OAuth2AuthenticationException ex) {
OAuth2Error error = ex.getError();
log.warn("OAuth2 authentication error: {} - {}",
error.getErrorCode(), error.getDescription());
String message = switch (error.getErrorCode()) {
case "invalid_token" -> "Token is invalid or expired";
case "insufficient_scope" -> "Token does not have required scopes";
default -> "Authentication failed";
};
return new ErrorResponse(
error.getErrorCode().toUpperCase(),
message,
Map.of("details", error.getDescription())
);
}
}
// ErrorResponse.java
public record ErrorResponse(
String code,
String message,
Map<String, String> details
) {}Os códigos de erro padronizados do OAuth2 (invalid_token, insufficient_scope) facilitam o processamento do lado do cliente.
Configuração multi-ambiente
A configuração varia conforme o ambiente. Em desenvolvimento, um servidor Keycloak local pode ser utilizado, enquanto em produção um IdP gerenciado como o Auth0 assume o controle.
# application.yml (common configuration)
app:
jwt:
required-audience: my-api
---
# application-dev.yml
spring:
config:
activate:
on-profile: dev
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8180/realms/dev-realm
keycloak:
client-id: my-api-dev
logging:
level:
org.springframework.security: DEBUG
---
# application-prod.yml
spring:
config:
activate:
on-profile: prod
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${OAUTH2_ISSUER_URI}
audiences: ${OAUTH2_AUDIENCES:my-api}
keycloak:
client-id: ${KEYCLOAK_CLIENT_ID}
logging:
level:
org.springframework.security: WARNEm produção, os valores sensíveis vêm de variáveis de ambiente ou de um gerenciador de segredos.
Testes de integração com JWTs simulados
Os testes de integração utilizam os utilitários do Spring Security Test para simular tokens JWT válidos.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class ResourceServerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldRejectRequestWithoutToken() throws Exception {
mockMvc.perform(get("/api/users/me"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockJwtAuth(
claims = @OpenIdClaims(sub = "user-123", preferredUsername = "john.doe"),
authorities = {"ROLE_USER"}
)
void shouldAllowAuthenticatedUser() throws Exception {
mockMvc.perform(get("/api/users/me"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.userId").value("user-123"))
.andExpect(jsonPath("$.username").value("john.doe"));
}
@Test
void shouldAllowAccessWithValidJwt() throws Exception {
mockMvc.perform(get("/api/users/me")
.with(jwt()
.jwt(jwt -> jwt
.subject("user-456")
.claim("preferred_username", "jane.doe")
.claim("email", "jane@example.com")
)
.authorities(new SimpleGrantedAuthority("ROLE_USER"))
))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value("jane@example.com"));
}
@Test
void shouldDenyAccessWithoutAdminRole() throws Exception {
mockMvc.perform(get("/api/admin/users")
.with(jwt()
.authorities(new SimpleGrantedAuthority("ROLE_USER"))
))
.andExpect(status().isForbidden());
}
@Test
void shouldAllowAdminAccess() throws Exception {
mockMvc.perform(get("/api/admin/users")
.with(jwt()
.authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))
))
.andExpect(status().isOk());
}
}O utilitário jwt() do spring-security-test cria tokens simulados sem necessidade de um Authorization Server.
@TestConfiguration
public class SecurityTestConfig {
// Mocked JwtDecoder for tests
@Bean
@Primary
public JwtDecoder jwtDecoder() {
return token -> {
// Return a mocked JWT for tests
return Jwt.withTokenValue(token)
.header("alg", "RS256")
.subject("test-user")
.claim("scope", "read write")
.build();
};
}
}Comece a praticar!
Teste seus conhecimentos com nossos simuladores de entrevista e testes tecnicos.
Conclusão
A configuração de um OAuth2 Resource Server com Spring Security 6 centraliza a validação de tokens e simplifica a segurança das APIs. O Authorization Server gerencia a autenticação e a emissão de tokens, enquanto a API se concentra na validação e extração das informações de identidade.
Checklist de deploy:
- ✅ Configurar
issuer-urioujwk-set-uriconforme necessário - ✅ Validar a audiência para evitar uso indevido de tokens de outros serviços
- ✅ Converter customizado para extrair roles do Keycloak
- ✅ Tratamento de erros OAuth2 com códigos padronizados
- ✅ Testes de integração com tokens simulados
- ✅ HTTPS obrigatório entre cliente e Resource Server
- ✅ Configuração específica por ambiente
- ✅ Logs de tentativas de autenticação falhas
Essa arquitetura se integra naturalmente em um ecossistema de microsserviços onde múltiplas APIs compartilham o mesmo Identity Provider.
Tags
Compartilhar
Artigos relacionados

Spring Security 6: Autenticação JWT Completa
Guia prático para implementar autenticação JWT com Spring Security 6. Configuração, geração de tokens, validação e boas práticas de segurança.

Spring Modulith: Arquitetura de Monólito Modular Explicada
Aprenda Spring Modulith para construir monólitos modulares em Java. Arquitetura, módulos, eventos assíncronos e testes com exemplos em Spring Boot 3.

Entrevista Spring Batch 5: Particionamento, Chunks e Tolerância
Domine as entrevistas de Spring Batch 5: 15 perguntas essenciais sobre particionamento, processamento por chunks e tolerância a falhas com exemplos em Java 21.