How I Built a Production-Ready JWT Auth Template with Java 21 and Spring Security 6
Every Java project needs authentication. And every time, you spend days wiring up Spring Security, configuring JWT, setting up roles, handling token refresh... before you even write a single line of business logic.
I got tired of it. So I built a template I can reuse — and decided to share it.
What's Inside
- Spring Boot 3.2 + Spring Security 6 stateless JWT auth
- Java 21 features used throughout — Records, Sealed classes, Pattern matching
- Role-based authorization — USER, ADMIN, MODERATOR out of the box
- Access token + refresh token rotation
- React + TypeScript frontend demo with password strength indicator
- Docker + PostgreSQL — one command to run everything
- Postman collection included
Why Java 21 Features Matter Here
This isn't just a Spring Boot project with Java 21 slapped on it. The modern Java features are used where they actually improve the code.
Records for DTOs
Instead of bloated POJOs with getters, setters, constructors and equals/hashCode:
public record LoginRequest(
@NotBlank(message = "Username is required")
String username,
@NotBlank(message = "Password is required")
String password
) {}
Immutable, zero boilerplate, built-in equals, hashCode and toString. Spring validation works out of the box.
Sealed Classes for Exception Hierarchy
public sealed class AuthException extends RuntimeException
permits AuthException.UserAlreadyExistsException,
AuthException.InvalidTokenException,
AuthException.UserNotFoundException,
AuthException.RoleNotFoundException {
public static final class UserAlreadyExistsException extends AuthException { ... }
public static final class InvalidTokenException extends AuthException { ... }
// ...
}
The compiler knows every possible subtype. This means the switch expression in the exception handler is exhaustive — no unchecked cases.
Pattern Matching Switch in Exception Handler
var status = switch (ex) {
case AuthException.UserAlreadyExistsException e -> HttpStatus.CONFLICT;
case AuthException.InvalidTokenException e -> HttpStatus.UNAUTHORIZED;
case AuthException.RoleNotFoundException e -> HttpStatus.INTERNAL_SERVER_ERROR;
case AuthException.UserNotFoundException e -> HttpStatus.NOT_FOUND;
default -> HttpStatus.BAD_REQUEST;
};
Clean, readable, type-safe. No instanceof chains.
Optional Chain in JWT Filter
parseJwt(request)
.filter(jwt -> SecurityContextHolder.getContext().getAuthentication() == null)
.ifPresent(jwt -> {
String username = jwtTokenProvider.extractUsername(jwt);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtTokenProvider.isTokenValid(jwt, userDetails)) {
var authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
});
No nested null checks, no if/else chains.
Security Configuration
Spring Security 6 dropped WebSecurityConfigurerAdapter. The new approach uses beans directly:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(PUBLIC_ENDPOINTS).permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/mod/**").hasAnyRole("ADMIN", "MODERATOR")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
Stateless, no sessions, JWT filter injected before Spring's default auth filter.
Password Validation
Both backend and frontend enforce strong passwords.
Backend — @Pattern on the DTO rejects weak passwords even if someone bypasses the UI:
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&_\\-#])[A-Za-z\\d@$!%*?&_\\-#]{8,}$",
message = "Password must contain uppercase, lowercase, number and special character"
)
String password
Frontend — real-time strength indicator with 5-segment bar and checklist that checks off rules as you type.
Docker Setup
services:
app:
build: .
ports:
- "8080:8080"
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: authdb
POSTGRES_USER: authuser
POSTGRES_PASSWORD: authpass
healthcheck:
test: ["CMD-SHELL", "pg_isready -U authuser -d authdb"]
interval: 5s
timeout: 5s
retries: 5
The healthcheck on PostgreSQL ensures the app container only starts after the database is actually ready — not just running.
API Endpoints
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /api/auth/register | Public | Register new user |
| POST | /api/auth/login | Public | Login, get tokens |
| POST | /api/auth/refresh | Public | Refresh access token |
| POST | /api/auth/logout | Bearer | Invalidate token |
| GET | /api/me | USER | Current user info |
| GET | /api/user/profile | USER | User profile |
| GET | /api/mod/dashboard | MOD+ | Moderator area |
| GET | /api/admin/users | ADMIN | Admin area |
Get the Template
The full template with React frontend, Docker setup and Postman collection is available here:
👉 Spring Boot JWT Auth Template on Gumroad
Includes everything you need to clone it, run docker-compose up --build, and have a working auth system in minutes.
Tags
#java #springboot #webdev #security #jwt
Top comments (0)