Project: A JWT-Secured REST API
This final lesson brings together every concept from the tutorial into a single, production-shaped project. You will build a small task-management API that demonstrates registration, login, token issuance, role-based access control, refresh-token rotation, and proper error handling — all wired together with Spring Boot 3 and Spring Security 6. The goal is not to build the biggest system, but to show why each piece is placed where it is and what breaks if you leave it out.
Project Overview & Dependencies
The API exposes three resource groups: public auth endpoints (/api/auth/**), user-scoped task endpoints (/api/tasks/**), and an admin reporting endpoint (/api/admin/**). The pom.xml needs four starters plus the JWT library:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
Why H2 for the project? An embedded H2 database means zero external setup — the app is fully runnable with ./mvnw spring-boot:run. Swap the JDBC URL and driver for PostgreSQL or MySQL in production without changing a single line of application code.
Configuration
All security-sensitive values live in application.yml and are injected via @Value. Never hard-code secrets in Java source files.
spring:
datasource:
url: jdbc:h2:mem:taskdb
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
show-sql: false
app:
jwt:
secret: "404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970"
access-token-expiry-ms: 900000 # 15 minutes
refresh-token-expiry-ms: 604800000 # 7 days
Domain Model
The AppUser entity stores credentials and roles. Roles are stored as a simple enum set — no join table needed for a project of this scale.
@Entity
@Table(name = "app_users")
public class AppUser {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String passwordHash;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"))
@Enumerated(EnumType.STRING)
private Set<Role> roles = new HashSet<>();
// getters / setters omitted for brevity
}
public enum Role { ROLE_USER, ROLE_ADMIN }
Refresh tokens are persisted so the server can revoke them individually — a pure in-memory store would survive restarts but lose revocation state on restart.
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String token; // opaque random string (UUID)
@ManyToOne(fetch = FetchType.LAZY)
private AppUser user;
private Instant expiresAt;
private boolean revoked;
// getters / setters omitted
}
JWT Service
The JwtService concentrates all token logic — generation, claim extraction, and validation. Keeping it in one class means every part of the system shares the same parsing rules and you have exactly one place to update when rotating algorithm choices.
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String secret;
@Value("${app.jwt.access-token-expiry-ms}")
private long accessExpiryMs;
private SecretKey signingKey() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
return Keys.hmacShaKeyFor(keyBytes);
}
public String generateAccessToken(UserDetails user) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
return Jwts.builder()
.claims(claims)
.subject(user.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + accessExpiryMs))
.signWith(signingKey())
.compact();
}
public String extractUsername(String token) {
return parseClaims(token).getSubject();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername())
&& !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return parseClaims(token).getExpiration().before(new Date());
}
private Claims parseClaims(String token) {
return Jwts.parser()
.verifyWith(signingKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
}
Authentication Filter
The JwtAuthenticationFilter intercepts every request, extracts the bearer token from the Authorization header, validates it, and populates the SecurityContext. Once the context is populated, Spring Security's downstream filters see an authenticated principal and allow access to protected routes.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
String username = jwtService.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
filterChain.doFilter(request, response);
}
}
Security Configuration
The SecurityConfig class wires everything together. Notice the stateless session policy — because the server never stores session state, every request carries its own credentials in the JWT.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable) // stateless API; CSRF not applicable
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) ->
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"))
.accessDeniedHandler((req, res, e) ->
res.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden"))
)
.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
throws Exception {
return config.getAuthenticationManager();
}
}
Never disable CSRF on a session-based application. Disabling CSRF is safe only when the API is truly stateless (no cookies carry credentials). If you later add cookie-based authentication alongside JWT, you must re-enable CSRF protection or introduce a separate cookie-based CSRF token strategy.
Auth Controller — Register, Login, and Refresh
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/register")
public ResponseEntity<MessageResponse> register(@Valid @RequestBody RegisterRequest req) {
authService.register(req);
return ResponseEntity.status(HttpStatus.CREATED)
.body(new MessageResponse("User registered successfully"));
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest req) {
return ResponseEntity.ok(authService.login(req));
}
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(@RequestBody RefreshRequest req) {
return ResponseEntity.ok(authService.refreshTokens(req.refreshToken()));
}
@PostMapping("/logout")
public ResponseEntity<MessageResponse> logout(@RequestBody RefreshRequest req) {
authService.revokeRefreshToken(req.refreshToken());
return ResponseEntity.ok(new MessageResponse("Logged out"));
}
}
The AuthService.login method authenticates via the AuthenticationManager (which uses the DaoAuthenticationProvider configured above), then issues both tokens in a single response:
public AuthResponse login(LoginRequest req) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(req.username(), req.password())
);
AppUser user = userRepository.findByUsername(req.username())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
UserDetails userDetails = toUserDetails(user);
String accessToken = jwtService.generateAccessToken(userDetails);
String refreshToken = refreshTokenService.createRefreshToken(user);
return new AuthResponse(accessToken, refreshToken);
}
Task Endpoints — Method-Level Security
Using @PreAuthorize keeps authorization logic close to the business code and makes it auditable in a single file:
@RestController
@RequestMapping("/api/tasks")
@RequiredArgsConstructor
public class TaskController {
private final TaskService taskService;
@GetMapping
@PreAuthorize("hasRole('USER') or hasRole('ADMIN')")
public List<TaskDto> getMyTasks(Authentication auth) {
return taskService.getTasksForUser(auth.getName());
}
@PostMapping
@PreAuthorize("hasRole('USER')")
public ResponseEntity<TaskDto> create(@Valid @RequestBody CreateTaskRequest req,
Authentication auth) {
TaskDto created = taskService.createTask(req, auth.getName());
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or @taskService.isOwner(#id, authentication.name)")
public ResponseEntity<Void> delete(@PathVariable Long id) {
taskService.deleteTask(id);
return ResponseEntity.noContent().build();
}
}
SpEL bean references in @PreAuthorize: The expression @taskService.isOwner(#id, authentication.name) calls a Spring bean method at authorization time. This is the idiomatic way to express data-level ownership checks without hard-coding repository calls in the controller. The bean method simply loads the task and compares owner usernames.
Testing the Secured Endpoints
A complete integration test uses @SpringBootTest with MockMvc. The test registers a user, logs in to obtain a real JWT, then asserts that the secured endpoint returns 200 with the token and 401 without it:
@SpringBootTest
@AutoConfigureMockMvc
class TaskApiIntegrationTest {
@Autowired MockMvc mvc;
@Autowired ObjectMapper mapper;
private String accessToken;
@BeforeEach
void authenticate() throws Exception {
mvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(
new RegisterRequest("alice", "P@ssw0rd!", Set.of(Role.ROLE_USER)))));
String body = mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new LoginRequest("alice", "P@ssw0rd!"))))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
accessToken = mapper.readTree(body).get("accessToken").asText();
}
@Test
void getTasksRequiresJwt() throws Exception {
mvc.perform(get("/api/tasks")).andExpect(status().isUnauthorized());
}
@Test
void getTasksWithValidJwt() throws Exception {
mvc.perform(get("/api/tasks")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken))
.andExpect(status().isOk());
}
}
Distributed Systems Trade-offs
Before closing, consider the architectural decisions baked into this design:
- Short-lived access tokens (15 min): If a token is stolen, the attack window is narrow. The cost is that clients must refresh frequently — use a refresh interceptor in your frontend HTTP client to do this silently.
- Persisted refresh tokens: A database row per active session enables targeted revocation (logout from one device). The trade-off is one extra database round-trip per refresh call. For high-traffic APIs, back this table with Redis and an expiry TTL.
- Roles in the JWT payload: After a role change, existing tokens reflect the old roles until they expire. Either accept this lag (appropriate for most apps), shorten the access token lifetime further, or introduce a token blocklist for immediate revocation.
- No shared secret between services: If you add a second microservice, both need the same signing key — or switch to asymmetric RS256 so the auth server holds the private key and other services verify with a public key only.
Summary
You have assembled a complete, runnable, and production-shaped JWT-secured REST API: dependency setup, YAML-driven configuration, a persisted domain model, a focused JwtService, a stateless SecurityFilterChain, an OncePerRequestFilter that authenticates every request, role-based endpoint guards at both the route and method level, refresh-token rotation with revocation, and a full integration test suite. Each piece maps directly to a lesson earlier in this tutorial — use this project as a living reference when you build your own services.