JWT in Spring Boot 3 Course #1: Fundamentals and Initial Setup
![]()
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
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.
JWT Structure
A JWT consists of three parts separated by dots (.): Header, Payload, and Signature.
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:
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)
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:
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.
| Feature | JWT (Stateless) | Server-Side Sessions |
|---|---|---|
| State storage | Client-side (token) | Server-side (memory/DB/Redis) |
| Scalability | Excellent — no shared state | Requires sticky sessions or shared store |
| Revocation | Hard — needs blacklist (covered in Part 4) | Easy — delete session from store |
| Payload size | Larger (contains claims) | Small (just session ID) |
| Cross-domain | Works natively | Requires CORS cookie config |
| Microservices | Ideal — any service can validate | Needs centralized session store |
| Mobile clients | Natural fit | Cookie handling can be tricky |
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)
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:
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.
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}
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.
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}
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.
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}
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
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
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
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
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
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:
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:
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:
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# }
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# }
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
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# }
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
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.