
Introduction
Securing REST APIs is one of the most critical parts of backend development, especially when building SaaS platforms, mobile backends, or multi-tenant services. Unlike traditional web applications that use session cookies, modern APIs require stateless authentication that scales horizontally and works across different clients and domains. JWT (JSON Web Tokens) combined with Spring Security provides an elegant solution for this challenge. In this comprehensive tutorial, you will learn how to build a secure REST API in Spring Boot 3 using Spring Security and JWT, implementing authentication, authorization, refresh tokens, and production-ready security patterns step by step.
What is JWT?
JWT (JSON Web Token) is a compact, self-contained token format used to securely transmit user identity and claims between a client and server. Unlike session-based authentication where the server stores session state, JWTs are stateless—the token itself contains all necessary information to verify the user.
JWT Structure
A JWT consists of three Base64-encoded parts separated by dots:
HEADER.PAYLOAD.SIGNATURE
Header: Contains the token type and signing algorithm
{
"alg": "HS256",
"typ": "JWT"
}
Payload: Contains claims (user data and metadata)
{
"sub": "user@example.com",
"roles": ["ROLE_USER"],
"iat": 1704067200,
"exp": 1704070800
}
Signature: Cryptographic signature verifying the token was not tampered with
Why Use JWT?
- Stateless: No server-side session storage required
- Scalable: Works across multiple servers without session synchronization
- Cross-domain: Works with mobile apps, SPAs, and microservices
- Self-contained: Contains user information and permissions
- Standardized: Industry standard with libraries in every language
Project Setup
Generate a Spring Boot 3 project with the required dependencies. You can use Spring Initializr or add dependencies manually.
Gradle Dependencies
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
}
java {
sourceCompatibility = '21'
}
dependencies {
// Spring Boot Starters
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
// JWT Library
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
// Database
runtimeOnly 'org.postgresql:postgresql'
runtimeOnly 'com.h2database:h2' // For development
// Lombok (optional but recommended)
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// Testing
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
Application Configuration
# application.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
app:
jwt:
secret: your-256-bit-secret-key-must-be-at-least-32-characters
access-token-expiration: 900000 # 15 minutes
refresh-token-expiration: 604800000 # 7 days
User Entity and Repository
Create the user entity with role support for authorization.
@Entity
@Table(name = "app_users")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String firstName;
@Column(nullable = false)
private String lastName;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "role")
@Enumerated(EnumType.STRING)
private Set<Role> roles = new HashSet<>();
@Column(nullable = false)
private boolean enabled = true;
@Column(nullable = false)
private boolean accountNonLocked = true;
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}
Role Enum
public enum Role {
ROLE_USER,
ROLE_ADMIN,
ROLE_MODERATOR
}
User Repository
@Repository
public interface UserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findByEmail(String email);
boolean existsByEmail(String email);
}
Custom UserDetails Implementation
Create a custom UserDetails implementation that wraps your user entity.
@AllArgsConstructor
public class UserPrincipal implements UserDetails {
private final AppUser user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority(role.name()))
.collect(Collectors.toSet());
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return user.isAccountNonLocked();
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return user.isEnabled();
}
public Long getId() {
return user.getId();
}
public AppUser getUser() {
return user;
}
}
UserDetailsService Implementation
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
AppUser user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException(
"User not found with email: " + email));
return new UserPrincipal(user);
}
}
JWT Service
Create a comprehensive JWT service for token generation and validation.
@Service
@Slf4j
public class JwtService {
@Value("${app.jwt.secret}")
private String secretKey;
@Value("${app.jwt.access-token-expiration}")
private long accessTokenExpiration;
@Value("${app.jwt.refresh-token-expiration}")
private long refreshTokenExpiration;
public String generateAccessToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
// Add roles to claims
claims.put("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
// Add user ID if available
if (userDetails instanceof UserPrincipal principal) {
claims.put("userId", principal.getId());
}
return buildToken(claims, userDetails.getUsername(), accessTokenExpiration);
}
public String generateRefreshToken(UserDetails userDetails) {
return buildToken(new HashMap<>(), userDetails.getUsername(), refreshTokenExpiration);
}
private String buildToken(Map<String, Object> claims, String subject, long expiration) {
return Jwts.builder()
.claims(claims)
.subject(subject)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey(), Jwts.SIG.HS256)
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
@SuppressWarnings("unchecked")
public List<String> extractRoles(String token) {
return extractClaim(token, claims -> claims.get("roles", List.class));
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
try {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
} catch (JwtException | IllegalArgumentException e) {
log.warn("Invalid JWT token: {}", e.getMessage());
return false;
}
}
public boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
}
JWT Authentication Filter
Create a filter that intercepts requests, extracts the JWT, and sets the authentication context.
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final CustomUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
// Skip filter for authentication endpoints
if (request.getServletPath().contains("/api/auth")) {
filterChain.doFilter(request, response);
return;
}
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
try {
final String jwt = authHeader.substring(7);
final String userEmail = jwtService.extractUsername(jwt);
if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(userEmail);
if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (Exception e) {
log.error("Cannot set user authentication: {}", e.getMessage());
}
filterChain.doFilter(request, response);
}
}
Security Configuration
Configure Spring Security with JWT filter, CORS, and endpoint protection.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final CustomUserDetailsService userDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(authenticationEntryPoint())
.accessDeniedHandler(accessDeniedHandler())
)
.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:3000", "https://yourdomain.com"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
public AuthenticationEntryPoint authenticationEntryPoint() {
return (request, response, authException) -> {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
Map<String, Object> body = new HashMap<>();
body.put("status", HttpServletResponse.SC_UNAUTHORIZED);
body.put("error", "Unauthorized");
body.put("message", authException.getMessage());
body.put("path", request.getServletPath());
new ObjectMapper().writeValue(response.getOutputStream(), body);
};
}
@Bean
public AccessDeniedHandler accessDeniedHandler() {
return (request, response, accessDeniedException) -> {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
Map<String, Object> body = new HashMap<>();
body.put("status", HttpServletResponse.SC_FORBIDDEN);
body.put("error", "Forbidden");
body.put("message", "Access denied");
body.put("path", request.getServletPath());
new ObjectMapper().writeValue(response.getOutputStream(), body);
};
}
}
Authentication Controller
Create endpoints for login, registration, and token refresh.
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
@Validated
public class AuthController {
private final AuthenticationService authService;
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(
@Valid @RequestBody RegisterRequest request
) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(authService.register(request));
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(
@Valid @RequestBody LoginRequest request
) {
return ResponseEntity.ok(authService.login(request));
}
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refreshToken(
@Valid @RequestBody RefreshTokenRequest request
) {
return ResponseEntity.ok(authService.refreshToken(request));
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(
@RequestHeader("Authorization") String authHeader
) {
// Optionally invalidate refresh token
return ResponseEntity.noContent().build();
}
}
Request and Response DTOs
@Data
public class LoginRequest {
@NotBlank(message = "Email is required")
@Email(message = "Invalid email format")
private String email;
@NotBlank(message = "Password is required")
private String password;
}
@Data
public class RegisterRequest {
@NotBlank(message = "Email is required")
@Email(message = "Invalid email format")
private String email;
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
private String password;
@NotBlank(message = "First name is required")
private String firstName;
@NotBlank(message = "Last name is required")
private String lastName;
}
@Data
public class RefreshTokenRequest {
@NotBlank(message = "Refresh token is required")
private String refreshToken;
}
@Data
@Builder
public class AuthResponse {
private String accessToken;
private String refreshToken;
private String tokenType;
private long expiresIn;
private UserDto user;
}
Authentication Service
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthenticationService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
public AuthResponse register(RegisterRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new UserAlreadyExistsException("Email already registered");
}
AppUser user = AppUser.builder()
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.firstName(request.getFirstName())
.lastName(request.getLastName())
.roles(Set.of(Role.ROLE_USER))
.enabled(true)
.accountNonLocked(true)
.build();
userRepository.save(user);
UserPrincipal principal = new UserPrincipal(user);
String accessToken = jwtService.generateAccessToken(principal);
String refreshToken = jwtService.generateRefreshToken(principal);
return buildAuthResponse(accessToken, refreshToken, user);
}
public AuthResponse login(LoginRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(),
request.getPassword()
)
);
AppUser user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
UserPrincipal principal = new UserPrincipal(user);
String accessToken = jwtService.generateAccessToken(principal);
String refreshToken = jwtService.generateRefreshToken(principal);
log.info("User {} logged in successfully", user.getEmail());
return buildAuthResponse(accessToken, refreshToken, user);
}
public AuthResponse refreshToken(RefreshTokenRequest request) {
String refreshToken = request.getRefreshToken();
String userEmail = jwtService.extractUsername(refreshToken);
AppUser user = userRepository.findByEmail(userEmail)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
UserPrincipal principal = new UserPrincipal(user);
if (!jwtService.isTokenValid(refreshToken, principal)) {
throw new InvalidTokenException("Invalid refresh token");
}
String newAccessToken = jwtService.generateAccessToken(principal);
return buildAuthResponse(newAccessToken, refreshToken, user);
}
private AuthResponse buildAuthResponse(String accessToken, String refreshToken, AppUser user) {
return AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.expiresIn(900) // 15 minutes
.user(UserDto.fromEntity(user))
.build();
}
}
Role-Based Access Control
Use method security annotations for fine-grained authorization.
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("/me")
public ResponseEntity<UserDto> getCurrentUser(
@AuthenticationPrincipal UserPrincipal principal
) {
return ResponseEntity.ok(UserDto.fromEntity(principal.getUser()));
}
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<List<UserDto>> getAllUsers() {
return ResponseEntity.ok(userService.getAllUsers());
}
@PutMapping("/{id}/roles")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<UserDto> updateUserRoles(
@PathVariable Long id,
@RequestBody Set<Role> roles
) {
return ResponseEntity.ok(userService.updateRoles(id, roles));
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or #id == principal.id")
public ResponseEntity<Void> deleteUser(
@PathVariable Long id,
@AuthenticationPrincipal UserPrincipal principal
) {
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}
}
Common Mistakes to Avoid
Weak Secret Keys
Never use short or predictable secret keys. Use at least 256 bits of entropy generated cryptographically. Store secrets in environment variables or secret management systems.
Long-Lived Access Tokens
Access tokens should be short-lived (15-30 minutes). Use refresh tokens for extended sessions. This limits exposure if a token is compromised.
Storing Sensitive Data in Tokens
JWTs are Base64-encoded, not encrypted. Never store passwords, credit cards, or other sensitive data in token payloads.
No Token Invalidation
JWTs are stateless, so you cannot invalidate them server-side by default. Implement a token blacklist for logout or use short expiration times.
Missing HTTPS
Always use HTTPS in production. Tokens sent over HTTP can be intercepted and replayed.
Ignoring Token Expiration
Always validate token expiration. Expired tokens should be rejected even if the signature is valid.
Testing the API
Test your API with curl or Postman:
# Register
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password123","firstName":"John","lastName":"Doe"}'
# Login
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password123"}'
# Access protected endpoint
curl http://localhost:8080/api/users/me \
-H "Authorization: Bearer <your-access-token>"
# Refresh token
curl -X POST http://localhost:8080/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refreshToken":"<your-refresh-token>"}'
Conclusion
JWT-based authentication with Spring Security provides a fast, scalable, and stateless solution ideal for modern SaaS platforms, mobile backends, and microservices architectures. By implementing proper token management with short-lived access tokens and refresh tokens, role-based authorization, and secure configuration, you can build production-ready authentication systems. Spring Boot 3 makes this smoother with native support for stateless configurations, improved security defaults, and better developer ergonomics. For complementary backend security patterns, read OAuth 2.0 with Keycloak in Spring Boot. To understand API design best practices, explore REST API Design Best Practices. For official documentation, see the Spring Security Reference and JWT.io Introduction.