Course topics

By WebNest Studio

Spring Boot Tutorial

JWT Authentication with OAuth2 Resource Server

The JWT concepts lesson showed a hand-written filter that parses tokens. In real projects you should not write that filter yourself: token parsing, signature verification, expiry checks, clock skew and error responses are subtle, and Spring Security already implements them correctly in its OAuth2 Resource Server support.

In this lesson you will build a complete stateless JWT setup with Spring Boot 4: a login endpoint that issues signed tokens with JwtEncoder, a resource server that validates them with JwtDecoder, mapping of claims to authorities, and configuration for validating tokens issued by an external identity provider such as Keycloak, Auth0 or Okta.

The Starter and What It Configures

Add spring-boot-starter-security-oauth2-resource-server (renamed from spring-boot-starter-oauth2-resource-server in Boot 4). When you call http.oauth2ResourceServer(o -> o.jwt(...)), Spring Security adds a BearerTokenAuthenticationFilter that reads the Authorization: Bearer header, passes the token to a JwtDecoder, and — if valid — stores a JwtAuthenticationToken in the SecurityContext. Invalid or expired tokens produce a 401 with a WWW-Authenticate: Bearer error="invalid_token" header.

Symmetric vs Asymmetric Signing

With HS256 (HMAC) the same secret signs and verifies tokens. It is simple but every service that verifies tokens must hold the secret, and any of them could therefore also mint tokens. With RS256/ES256 a private key signs and a public key verifies, so only the issuer can create tokens while any service can verify them. Use asymmetric keys whenever more than one service consumes the tokens, and load keys from a secret store — never commit them to source control.

Issuing Tokens with JwtEncoder

If your application is its own token issuer, define a JwtEncoder bean (NimbusJwtEncoder) and build a JwtClaimsSet containing issuer, subject, issued-at, expiry and your custom claims (for example scope or roles). Keep access tokens short-lived — 5 to 15 minutes is common — and use refresh tokens or re-login for longer sessions.

For anything beyond a single application, prefer a dedicated authorization server (see the Spring Authorization Server lesson) rather than issuing tokens from your API.

Validating Tokens from an External Issuer

When tokens come from Keycloak, Auth0, Okta, Azure Entra ID or Spring Authorization Server, you usually need only one property: spring.security.oauth2.resourceserver.jwt.issuer-uri. At startup Spring discovers the issuer's JWK Set URL from its OpenID configuration, downloads the public keys, and validates the signature, expiry and iss claim of every token. Add an audience check so tokens meant for other APIs are rejected.

Mapping Claims to Authorities

By default, values in the scope or scp claim become authorities prefixed with SCOPE_ — so a token with scope: "orders.read" grants SCOPE_orders.read. If your tokens carry roles in a different claim, configure a JwtAuthenticationConverter with a JwtGrantedAuthoritiesConverter pointing at that claim and a ROLE_ prefix. Spring Boot 4.1 also offers spring.security.oauth2.resourceserver.jwt.authorities-claim-expressions to extract authorities from nested claims with SpEL.

Refresh Tokens and Logout

JWT access tokens cannot be revoked once issued, which is why they must be short-lived. A refresh token — long-lived, stored server-side or in an HttpOnly cookie, rotated on every use — lets the client get new access tokens without re-entering a password. "Logout" then means deleting the refresh token; the access token simply expires within minutes.

Examples

pom.xml and application.yml for a self-issuing API

Java
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>

# application.yml — keys are loaded from files/secrets, never committed
app:
  jwt:
    issuer: https://api.webnest.in
    ttl: 15m
    public-key: file:/run/secrets/jwt-public.pem
    private-key: file:/run/secrets/jwt-private.pem
Output
(Generate a key pair for local development with: openssl genpkey -algorithm RSA -out jwt-private.pem -pkeyopt rsa_keygen_bits:2048 && openssl rsa -in jwt-private.pem -pubout -out jwt-public.pem)

Security configuration with JwtEncoder and JwtDecoder beans

Java
@ConfigurationProperties(prefix = "app.jwt")
public record JwtProperties(String issuer, Duration ttl,
                           RSAPublicKey publicKey, RSAPrivateKey privateKey) {}

@Configuration
@EnableMethodSecurity
@EnableConfigurationProperties(JwtProperties.class)
public class JwtSecurityConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/token").permitAll()
                .anyRequest().authenticated())
            .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(csrf -> csrf.disable());   // no cookies, so no CSRF risk
        return http.build();
    }

    @Bean
    JwtDecoder jwtDecoder(JwtProperties props) {
        return NimbusJwtDecoder.withPublicKey(props.publicKey()).build();
    }

    @Bean
    JwtEncoder jwtEncoder(JwtProperties props) {
        JWK jwk = new RSAKey.Builder(props.publicKey()).privateKey(props.privateKey()).build();
        return new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(jwk)));
    }
}
Output
(Spring Boot converts the PEM files referenced in app.jwt.public-key/private-key into RSAPublicKey/RSAPrivateKey automatically.)

A token endpoint that authenticates the user and issues a JWT

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

    private final AuthenticationManager authManager;
    private final JwtEncoder encoder;
    private final JwtProperties props;

    public TokenController(AuthenticationManager authManager, JwtEncoder encoder, JwtProperties props) {
        this.authManager = authManager;
        this.encoder = encoder;
        this.props = props;
    }

    public record TokenRequest(@NotBlank String username, @NotBlank String password) {}
    public record TokenResponse(String accessToken, long expiresIn) {}

    @PostMapping("/token")
    public TokenResponse token(@Valid @RequestBody TokenRequest req) {
        Authentication auth = authManager.authenticate(
            UsernamePasswordAuthenticationToken.unauthenticated(req.username(), req.password()));

        Instant now = Instant.now();
        String scope = auth.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .map(a -> a.replace("ROLE_", "").toLowerCase())
            .collect(Collectors.joining(" "));

        JwtClaimsSet claims = JwtClaimsSet.builder()
            .issuer(props.issuer())
            .issuedAt(now)
            .expiresAt(now.plus(props.ttl()))
            .subject(auth.getName())
            .claim("scope", scope)
            .build();

        String token = encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
        return new TokenResponse(token, props.ttl().toSeconds());
    }
}
Output
curl -X POST localhost:8080/api/auth/token -H "Content-Type: application/json" \
     -d '{"username":"asha","password":"user123"}'
{"accessToken":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwaS53ZWJuZXN0LmluIiwic3ViIjoiYXNoYSIsInNjb3BlIjoidXNlciJ9.kX3...","expiresIn":900}

Using the token and protecting endpoints with scopes

Java
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping
    @PreAuthorize("hasAuthority('SCOPE_user')")
    public List<String> myOrders(@AuthenticationPrincipal Jwt jwt) {
        return List.of("Order #1001 for " + jwt.getSubject());
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasAuthority('SCOPE_admin')")
    public void delete(@PathVariable Long id) { }
}
Output
curl localhost:8080/api/orders -H "Authorization: Bearer eyJhbGciOi..."
["Order #1001 for asha"]

curl localhost:8080/api/orders          (no token)      -> 401, WWW-Authenticate: Bearer
curl ... (expired token)                                -> 401, error="invalid_token", error_description="Jwt expired at 2026-09-27T10:15:00Z"
curl -X DELETE ... (token with scope "user" only)       -> 403, error="insufficient_scope"

Validating tokens from Keycloak/Auth0 with audience check and role mapping

Java
# application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.webnest.in/realms/shop
          audiences: orders-api

// Map a "roles" claim to ROLE_ authorities instead of the default SCOPE_ mapping
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
    roles.setAuthoritiesClaimName("roles");
    roles.setAuthorityPrefix("ROLE_");

    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(roles);
    converter.setPrincipalClaimName("preferred_username");
    return converter;
}
Output
Token payload: {"iss":"https://auth.webnest.in/realms/shop","aud":"orders-api","preferred_username":"asha","roles":["ADMIN"]}
Resulting Authentication: name=asha, authorities=[ROLE_ADMIN]
Token with aud "billing-api" -> 401 invalid_token (audience mismatch)

Common Mistakes

  • Writing a custom JWT parsing filter instead of using oauth2ResourceServer().jwt(), and missing checks such as expiry, algorithm confusion or issuer validation.
  • Hard-coding the signing secret in application.properties and committing it to Git.
  • Issuing access tokens that last days or weeks; they cannot be revoked, so a stolen token stays valid.
  • Forgetting that the default authority prefix is SCOPE_, then wondering why hasRole("ADMIN") never matches.
  • Skipping the audience check, which lets a token issued for a different API be replayed against yours.

Key Points to Remember

  • Use spring-boot-starter-security-oauth2-resource-server and oauth2ResourceServer(o -> o.jwt(...)) rather than a hand-written filter.
  • JwtEncoder issues tokens; JwtDecoder validates signature, expiry and issuer on every request.
  • For external identity providers, issuer-uri plus audiences is usually all the configuration you need.
  • Scopes map to SCOPE_ authorities by default; customise with JwtAuthenticationConverter.
  • Keep access tokens short-lived and use rotated refresh tokens for long sessions.

Practice the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.