Advanced Java & Spring Boot · Lesson 9 of 16 · Updated ·
Spring Security with JWT — Login, Roles and Stateless APIs
Right now anyone could delete our courses. We need authentication (who are you?) and authorisation (what may you do?). For REST APIs used by apps, the standard answer is a JWT sent with every request.
1. What is a JWT?
A JSON Web Token is header.payload.signature. The payload holds claims — who the user is (sub), their roles (scope), the issuer and the expiry (exp). The server signs it with a secret; if anyone changes one character, the signature check fails.
- Stateless: the server stores no session — perfect for many servers behind a load balancer and for mobile apps.
- The payload is only encoded, not encrypted: anyone can read it. Never put passwords or secrets in it.
- Keep tokens short-lived (we use 60 minutes); real apps add refresh tokens.
2. Security configuration
// File: course-api/src/main/java/com/skills101/courses/security/SecurityConfig.java
package com.skills101.courses.security;
import java.util.Base64;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // no cookies or sessions: JWTs aren't sent automatically, so CSRF can't happen
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/token", "/actuator/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/courses/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/courses/*/enrollments").hasAuthority("SCOPE_STUDENT")
.requestMatchers("/api/courses/**").hasAuthority("SCOPE_ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> { })); // read "Authorization: Bearer <token>"
return http.build();
}
@Bean
SecretKey jwtKey(@Value("${app.jwt.secret}") String secret) {
return new SecretKeySpec(Base64.getDecoder().decode(secret), "HmacSHA256");
}
@Bean
JwtEncoder jwtEncoder(SecretKey jwtKey) {
return NimbusJwtEncoder.withSecretKey(jwtKey).build();
}
@Bean
JwtDecoder jwtDecoder(SecretKey jwtKey) {
return NimbusJwtDecoder.withSecretKey(jwtKey).build();
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder(); // bcrypt by default
}
// Demo users. A real app loads users from the database with its own UserDetailsService.
@Bean
UserDetailsService users(PasswordEncoder encoder) {
return new InMemoryUserDetailsManager(
User.withUsername("admin@skills101.in").password(encoder.encode("admin123")).roles("ADMIN").build(),
User.withUsername("asha@skills101.in").password(encoder.encode("asha123")).roles("STUDENT").build(),
User.withUsername("ravi@skills101.in").password(encoder.encode("ravi123")).roles("STUDENT").build());
}
@Bean
AuthenticationManager authenticationManager(UserDetailsService users, PasswordEncoder encoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(users);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider);
}
}
Line by line:
SecurityFilterChain— the URL rules, checked top to bottom: login, health and API docs are public; anyone may GET courses; onlySTUDENTtokens may enrol; every other/api/courses/**call (POST, PUT, DELETE) needsADMIN.csrf.disable()— CSRF attacks abuse cookies a browser sends automatically. We use no cookies or sessions (the token goes in a header), so CSRF protection isn't needed here. Keep it on for cookie-based web apps.STATELESS— never create an HTTP session.oauth2ResourceServer(… jwt …)— Spring Security's built-in support readsAuthorization: Bearer <token>, verifies the signature and expiry with ourJwtDecoder, and turns thescopeclaim into authorities likeSCOPE_ADMIN. No hand-written filter needed.- Passwords are stored hashed with bcrypt (a slow, salted hash) — never in plain text.
- The demo users live in memory; a real app implements
UserDetailsServiceto load users from the database.
3. Issuing the token at login
// File: course-api/src/main/java/com/skills101/courses/security/TokenService.java
package com.skills101.courses.security;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;
@Service
public class TokenService {
private final JwtEncoder encoder;
private final long ttlMinutes;
public TokenService(JwtEncoder encoder, @Value("${app.jwt.ttl-minutes}") long ttlMinutes) {
this.encoder = encoder;
this.ttlMinutes = ttlMinutes;
}
public String issue(Authentication user) {
// ROLE_ADMIN → "ADMIN"; Spring turns the "scope" claim back into SCOPE_ADMIN when reading the token
String scope = user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.map(a -> a.replaceFirst("^ROLE_", ""))
.collect(Collectors.joining(" "));
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("skills101-course-api")
.subject(user.getName())
.issuedAt(now)
.expiresAt(now.plus(ttlMinutes, ChronoUnit.MINUTES))
.claim("scope", scope)
.build();
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}
}
// File: course-api/src/main/java/com/skills101/courses/security/AuthController.java
package com.skills101.courses.security;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AuthController {
public record LoginRequest(@NotBlank String email, @NotBlank String password) { }
public record TokenResponse(String token, String type) { }
private final AuthenticationManager authManager;
private final TokenService tokens;
public AuthController(AuthenticationManager authManager, TokenService tokens) {
this.authManager = authManager;
this.tokens = tokens;
}
@PostMapping("/auth/token")
public ResponseEntity<TokenResponse> login(@Valid @RequestBody LoginRequest req) {
try {
Authentication user = authManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(req.email(), req.password()));
return ResponseEntity.ok(new TokenResponse(tokens.issue(user), "Bearer"));
} catch (AuthenticationException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); // same answer for wrong email or password
}
}
}
Calling it:
POST /auth/token
{ "email": "admin@skills101.in", "password": "admin123" }
200 OK
{ "token": "eyJraWQiOiJQNUM0…", "type": "Bearer" }
A wrong password gives 401 with no hint whether the email exists (that would help attackers).
4. Who is calling?
Controllers receive the verified token with @AuthenticationPrincipal:
// File: course-api/src/main/java/com/skills101/courses/enrollment/EnrollmentController.java
package com.skills101.courses.enrollment;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class EnrollmentController {
private final EnrollmentService service;
public EnrollmentController(EnrollmentService service) {
this.service = service;
}
// the logged-in student comes from the JWT, never from the request body
@PostMapping("/api/courses/{courseId}/enrollments")
@ResponseStatus(HttpStatus.CREATED)
public EnrollmentResponse enroll(@PathVariable Long courseId, @AuthenticationPrincipal Jwt jwt) {
return service.enroll(courseId, jwt.getSubject());
}
@GetMapping("/api/me/courses")
public List<String> myCourses(@AuthenticationPrincipal Jwt jwt) {
return service.myCourses(jwt.getSubject());
}
}
The student's identity comes from the signed token, never from the request body — otherwise anyone could enrol "as" someone else.
5. What our tests prove
| Request | Result |
|---|---|
GET /api/courses with no token | 200 |
POST /api/courses with no token | 401 Unauthorized |
POST /api/courses with a STUDENT token | 403 Forbidden |
POST /api/courses with an ADMIN token | 201 Created |
| login with a wrong password | 401 |
Common mistakes
- Storing plain-text passwords, or hashing with MD5/SHA-1 (too fast) instead of bcrypt/argon2.
- A short or committed JWT secret — use 32+ random bytes from an environment variable.
- Trusting a user id sent in the JSON body instead of the token.
- Disabling CSRF in a cookie/session-based web app.
- 401 vs 403 mix-ups: 401 = not authenticated, 403 = authenticated but not allowed.
Quick recap
- JWT = signed
header.payload.signature; stateless; readable, so no secrets inside. SecurityFilterChainholds URL rules;oauth2ResourceServer().jwt()validates Bearer tokens.- Hash passwords with bcrypt; issue tokens with
JwtEncoder; read the user with@AuthenticationPrincipal Jwt. - 401 = who are you? 403 = you can't do that.