StackWise

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.

advanced 3 min readLesson 15 of 23

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();
}
Finished this lesson?

Mark it complete to track your progress through Spring Boot 4.