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 )- The payload is only Base64 encoded, not encrypted. Anyone can read it. Never put secrets in it.
- The signature proves that the token was not changed and who created it.
- Always set a short expiry (exp) and check it.
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 controllerOption 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
- Use HTTPS only. A bearer token is like a password.
- Keep access tokens short (15 to 60 minutes) and use refresh tokens for longer sessions.
- Validate issuer (iss), audience (aud) and expiry (exp).
- Prefer an identity provider (Keycloak, Auth0) over building your own login system.
- Plan key rotation. With a JWK set URI, keys can rotate without redeploying.
JWT trade-offs in one table
| Advantage | Disadvantage |
|---|---|
| Stateless, scales without a session store | Hard to revoke before expiry |
| Works across services and domains | Roles in the token can be stale |
| Good for mobile and machine clients | Token theft gives full access until it expires |
| Verified locally with a public key | Payload is readable, only signed |
| Standard libraries | Wrong validation (alg, aud, iss) is easy to introduce |
Complete guide on this site
- What a JWT contains and how verification works: /springsecurity/jwt/jwt-fundamentals/
- Fayde aur nuksan with a decision guide: /springsecurity/jwt/jwt-pros-and-cons/
- Full RS256 implementation: /springsecurity/jwt/jwt-implementation/
- How to make JWT more secure: /springsecurity/jwt/jwt-hardening/
- JWT attacks and defenses: /springsecurity/jwt/jwt-attacks-and-defenses/
- Refresh token rotation: /springboot/security/jwt-refresh-token-flow/
Minimum rules if you use JWT tomorrow
- Sign with RS256 or ES256 and pin the algorithm in the decoder.
- Validate exp, iss and aud in every service.
- Access token 5 to 15 minutes, refresh token opaque, hashed and rotated.
- Never keep long-lived tokens in localStorage.
- Keep claims minimal and have a plan to revoke (deny list or token version).
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.