Spring Security JWT Authentication with Refresh Token — Spring Boot 3 + MySQL

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.

After following this tutorial, you will be able to:
  • 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.

StepActionDescription
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)
We will keep JWTs stateless: tokens are not stored in the database. In real-world systems, you can extend this to store/revoke refresh tokens if needed.

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
Use a strong, randomly generated value for 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);
  }
}
Use a Base64-encoded secret key (e.g., via an online generator). For HS256, use at least 256-bit key.

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();
  }
}
Key points:
  • 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 JwtAuthenticationFilter before 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);
      }
    };
  }
}
Default admin credentials: 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

ProblemCauseBest 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).