Spring Security JWT Authentication with Refresh Token (Spring Boot 3 + MySQL) — Complete Guide
In modern REST APIs, the most common requirement is: secure endpoints using JWT, support
login + signup, issue access & refresh tokens, and restrict endpoints based on
user roles like ROLE_USER and ROLE_ADMIN.
In this in-depth guide, we’ll build a complete Spring Boot 3 application using Spring Security 6, JWT, and MySQL (with Oracle config as an alternative). You’ll start from an empty project and end with a production-ready authentication system.
- Register & login users with encrypted passwords
- Issue access tokens and refresh tokens using JWT
- Protect REST APIs with role-based access control
- Use a JWT filter to authenticate every request
- Implement a refresh token endpoint to get a new access token
1. Architecture Overview
Before we write code, let’s understand the flow.
| Step | Action | Description |
|---|---|---|
| 1 | Register | User calls POST /api/auth/register with email, password, name, roles |
| 2 | Login | User calls POST /api/auth/login, gets accessToken + refreshToken |
| 3 | Access protected API | Client sends Authorization: Bearer <accessToken> to secured endpoints |
| 4 | Token expiry | When access token expires, client calls /api/auth/refresh with refreshToken |
| 5 | New access token | Server validates refresh token, issues a new access token (optionally new refresh token) |
2. Project Setup (Spring Boot 3 + Maven)
2.1. Create Spring Boot Project
Use start.spring.io with:
- Project: Maven
- Language: Java (17 or 21)
- Spring Boot: 3.x.x
- Dependencies:
- Spring Web
- Spring Data JPA
- Spring Security
- MySQL Driver
- Lombok
- Validation (optional but recommended)
2.2. Maven Dependencies
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</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-security</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Optional: Oracle Driver (if you use Oracle) -->
<!--
<dependency>
<groupId>com.oracle.database.jdbc</groupId>
<artifactId>ojdbc11</artifactId>
<scope>runtime</scope>
</dependency>
-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
3. Database Configuration (MySQL primary, Oracle alternative)
3.1. MySQL Configuration (application.properties)
spring.datasource.url=jdbc:mysql://localhost:3306/jwt_security_demo?useSSL=false&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=your_password
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
# JWT custom properties
app.security.jwt.secret=change_this_secret_to_a_long_random_string
app.security.jwt.access-token-expiration-ms=900000 # 15 minutes
app.security.jwt.refresh-token-expiration-ms=604800000 # 7 days
3.2. Oracle Configuration (alternative)
spring.datasource.url=jdbc:oracle:thin:@localhost:1521/ORCLCDB
spring.datasource.username=JWT_USER
spring.datasource.password=your_password
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.database-platform=org.hibernate.dialect.OracleDialect
app.security.jwt.secret.
Never commit real secrets to Git. Use environment variables / config server for production.
4. Domain Model — User & Role Entities
We’ll store users and roles in tables users, roles, and a join table user_roles.
4.1. Role Enum
package com.example.securitydemo.user;
public enum RoleName {
ROLE_USER,
ROLE_ADMIN
}
4.2. Role Entity
package com.example.securitydemo.user;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "roles")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@Column(nullable = false, unique = true)
private RoleName name;
}
4.3. User Entity
package com.example.securitydemo.user;
import jakarta.persistence.*;
import lombok.*;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String fullName;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String password;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
@Builder.Default
private Set<Role> roles = new HashSet<>();
}
5. Repositories
5.1. RoleRepository
package com.example.securitydemo.user;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface RoleRepository extends JpaRepository<Role, Long> {
Optional<Role> findByName(RoleName name);
}
5.2. UserRepository
package com.example.securitydemo.user;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findByEmail(String email);
boolean existsByEmail(String email);
}
6. Security UserDetails Implementation
Spring Security works with UserDetails objects. We will map AppUser to
UserDetails.
6.1. CustomUserDetails
package com.example.securitydemo.security;
import com.example.securitydemo.user.AppUser;
import com.example.securitydemo.user.Role;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
public class CustomUserDetails implements UserDetails {
private final AppUser user;
public CustomUserDetails(AppUser user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return user.getRoles().stream()
.map(Role::getName)
.map(Enum::name)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@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 true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
public Long getId() {
return user.getId();
}
public String getFullName() {
return user.getFullName();
}
}
6.2. CustomUserDetailsService
package com.example.securitydemo.security;
import com.example.securitydemo.user.AppUser;
import com.example.securitydemo.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@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 CustomUserDetails(user);
}
}
7. JWT Utility Service
We’ll create a dedicated service for generating and validating JWTs. We’ll support two token types: access token and refresh token with different expirations.
7.1. JwtService
package com.example.securitydemo.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import java.security.Key;
import java.util.Date;
import java.util.Map;
import java.util.function.Function;
@Service
@Slf4j
public class JwtService {
@Value("${app.security.jwt.secret}")
private String secretKey;
@Value("${app.security.jwt.access-token-expiration-ms}")
private long accessTokenExpirationMs;
@Value("${app.security.jwt.refresh-token-expiration-ms}")
private long refreshTokenExpirationMs;
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
public String generateAccessToken(UserDetails userDetails) {
return generateToken(userDetails, accessTokenExpirationMs);
}
public String generateRefreshToken(UserDetails userDetails) {
return generateToken(userDetails, refreshTokenExpirationMs);
}
private String generateToken(UserDetails userDetails, long expirationMs) {
return Jwts.builder()
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(getSignInKey(), SignatureAlgorithm.HS256)
.compact();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
private Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
private Claims extractAllClaims(String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(getSignInKey())
.build()
.parseClaimsJws(token)
.getBody();
} catch (JwtException ex) {
log.warn("Invalid JWT token: {}", ex.getMessage());
throw ex;
}
}
private Key getSignInKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
}
8. JWT Authentication Filter
We’ll create a filter that runs on every request, checks the Authorization header for a Bearer token, validates it, and sets the authentication in the SecurityContext.
8.1. JwtAuthenticationFilter
package com.example.securitydemo.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final CustomUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
final String jwt;
final String username;
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
jwt = authHeader.substring(7);
try {
username = jwtService.extractUsername(jwt);
} catch (Exception ex) {
filterChain.doFilter(request, response);
return;
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
9. Security Configuration (Spring Security 6)
Spring Security 6 (with Boot 3) uses a SecurityFilterChain bean instead of extending
WebSecurityConfigurerAdapter.
9.1. SecurityConfig
package com.example.securitydemo.security;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@RequiredArgsConstructor
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final CustomUserDetailsService userDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
- We disable sessions (
STATELESS) because we rely on JWT /api/auth/**is open for login/register/refresh- We secure
/api/admin/**and/api/user/**with roles - We add our
JwtAuthenticationFilterbefore the username/password filter
10. DTOs for Authentication
10.1. RegisterRequest
package com.example.securitydemo.auth;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.*;
import java.util.Set;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class RegisterRequest {
@NotBlank
private String fullName;
@Email
@NotBlank
private String email;
@NotBlank
@Size(min = 6, message = "Password must be at least 6 characters")
private String password;
// Optional: allow passing roles, otherwise default to ROLE_USER
private Set<String> roles;
}
10.2. LoginRequest
package com.example.securitydemo.auth;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.*;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class LoginRequest {
@Email
@NotBlank
private String email;
@NotBlank
private String password;
}
10.3. AuthResponse (Access + Refresh tokens)
package com.example.securitydemo.auth;
import lombok.*;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AuthResponse {
private String accessToken;
private String refreshToken;
private String tokenType; // e.g. "Bearer"
}
10.4. RefreshTokenRequest
package com.example.securitydemo.auth;
import jakarta.validation.constraints.NotBlank;
import lombok.*;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class RefreshTokenRequest {
@NotBlank
private String refreshToken;
}
11. Authentication Service (Register, Login, Refresh)
11.1. AuthService
package com.example.securitydemo.auth;
import com.example.securitydemo.security.CustomUserDetails;
import com.example.securitydemo.security.JwtService;
import com.example.securitydemo.user.*;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashSet;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
@Transactional
public AuthResponse register(RegisterRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new IllegalArgumentException("Email is already in use");
}
Set<Role> roles = resolveRoles(request.getRoles());
AppUser user = AppUser.builder()
.fullName(request.getFullName())
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.roles(roles)
.build();
AppUser saved = userRepository.save(user);
CustomUserDetails userDetails = new CustomUserDetails(saved);
String accessToken = jwtService.generateAccessToken(userDetails);
String refreshToken = jwtService.generateRefreshToken(userDetails);
return AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.build();
}
public AuthResponse login(LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword())
);
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
String accessToken = jwtService.generateAccessToken(userDetails);
String refreshToken = jwtService.generateRefreshToken(userDetails);
return AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.build();
}
public AuthResponse refreshToken(RefreshTokenRequest request) {
String refreshToken = request.getRefreshToken();
String username = jwtService.extractUsername(refreshToken);
AppUser user = userRepository.findByEmail(username)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
CustomUserDetails userDetails = new CustomUserDetails(user);
if (!jwtService.isTokenValid(refreshToken, userDetails)) {
throw new IllegalArgumentException("Invalid refresh token");
}
String newAccessToken = jwtService.generateAccessToken(userDetails);
// Optionally: issue a new refresh token as well (here we keep same refresh token)
return AuthResponse.builder()
.accessToken(newAccessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.build();
}
private Set<Role> resolveRoles(Set<String> roleNames) {
Set<Role> roles = new HashSet<>();
if (roleNames == null || roleNames.isEmpty()) {
Role userRole = roleRepository.findByName(RoleName.ROLE_USER)
.orElseThrow(() -> new IllegalStateException("ROLE_USER not configured"));
roles.add(userRole);
return roles;
}
for (String roleNameStr : roleNames) {
RoleName roleName = RoleName.valueOf(roleNameStr);
Role role = roleRepository.findByName(roleName)
.orElseThrow(() -> new IllegalStateException(roleName + " not configured"));
roles.add(role);
}
return roles;
}
}
12. Authentication Controller
12.1. AuthController
package com.example.securitydemo.auth;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
return ResponseEntity.ok(authService.register(request));
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
return ResponseEntity.ok(authService.login(request));
}
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
return ResponseEntity.ok(authService.refreshToken(request));
}
}
13. Secured Sample Controllers (User vs Admin)
13.1. UserController
package com.example.securitydemo.demo;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/user")
public class UserController {
@GetMapping("/me")
@PreAuthorize("hasRole('USER') or hasRole('ADMIN')")
public ResponseEntity<String> me() {
return ResponseEntity.ok("Hello from USER endpoint (USER or ADMIN can access)");
}
}
13.2. AdminController
package com.example.securitydemo.demo;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/admin")
public class AdminController {
@GetMapping("/dashboard")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<String> dashboard() {
return ResponseEntity.ok("Hello from ADMIN dashboard");
}
}
13.3. PublicController (no auth required)
package com.example.securitydemo.demo;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/public")
public class PublicController {
@GetMapping("/hello")
public ResponseEntity<String> hello() {
return ResponseEntity.ok("Public endpoint - no authentication needed");
}
}
14. Data Initialization (Insert Default Roles & Admin)
We’ll create a CommandLineRunner to insert default roles and an admin user on startup (only if not present).
14.1. DataInitializer
package com.example.securitydemo.config;
import com.example.securitydemo.user.*;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.Set;
@Configuration
@RequiredArgsConstructor
public class DataInitializer {
private final RoleRepository roleRepository;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Bean
public CommandLineRunner initData() {
return args -> {
// Create roles if not exist
Role userRole = roleRepository.findByName(RoleName.ROLE_USER)
.orElseGet(() -> roleRepository.save(
Role.builder().name(RoleName.ROLE_USER).build()
));
Role adminRole = roleRepository.findByName(RoleName.ROLE_ADMIN)
.orElseGet(() -> roleRepository.save(
Role.builder().name(RoleName.ROLE_ADMIN).build()
));
// Create admin user if not exist
if (!userRepository.existsByEmail("admin@example.com")) {
AppUser admin = AppUser.builder()
.fullName("Admin User")
.email("admin@example.com")
.password(passwordEncoder.encode("admin123"))
.roles(Set.of(userRole, adminRole))
.build();
userRepository.save(admin);
}
};
}
}
admin@example.com / admin123 — change this in real systems.
15. Testing the Flow with Postman or cURL
15.1. Test Public Endpoint (No Auth)
curl http://localhost:8080/api/public/hello
Expected response:
Public endpoint - no authentication needed
15.2. Register New User
curl -X POST "http://localhost:8080/api/auth/register" \
-H "Content-Type: application/json" \
-d '{
"fullName": "John Doe",
"email": "john@example.com",
"password": "password123"
}'
Example response:
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"tokenType": "Bearer"
}
15.3. Login as Existing User
curl -X POST "http://localhost:8080/api/auth/login" \
-H "Content-Type: application/json" \
-d '{
"email": "john@example.com",
"password": "password123"
}'
15.4. Access User Endpoint with Access Token
curl http://localhost:8080/api/user/me \
-H "Authorization: Bearer <ACCESS_TOKEN_HERE>"
15.5. Access Admin Endpoint as Admin
curl -X POST "http://localhost:8080/api/auth/login" \
-H "Content-Type: application/json" \
-d '{
"email": "admin@example.com",
"password": "admin123"
}'
# Then call admin endpoint:
curl http://localhost:8080/api/admin/dashboard \
-H "Authorization: Bearer <ADMIN_ACCESS_TOKEN>"
15.6. Refresh Access Token Using Refresh Token
curl -X POST "http://localhost:8080/api/auth/refresh" \
-H "Content-Type: application/json" \
-d '{
"refreshToken": "<REFRESH_TOKEN_HERE>"
}'
This returns a new accessToken while keeping the same refreshToken (in our implementation).
16. Common Pitfalls and Best Practices
| Problem | Cause | Best Practice |
|---|---|---|
| Anyone can access JWT over HTTP | No HTTPS in production | Always use HTTPS for auth endpoints in production |
| JWT never expires | No expiration set or very large expiration | Use short-lived access tokens and longer-lived refresh tokens |
| Tokens can be reused after password change | No invalidation strategy | Store token metadata or user “token version” and check against DB |
| User can escalate role by changing JWT payload | Weak or exposed secret key | Use strong secret keys, rotate them, and never share them |
| Broken behavior when combining JWT with sessions | SessionCreationPolicy not stateless |
Use STATELESS for JWT-based APIs |
17. When to Store Refresh Tokens in DB?
In this tutorial, we keep JWTs stateless (no DB storage for tokens). This is simpler but has trade-offs.
- Stateless refresh tokens (our approach):
- Easy to scale horizontally
- No server-side storage
- Cannot easily revoke specific refresh tokens
- Stateful refresh tokens (store in DB):
- Possible to revoke tokens individually
- Track devices / sessions
- More complex (tables for tokens, cleanup jobs)
For many small/medium applications, stateless JWT + reasonable expiration is enough. For critical systems, consider storing refresh tokens in DB with status (active / revoked).
18. Summary
In this guide, we built a complete JWT-based authentication system with Spring Boot 3 and Spring Security 6:
- Configured MySQL / Oracle database
- Created User and Role entities with many-to-many mapping
- Implemented CustomUserDetails and UserDetailsService
- Built a JwtService to generate and validate access & refresh tokens
- Created a JWT filter (
OncePerRequestFilter) to protect every request - Configured SecurityFilterChain for stateless security & role-based access
- Implemented /register, /login, /refresh endpoints
- Protected endpoints for USER and ADMIN roles
- Initialized default roles and admin user
You can now extend this setup with features like logout, token blacklisting, password reset, 2FA, and integration with your frontend (Angular/React/Vue).