Home / Spring Boot / Security / JWT and OAuth2 Resource Server

JWT and OAuth2 Resource Server

For REST APIs the common approach is stateless authentication with JSON Web Tokens (JWT). The client logs in once, gets a signed token, and sends it in the Authorization header with every request. The server verifies the signature and needs no session.

What is a JWT?

eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiJyYW0iLCJyb2xlcyI6WyJVU0VSIl0sImV4cCI6MTc... . SIGNATURE
      header                        payload (claims)                                  signature

header  : {"alg":"HS256"}
payload : {"sub":"ram","roles":["USER"],"iss":"shop-app","iat":1790000000,"exp":1790003600}
signature = HMAC-SHA256( base64(header) + "." + base64(payload), secret )

Flow

1. POST /login (username + password) ───────────────► Auth server / your login endpoint
2. ◄────────────────────────────────────────────── JWT access token (valid 15 to 60 min)
3. GET /api/orders   Authorization: Bearer <JWT> ─────► Resource server (your Spring Boot API)
4. Verifies signature, expiry, issuer  →  builds the user and authorities  →  runs the controller

Option A: validate tokens from an identity provider (production way)

Use Keycloak, Auth0, Okta, Azure AD or Spring Authorization Server as the issuer. Your API is only 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://keycloak.example.com/realms/shop
# or: spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://.../protocol/openid-connect/certs
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain api(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/**").hasAuthority("SCOPE_read")
                .requestMatchers("/api/**").hasAuthority("SCOPE_write")
                .anyRequest().authenticated())
            .oauth2ResourceServer(oauth -> oauth.jwt(jwt -> jwt.jwtAuthenticationConverter(converter())));
        return http.build();
    }

    // read roles from a custom "roles" claim instead of the default "scope" claim
    private JwtAuthenticationConverter converter() {
        JwtGrantedAuthoritiesConverter authorities = new JwtGrantedAuthoritiesConverter();
        authorities.setAuthoritiesClaimName("roles");
        authorities.setAuthorityPrefix("ROLE_");
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(authorities);
        return converter;
    }
}

@RestController
public class MeController {
    @GetMapping("/api/me")
    public Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) {
        return Map.of("user", jwt.getSubject(), "claims", jwt.getClaims());
    }
}

Option B: learning setup with a shared secret (issue and validate tokens yourself)

This is good for learning and small internal apps. The secret must be at least 32 bytes for HS256 and must come from an environment variable, never from Git.

@Configuration
public class JwtConfig {

    private final SecretKey key;

    public JwtConfig(@Value("${app.jwt.secret}") String secret) {
        this.key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
    }

    @Bean
    public JwtEncoder jwtEncoder() { return new NimbusJwtEncoder(new ImmutableSecret<>(key)); }

    @Bean
    public JwtDecoder jwtDecoder() { return NimbusJwtDecoder.withSecretKey(key).build(); }
}

@Service
public class TokenService {
    private final JwtEncoder encoder;
    public TokenService(JwtEncoder encoder) { this.encoder = encoder; }

    public String createToken(String username, List<String> roles) {
        Instant now = Instant.now();
        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer("shop-app").issuedAt(now).expiresAt(now.plusSeconds(3600))
                .subject(username).claim("roles", roles).build();
        JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
        return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
    }
}

@RestController
public class LoginController {
    private final AuthenticationManager manager;
    private final TokenService tokens;

    public LoginController(AuthenticationManager manager, TokenService tokens) {
        this.manager = manager; this.tokens = tokens;
    }

    @PostMapping("/login")
    public Map<String, String> login(@RequestBody LoginRequest request) {
        Authentication auth = manager.authenticate(
                new UsernamePasswordAuthenticationToken(request.username(), request.password()));
        List<String> roles = auth.getAuthorities().stream().map(a -> a.getAuthority().replace("ROLE_", "")).toList();
        return Map.of("token", tokens.createToken(auth.getName(), roles));
    }
}
public record LoginRequest(String username, String password) { }

For this to work, expose the AuthenticationManager as a bean: return configuration.getAuthenticationManager() from a @Bean method that receives AuthenticationConfiguration. Then call the API with:

curl -X POST localhost:8080/login -H "Content-Type: application/json" -d '{"username":"ram","password":"ram123"}'
curl localhost:8080/api/me -H "Authorization: Bearer <token>"

Best practices

JWT trade-offs in one table

AdvantageDisadvantage
Stateless, scales without a session storeHard to revoke before expiry
Works across services and domainsRoles in the token can be stale
Good for mobile and machine clientsToken theft gives full access until it expires
Verified locally with a public keyPayload is readable, only signed
Standard librariesWrong validation (alg, aud, iss) is easy to introduce

Complete guide on this site

Minimum rules if you use JWT tomorrow

Common mistake: Putting sensitive data such as passwords or card numbers in the JWT payload. The payload is readable by anyone who has the token.

Interview questions

Session or JWT?

Sessions are simple for server-rendered sites. JWTs suit stateless REST APIs and microservices, but tokens are harder to revoke before they expire.

What is a resource server?

An application that hosts protected data and accepts access tokens issued by an authorization server, verifying them on every request.

Where should the client store the token?

For browser apps prefer an HttpOnly secure cookie handled by a backend, or memory. Avoid localStorage because scripts can read it (XSS risk).

Should I build my own JWT login or use Keycloak?

For a serious product use an identity provider such as Keycloak. It provides login, MFA, SSO, password reset and token rotation, and your API only validates tokens as a resource server.