Cristhian Villegas
Cursos15 min read0 views

JWT in Spring Boot 3 Course #1: Fundamentals and Initial Setup

JWT in Spring Boot 3 Course #1: Fundamentals and Initial Setup

JWT Security in Spring Boot

Source: Markus Spiske — Unsplash

Welcome to the Course — Part 1 of 4

Welcome to the JWT in Spring Boot 3 Course, a comprehensive four-part series where you will learn to implement production-grade authentication and authorization using JSON Web Tokens with Spring Boot 3.2+ and Spring Security 6.

In this first article, you will set up the entire foundation: understand what JWT is, how it works internally, create a JWT service, implement the security filter chain, and build your first login endpoint. By the end, you will have a fully working stateless authentication system.

What you will learn in the full course

  • Part 1 (this article): JWT fundamentals, project setup, token generation, security filter, and login endpoint
  • Part 2: Refresh tokens, token rotation, and secure cookie storage
  • Part 3: Role-based access control (RBAC), method-level security with @PreAuthorize
  • Part 4: Blacklisting tokens with Redis, logout flow, and production hardening

Prerequisites

  • Java 17 or higher (we use Java 17+ syntax throughout)
  • Spring Boot 3.2+ with Spring Security 6
  • Maven 3.9+ (Gradle instructions are similar)
  • Basic understanding of Spring Boot and REST APIs
  • An IDE such as IntelliJ IDEA or VS Code with Java extensions
Source code: All the code in this course is compilable and production-ready. Each article builds on the previous one, so follow them in order for the best learning experience.

What is JWT?

A JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way to transmit information between parties as a JSON object. This information can be verified and trusted because it is digitally signed.

JWTs are commonly used for authentication and information exchange. When a user logs in, the server generates a JWT containing the user's identity and claims. The client stores this token (usually in memory or an HTTP-only cookie) and sends it with every subsequent request in the Authorization header.

Why JWT for authentication?

  • Stateless: The server does not need to store session data. All the information needed to authenticate the user is embedded in the token itself.
  • Scalable: Since there is no server-side session, you can scale horizontally without worrying about sticky sessions or shared session stores.
  • Cross-domain: JWTs work seamlessly across different domains and services, making them ideal for microservices architectures.
  • Self-contained: The token carries all the user's claims (roles, permissions, metadata), reducing database lookups on every request.
Important: JWT is not encrypted by default — it is encoded (Base64URL). Anyone who intercepts the token can read the payload. Never put sensitive data (passwords, credit card numbers) in the payload. Always use HTTPS in production.

JWT Structure

A JWT consists of three parts separated by dots (.): Header, Payload, and Signature.

text
1eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIiwiaWF0IjoxNzE3MDAwMDAwLCJleHAiOjE3MTcwMDM2MDAsInJvbGVzIjpbIlJPTEVfVVNFUiJdfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
2|____________Header____________|_________________________Payload___________________________|______________Signature_______________|

Header

The header typically contains two fields: the type of token (typ) and the signing algorithm (alg). When decoded from Base64URL:

json
1{
2  "alg": "HS256",
3  "typ": "JWT"
4}

Payload (Claims)

The payload contains the claims — statements about the user and additional metadata. There are three types of claims:

  • Registered claims: Predefined fields like sub (subject), iat (issued at), exp (expiration), iss (issuer)
  • Public claims: Custom claims registered in the IANA JSON Web Token Registry to avoid collisions
  • Private claims: Custom claims agreed upon by the parties (e.g., roles, userId)
json
1{
2  "sub": "[email protected]",
3  "iat": 1717000000,
4  "exp": 1717003600,
5  "roles": ["ROLE_USER"]
6}

Signature

The signature is created by taking the encoded header, the encoded payload, a secret key, and the algorithm specified in the header:

text
1HMACSHA256(
2  base64UrlEncode(header) + "." + base64UrlEncode(payload),
3  secret
4)

The signature ensures that the token has not been tampered with. If anyone modifies the payload, the signature will no longer match, and the server will reject the token.

JWT vs Traditional Sessions

Understanding when to use JWT versus server-side sessions is critical for making the right architectural decision.

FeatureJWT (Stateless)Server-Side Sessions
State storageClient-side (token)Server-side (memory/DB/Redis)
ScalabilityExcellent — no shared stateRequires sticky sessions or shared store
RevocationHard — needs blacklist (covered in Part 4)Easy — delete session from store
Payload sizeLarger (contains claims)Small (just session ID)
Cross-domainWorks nativelyRequires CORS cookie config
MicroservicesIdeal — any service can validateNeeds centralized session store
Mobile clientsNatural fitCookie handling can be tricky
When to use sessions: If your application is a traditional server-rendered monolith where you need immediate token revocation (e.g., banking), server-side sessions may be simpler. For SPAs, mobile apps, and microservices, JWT is typically the better choice.

Project Setup

Let's create our Spring Boot project. Go to Spring Initializr or use the following pom.xml dependencies. We need:

  • spring-boot-starter-web — REST controllers and embedded Tomcat
  • spring-boot-starter-security — Spring Security 6 auto-configuration
  • spring-boot-starter-data-jpa — JPA for user persistence
  • jjwt-api, jjwt-impl, jjwt-jackson — JJWT 0.12.x for token operations
  • h2 — In-memory database for development
  • lombok — Reduce boilerplate (optional)
xml
1<?xml version="1.0" encoding="UTF-8"?>
2<project xmlns="http://maven.apache.org/POM/4.0.0"
3         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
5         https://maven.apache.org/xsd/maven-4.0.0.xsd">
6    <modelVersion>4.0.0</modelVersion>
7
8    <parent>
9        <groupId>org.springframework.boot</groupId>
10        <artifactId>spring-boot-starter-parent</artifactId>
11        <version>3.2.5</version>
12        <relativePath/>
13    </parent>
14
15    <groupId>com.example</groupId>
16    <artifactId>jwt-security-demo</artifactId>
17    <version>1.0.0</version>
18    <name>jwt-security-demo</name>
19    <description>JWT Authentication with Spring Boot 3</description>
20
21    <properties>
22        <java.version>17</java.version>
23        <jjwt.version>0.12.6</jjwt.version>
24    </properties>
25
26    <dependencies>
27        <dependency>
28            <groupId>org.springframework.boot</groupId>
29            <artifactId>spring-boot-starter-web</artifactId>
30        </dependency>
31        <dependency>
32            <groupId>org.springframework.boot</groupId>
33            <artifactId>spring-boot-starter-security</artifactId>
34        </dependency>
35        <dependency>
36            <groupId>org.springframework.boot</groupId>
37            <artifactId>spring-boot-starter-data-jpa</artifactId>
38        </dependency>
39
40        <!-- JJWT 0.12.x -->
41        <dependency>
42            <groupId>io.jsonwebtoken</groupId>
43            <artifactId>jjwt-api</artifactId>
44            <version>${jjwt.version}</version>
45        </dependency>
46        <dependency>
47            <groupId>io.jsonwebtoken</groupId>
48            <artifactId>jjwt-impl</artifactId>
49            <version>${jjwt.version}</version>
50            <scope>runtime</scope>
51        </dependency>
52        <dependency>
53            <groupId>io.jsonwebtoken</groupId>
54            <artifactId>jjwt-jackson</artifactId>
55            <version>${jjwt.version}</version>
56            <scope>runtime</scope>
57        </dependency>
58
59        <!-- Database -->
60        <dependency>
61            <groupId>com.h2database</groupId>
62            <artifactId>h2</artifactId>
63            <scope>runtime</scope>
64        </dependency>
65
66        <!-- Lombok (optional) -->
67        <dependency>
68            <groupId>org.projectlombok</groupId>
69            <artifactId>lombok</artifactId>
70            <optional>true</optional>
71        </dependency>
72
73        <!-- Test -->
74        <dependency>
75            <groupId>org.springframework.boot</groupId>
76            <artifactId>spring-boot-starter-test</artifactId>
77            <scope>test</scope>
78        </dependency>
79        <dependency>
80            <groupId>org.springframework.security</groupId>
81            <artifactId>spring-security-test</artifactId>
82            <scope>test</scope>
83        </dependency>
84    </dependencies>
85
86    <build>
87        <plugins>
88            <plugin>
89                <groupId>org.springframework.boot</groupId>
90                <artifactId>spring-boot-maven-plugin</artifactId>
91                <configuration>
92                    <excludes>
93                        <exclude>
94                            <groupId>org.projectlombok</groupId>
95                            <artifactId>lombok</artifactId>
96                        </exclude>
97                    </excludes>
98                </configuration>
99            </plugin>
100        </plugins>
101    </build>
102</project>

Now configure application.yml with the JWT secret and expiration time:

yaml
1# src/main/resources/application.yml
2spring:
3  datasource:
4    url: jdbc:h2:mem:jwtdb
5    driver-class-name: org.h2.Driver
6    username: sa
7    password:
8  jpa:
9    hibernate:
10      ddl-auto: create-drop
11    show-sql: true
12  h2:
13    console:
14      enabled: true
15
16jwt:
17  secret: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
18  expiration: 3600000  # 1 hour in milliseconds

Creating the JWT Service

The JwtService is the core class responsible for generating, validating, and parsing tokens. We use the JJWT 0.12.x API, which introduced significant changes from 0.11.x — notably the use of SecretKey objects instead of raw strings and a builder-based parser.

java
1package com.example.jwtsecuritydemo.security;
2
3import io.jsonwebtoken.Claims;
4import io.jsonwebtoken.Jwts;
5import io.jsonwebtoken.io.Decoders;
6import io.jsonwebtoken.security.Keys;
7import org.springframework.beans.factory.annotation.Value;
8import org.springframework.security.core.userdetails.UserDetails;
9import org.springframework.stereotype.Service;
10
11import javax.crypto.SecretKey;
12import java.util.Date;
13import java.util.HashMap;
14import java.util.Map;
15import java.util.function.Function;
16
17@Service
18public class JwtService {
19
20    @Value("${jwt.secret}")
21    private String secretKey;
22
23    @Value("${jwt.expiration}")
24    private long jwtExpiration;
25
26    /**
27     * Extract the username (subject) from the token.
28     */
29    public String extractUsername(String token) {
30        return extractClaim(token, Claims::getSubject);
31    }
32
33    /**
34     * Extract a specific claim using a resolver function.
35     */
36    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
37        final Claims claims = extractAllClaims(token);
38        return claimsResolver.apply(claims);
39    }
40
41    /**
42     * Generate a token with no extra claims.
43     */
44    public String generateToken(UserDetails userDetails) {
45        return generateToken(new HashMap<>(), userDetails);
46    }
47
48    /**
49     * Generate a token with extra claims (roles, permissions, etc.).
50     */
51    public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
52        return buildToken(extraClaims, userDetails, jwtExpiration);
53    }
54
55    /**
56     * Validate the token against the UserDetails.
57     */
58    public boolean isTokenValid(String token, UserDetails userDetails) {
59        final String username = extractUsername(token);
60        return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
61    }
62
63    /**
64     * Check if the token has expired.
65     */
66    public boolean isTokenExpired(String token) {
67        return extractExpiration(token).before(new Date());
68    }
69
70    public long getExpirationTime() {
71        return jwtExpiration;
72    }
73
74    // ── Private helpers ──────────────────────────────────────────────
75
76    private Date extractExpiration(String token) {
77        return extractClaim(token, Claims::getExpiration);
78    }
79
80    private String buildToken(Map<String, Object> extraClaims,
81                              UserDetails userDetails,
82                              long expiration) {
83        return Jwts.builder()
84                .claims(extraClaims)
85                .subject(userDetails.getUsername())
86                .issuedAt(new Date(System.currentTimeMillis()))
87                .expiration(new Date(System.currentTimeMillis() + expiration))
88                .signWith(getSigningKey())
89                .compact();
90    }
91
92    private Claims extractAllClaims(String token) {
93        return Jwts.parser()
94                .verifyWith(getSigningKey())
95                .build()
96                .parseSignedClaims(token)
97                .getPayload();
98    }
99
100    private SecretKey getSigningKey() {
101        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
102        return Keys.hmacShaKeyFor(keyBytes);
103    }
104}
JJWT 0.12.x changes: The old Jwts.parser().setSigningKey(String) API is deprecated. In 0.12.x, you must use Jwts.parser().verifyWith(SecretKey) and parseSignedClaims() instead of parseClaimsJws(). The builder also uses .claims() instead of .setClaims().

Implementing the JWT Filter

The JwtAuthenticationFilter intercepts every HTTP request, extracts the JWT from the Authorization header, validates it, and sets the authentication in the SecurityContext. By extending OncePerRequestFilter, we guarantee it runs exactly once per request.

java
1package com.example.jwtsecuritydemo.security;
2
3import jakarta.servlet.FilterChain;
4import jakarta.servlet.ServletException;
5import jakarta.servlet.http.HttpServletRequest;
6import jakarta.servlet.http.HttpServletResponse;
7import org.springframework.lang.NonNull;
8import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
9import org.springframework.security.core.context.SecurityContextHolder;
10import org.springframework.security.core.userdetails.UserDetails;
11import org.springframework.security.core.userdetails.UserDetailsService;
12import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
13import org.springframework.stereotype.Component;
14import org.springframework.web.filter.OncePerRequestFilter;
15
16import java.io.IOException;
17
18@Component
19public class JwtAuthenticationFilter extends OncePerRequestFilter {
20
21    private final JwtService jwtService;
22    private final UserDetailsService userDetailsService;
23
24    public JwtAuthenticationFilter(JwtService jwtService,
25                                   UserDetailsService userDetailsService) {
26        this.jwtService = jwtService;
27        this.userDetailsService = userDetailsService;
28    }
29
30    @Override
31    protected void doFilterInternal(
32            @NonNull HttpServletRequest request,
33            @NonNull HttpServletResponse response,
34            @NonNull FilterChain filterChain
35    ) throws ServletException, IOException {
36
37        // 1. Extract the Authorization header
38        final String authHeader = request.getHeader("Authorization");
39
40        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
41            filterChain.doFilter(request, response);
42            return;
43        }
44
45        // 2. Extract the token (remove "Bearer " prefix)
46        final String jwt = authHeader.substring(7);
47        final String username = jwtService.extractUsername(jwt);
48
49        // 3. If we have a username and no existing authentication
50        if (username != null &&
51                SecurityContextHolder.getContext().getAuthentication() == null) {
52
53            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
54
55            // 4. Validate token and set authentication
56            if (jwtService.isTokenValid(jwt, userDetails)) {
57                UsernamePasswordAuthenticationToken authToken =
58                        new UsernamePasswordAuthenticationToken(
59                                userDetails,
60                                null,
61                                userDetails.getAuthorities()
62                        );
63                authToken.setDetails(
64                        new WebAuthenticationDetailsSource().buildDetails(request)
65                );
66                SecurityContextHolder.getContext().setAuthentication(authToken);
67            }
68        }
69
70        filterChain.doFilter(request, response);
71    }
72}
Security note: Never catch exceptions silently inside the filter. If extractUsername throws an ExpiredJwtException or MalformedJwtException, the filter will not set the authentication, and Spring Security will return a 401. In a production app, you may want to add an AuthenticationEntryPoint for cleaner error responses.

Spring Security Configuration

Now we configure Spring Security to use our JWT filter. The key points are: disable CSRF (since we use stateless tokens, not cookies), set session management to STATELESS, and register our JWT filter before the default UsernamePasswordAuthenticationFilter.

java
1package com.example.jwtsecuritydemo.security;
2
3import org.springframework.context.annotation.Bean;
4import org.springframework.context.annotation.Configuration;
5import org.springframework.security.authentication.AuthenticationManager;
6import org.springframework.security.authentication.AuthenticationProvider;
7import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
8import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
9import org.springframework.security.config.annotation.web.builders.HttpSecurity;
10import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
11import org.springframework.security.config.http.SessionCreationPolicy;
12import org.springframework.security.core.userdetails.UserDetailsService;
13import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
14import org.springframework.security.crypto.password.PasswordEncoder;
15import org.springframework.security.web.SecurityFilterChain;
16import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
17
18@Configuration
19@EnableWebSecurity
20public class SecurityConfig {
21
22    private final JwtAuthenticationFilter jwtAuthFilter;
23    private final UserDetailsService userDetailsService;
24
25    public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter,
26                          UserDetailsService userDetailsService) {
27        this.jwtAuthFilter = jwtAuthFilter;
28        this.userDetailsService = userDetailsService;
29    }
30
31    @Bean
32    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
33        http
34            .csrf(csrf -> csrf.disable())
35            .authorizeHttpRequests(auth -> auth
36                .requestMatchers("/auth/**", "/h2-console/**").permitAll()
37                .anyRequest().authenticated()
38            )
39            .sessionManagement(session -> session
40                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
41            )
42            .authenticationProvider(authenticationProvider())
43            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
44
45        // Allow H2 console frames (dev only)
46        http.headers(headers -> headers
47            .frameOptions(frame -> frame.sameOrigin())
48        );
49
50        return http.build();
51    }
52
53    @Bean
54    public AuthenticationProvider authenticationProvider() {
55        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
56        provider.setUserDetailsService(userDetailsService);
57        provider.setPasswordEncoder(passwordEncoder());
58        return provider;
59    }
60
61    @Bean
62    public AuthenticationManager authenticationManager(
63            AuthenticationConfiguration config) throws Exception {
64        return config.getAuthenticationManager();
65    }
66
67    @Bean
68    public PasswordEncoder passwordEncoder() {
69        return new BCryptPasswordEncoder();
70    }
71}
Why disable CSRF? CSRF protection is essential for cookie-based authentication because browsers automatically attach cookies to requests. With JWT in the Authorization header, the browser never sends the token automatically — the client must explicitly add it. This makes CSRF attacks irrelevant for Bearer token auth.

Authentication Endpoint

Let's build the complete authentication flow: a UserEntity, a UserRepository, DTOs using Java records, an AuthService, and the AuthController with register and login endpoints.

User Entity

java
1package com.example.jwtsecuritydemo.user;
2
3import jakarta.persistence.*;
4import lombok.*;
5import org.springframework.security.core.GrantedAuthority;
6import org.springframework.security.core.authority.SimpleGrantedAuthority;
7import org.springframework.security.core.userdetails.UserDetails;
8
9import java.util.Collection;
10import java.util.List;
11
12@Entity
13@Table(name = "app_user")
14@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
15public class UserEntity implements UserDetails {
16
17    @Id
18    @GeneratedValue(strategy = GenerationType.IDENTITY)
19    private Long id;
20
21    @Column(nullable = false, unique = true)
22    private String email;
23
24    @Column(nullable = false)
25    private String password;
26
27    @Column(nullable = false)
28    private String fullName;
29
30    @Enumerated(EnumType.STRING)
31    @Column(nullable = false)
32    private Role role;
33
34    @Override
35    public Collection<? extends GrantedAuthority> getAuthorities() {
36        return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
37    }
38
39    @Override
40    public String getUsername() {
41        return email;
42    }
43
44    @Override
45    public boolean isAccountNonExpired()     { return true; }
46    @Override
47    public boolean isAccountNonLocked()      { return true; }
48    @Override
49    public boolean isCredentialsNonExpired() { return true; }
50    @Override
51    public boolean isEnabled()               { return true; }
52}
53
54enum Role {
55    USER, ADMIN
56}

User Repository

java
1package com.example.jwtsecuritydemo.user;
2
3import org.springframework.data.jpa.repository.JpaRepository;
4import java.util.Optional;
5
6public interface UserRepository extends JpaRepository<UserEntity, Long> {
7    Optional<UserEntity> findByEmail(String email);
8    boolean existsByEmail(String email);
9}

DTO Records

java
1package com.example.jwtsecuritydemo.auth;
2
3// Request DTOs
4public record RegisterRequest(
5    String email,
6    String password,
7    String fullName
8) {}
9
10public record LoginRequest(
11    String email,
12    String password
13) {}
14
15// Response DTO
16public record AuthResponse(
17    String token,
18    long expiresIn
19) {}

Auth Service

java
1package com.example.jwtsecuritydemo.auth;
2
3import com.example.jwtsecuritydemo.security.JwtService;
4import com.example.jwtsecuritydemo.user.Role;
5import com.example.jwtsecuritydemo.user.UserEntity;
6import com.example.jwtsecuritydemo.user.UserRepository;
7import org.springframework.security.authentication.AuthenticationManager;
8import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
9import org.springframework.security.crypto.password.PasswordEncoder;
10import org.springframework.stereotype.Service;
11
12@Service
13public class AuthService {
14
15    private final UserRepository userRepository;
16    private final PasswordEncoder passwordEncoder;
17    private final JwtService jwtService;
18    private final AuthenticationManager authenticationManager;
19
20    public AuthService(UserRepository userRepository,
21                       PasswordEncoder passwordEncoder,
22                       JwtService jwtService,
23                       AuthenticationManager authenticationManager) {
24        this.userRepository = userRepository;
25        this.passwordEncoder = passwordEncoder;
26        this.jwtService = jwtService;
27        this.authenticationManager = authenticationManager;
28    }
29
30    public AuthResponse register(RegisterRequest request) {
31        if (userRepository.existsByEmail(request.email())) {
32            throw new RuntimeException("Email already registered");
33        }
34
35        UserEntity user = UserEntity.builder()
36                .email(request.email())
37                .password(passwordEncoder.encode(request.password()))
38                .fullName(request.fullName())
39                .role(Role.USER)
40                .build();
41
42        userRepository.save(user);
43
44        String token = jwtService.generateToken(user);
45        return new AuthResponse(token, jwtService.getExpirationTime());
46    }
47
48    public AuthResponse login(LoginRequest request) {
49        authenticationManager.authenticate(
50                new UsernamePasswordAuthenticationToken(
51                        request.email(),
52                        request.password()
53                )
54        );
55
56        UserEntity user = userRepository.findByEmail(request.email())
57                .orElseThrow(() -> new RuntimeException("User not found"));
58
59        String token = jwtService.generateToken(user);
60        return new AuthResponse(token, jwtService.getExpirationTime());
61    }
62}

Auth Controller

java
1package com.example.jwtsecuritydemo.auth;
2
3import org.springframework.http.ResponseEntity;
4import org.springframework.web.bind.annotation.*;
5
6@RestController
7@RequestMapping("/auth")
8public class AuthController {
9
10    private final AuthService authService;
11
12    public AuthController(AuthService authService) {
13        this.authService = authService;
14    }
15
16    @PostMapping("/register")
17    public ResponseEntity<AuthResponse> register(@RequestBody RegisterRequest request) {
18        return ResponseEntity.ok(authService.register(request));
19    }
20
21    @PostMapping("/login")
22    public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest request) {
23        return ResponseEntity.ok(authService.login(request));
24    }
25}

Finally, we need a UserDetailsService implementation so Spring Security can load users from the database:

java
1package com.example.jwtsecuritydemo.user;
2
3import org.springframework.security.core.userdetails.UserDetails;
4import org.springframework.security.core.userdetails.UserDetailsService;
5import org.springframework.security.core.userdetails.UsernameNotFoundException;
6import org.springframework.stereotype.Service;
7
8@Service
9public class CustomUserDetailsService implements UserDetailsService {
10
11    private final UserRepository userRepository;
12
13    public CustomUserDetailsService(UserRepository userRepository) {
14        this.userRepository = userRepository;
15    }
16
17    @Override
18    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
19        return userRepository.findByEmail(username)
20                .orElseThrow(() ->
21                    new UsernameNotFoundException("User not found: " + username));
22    }
23}

Also, add a simple protected endpoint so we can test authentication:

java
1package com.example.jwtsecuritydemo.demo;
2
3import org.springframework.http.ResponseEntity;
4import org.springframework.security.core.Authentication;
5import org.springframework.web.bind.annotation.GetMapping;
6import org.springframework.web.bind.annotation.RequestMapping;
7import org.springframework.web.bind.annotation.RestController;
8
9import java.util.Map;
10
11@RestController
12@RequestMapping("/api")
13public class DemoController {
14
15    @GetMapping("/profile")
16    public ResponseEntity<Map<String, Object>> profile(Authentication authentication) {
17        return ResponseEntity.ok(Map.of(
18            "message", "You are authenticated!",
19            "user", authentication.getName(),
20            "authorities", authentication.getAuthorities()
21        ));
22    }
23}

Testing the API

Start the application and test the full flow with curl. First, register a new user:

bash
1# 1. Register a new user
2curl -s -X POST http://localhost:8080/auth/register \
3  -H "Content-Type: application/json" \
4  -d '{
5    "email": "[email protected]",
6    "password": "SecureP@ss123",
7    "fullName": "John Doe"
8  }' | jq .
9
10# Response:
11# {
12#   "token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqb2hu...",
13#   "expiresIn": 3600000
14# }
bash
1# 2. Login with the registered user
2curl -s -X POST http://localhost:8080/auth/login \
3  -H "Content-Type: application/json" \
4  -d '{
5    "email": "[email protected]",
6    "password": "SecureP@ss123"
7  }' | jq .
8
9# Response:
10# {
11#   "token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqb2hu...",
12#   "expiresIn": 3600000
13# }
bash
1# 3. Access a protected endpoint WITHOUT a token (should fail)
2curl -s -w "\nHTTP Status: %{http_code}\n" \
3  http://localhost:8080/api/profile
4
5# HTTP Status: 403
bash
1# 4. Access a protected endpoint WITH the Bearer token
2TOKEN="eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqb2hu..."
3
4curl -s http://localhost:8080/api/profile \
5  -H "Authorization: Bearer $TOKEN" | jq .
6
7# Response:
8# {
9#   "message": "You are authenticated!",
10#   "user": "[email protected]",
11#   "authorities": [{"authority": "ROLE_USER"}]
12# }
Tip: You can inspect any JWT at jwt.io by pasting the token. You will see the decoded header, payload, and be able to verify the signature. Never paste production tokens on third-party websites.

Next Steps — Part 2: Refresh Tokens

In this article, we built a complete JWT authentication system from scratch: the token service, the security filter, Spring Security configuration, and authentication endpoints. You now have a working stateless auth flow.

However, our current implementation has a significant limitation: when the access token expires, the user must log in again. In a production application, this creates a poor user experience.

In Part 2 of this course, we will solve this by implementing:

  • Refresh tokens — Long-lived tokens stored securely that can generate new access tokens
  • Token rotation — Each refresh token is single-use; using it generates a new refresh token
  • Secure storage — Storing refresh tokens in HTTP-only cookies to prevent XSS attacks
  • Database persistence — Tracking active refresh tokens for revocation capabilities
Course progress: You have completed Part 1 of 4. Continue with Part 2 to add refresh token support and make your authentication system production-ready.
Share:
CV

Cristhian Villegas

Software Engineer specializing in Java, Spring Boot, Angular & AWS. Building scalable distributed systems with clean architecture.

Comments

Sign in to leave a comment

No comments yet. Be the first!

Related Articles

Stay updated

Get notified when I publish new articles. No spam, unsubscribe anytime.