JWT & OAuth2 Resource Servers
How JWTs work, securing an API as an OAuth2 resource server, mapping claims to authorities, and what not to build yourself.
OAuth2 and OIDC in one picture
Modern apps don’t usually check passwords themselves. An authorization server (Keycloak, Okta, Auth0, Microsoft Entra ID, AWS Cognito, or Spring Authorization Server) authenticates the user and issues access tokens. Your API is a resource server: it validates the token on each request and authorizes based on its claims.
sequenceDiagram
participant U as User / SPA
participant AS as Authorization Server
participant API as Your API (resource server)
U->>AS: login (Authorization Code + PKCE)
AS-->>U: access token (JWT) + refresh token
U->>API: GET /api/orders<br/>Authorization: Bearer eyJ...
API->>AS: (once, then cached) fetch public keys from JWKS endpoint
API->>API: verify signature, exp, iss, aud → authorities
API-->>U: 200 OK
OpenID Connect (OIDC) adds identity on top of OAuth2: an ID token describing the user, and a standard user-info endpoint.
Anatomy of a JWT
A JSON Web Token is three base64url parts separated by dots: header.payload.signature.
// header
{ "alg": "RS256", "kid": "2026-key-1", "typ": "JWT" }
// payload (claims)
{
"iss": "https://auth.shop.example",
"sub": "user-123",
"aud": "shop-api",
"exp": 1793347200,
"iat": 1793346300,
"scope": "orders:read orders:write",
"roles": ["customer"]
}
The signature proves the token was issued by the authorization server and hasn’t been modified. With asymmetric algorithms (RS256, ES256), the API verifies using the server’s public key — fetched from its JWKS endpoint — and never needs a shared secret.
Spring Boot as a resource server
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.shop.example # discovers the JWKS URL via OIDC metadata
audiences: shop-api # reject tokens meant for other APIs
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders:read")
.requestMatchers("/api/orders/**").hasAuthority("SCOPE_orders:write")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> jwt.jwtAuthenticationConverter(rolesConverter())))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable())
.build();
}
With that, Spring Security validates the signature, expiry (exp, nbf), issuer (iss) and audience (aud) on every request, and turns the scope claim into SCOPE_… authorities.
Mapping custom claims to roles
private JwtAuthenticationConverter rolesConverter() {
var scopes = new JwtGrantedAuthoritiesConverter(); // scope -> SCOPE_*
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Collection<GrantedAuthority> authorities = new ArrayList<>(scopes.convert(jwt));
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) roles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r.toUpperCase())));
return authorities;
});
return converter;
}
Testing secured endpoints
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderControllerSecurityTest {
@Autowired MockMvc mvc;
@MockitoBean OrderService orders;
@Test
void rejectsAnonymous() throws Exception {
mvc.perform(get("/api/orders/1")).andExpect(status().isUnauthorized());
}
@Test
void allowsReadScope() throws Exception {
mvc.perform(get("/api/orders/1").with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_orders:read"))))
.andExpect(status().isOk());
}
@Test
void forbidsWriteWithoutScope() throws Exception {
mvc.perform(post("/api/orders").with(jwt()).contentType(APPLICATION_JSON).content("{}"))
.andExpect(status().isForbidden());
}
}
Token lifetime and revocation
JWT validation is stateless: the API doesn’t call the authorization server per request, which is fast but means a token stays valid until it expires. Mitigations:
- Short-lived access tokens (5–15 minutes) + refresh tokens (rotated, revocable)
- Opaque tokens + introspection (
opaqueToken(...)) when instant revocation matters, at the cost of a call per request (cacheable) - A deny-list of token ids (
jti) for emergencies
Service-to-service calls
For machine-to-machine calls use the client credentials grant. Spring Security’s OAuth2 client support fetches and caches tokens and attaches them to RestClient requests:
@Bean
RestClient inventoryClient(RestClient.Builder builder, OAuth2AuthorizedClientManager manager) {
var interceptor = new OAuth2ClientHttpRequestInterceptor(manager);
interceptor.setClientRegistrationIdResolver(request -> "inventory");
return builder.baseUrl("https://inventory.internal").requestInterceptor(interceptor).build();
}
Mark it complete to track your progress through Spring Boot 4.