Learn how to add JWT authentication to a Spring Boot API with JJWT, including login, registration, Bearer tokens, security filters, and Postman testing.
Updated August 2026 — full tutorial restored for the InventoryMS JWT authentication URL.
TL;DR
-
JWT authentication lets InventoryMS use stateless Bearer tokens instead of Basic Auth sessions.
-
JJWT handles creating and validating signed tokens containing the authenticated user’s identity.
-
A JWT authentication filter reads the Bearer token on each request and loads the user’s current authorities.
-
Spring Security can enforce InventoryMS privileges such as
VIEW_PRODUCTandCREATE_PRODUCTon protected endpoints. -
Postman provides a simple way to test registration, login, protected requests, authorization failures, and missing tokens.
In this tutorial we add JSON Web Token (JWT) authentication to the InventoryMS Spring Boot API. After login (or register), the client receives a signed token and sends it as Authorization: Bearer … on every protected call — no Basic Auth session cookies.
Previously we covered:
- InventoryMS Complete Application – Spring Boot API
- Roles and Privileges 1 – standard roles
- Roles and Privileges 2 – data model and API
- Roles and Privileges 3 – Granted Authorities
A general JWT walkthrough (good companion) is here: Secure a REST API using JWT in Spring Boot.
We will cover:
- Why JWT for InventoryMS
- Dependencies and application properties
- JwtService – create and validate tokens
- Auth DTOs, register and login endpoints
- JwtAuthenticationFilter
- Wire SecurityFilterChain (stateless)
- Test with Postman
- Next steps (React UI)
1. Why JWT for InventoryMS
So far InventoryMS can authorize with hasAuthority("VIEW_PRODUCT") (and friends) via UserPrincipal. That works for Basic Auth, but a React SPA needs a stateless token:
- login once → receive JWT
- store token (memory or httpOnly cookie in production)
- send Bearer token on API calls
- server validates signature + expiry, then loads authorities
We keep your privilege model. JWT only changes how the user proves identity.
2. Dependencies and application properties
Step 1: In pom.xml add JJWT (versions that match your Spring Boot 3.x line):
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
Step 2: In application.properties (use a long random secret in real projects — never commit production keys):
app.jwt.secret=ChangeMeToALongRandomSecretAtLeast32Chars!!
app.jwt.expiration-ms=86400000
3. JwtService – create and validate tokens
Create security.JwtService (or jwt.JwtService) that signs with HMAC and puts the username in the subject.
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String secret;
@Value("${app.jwt.expiration-ms}")
private long expirationMs;
private SecretKey key() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(UserDetails user) {
Date now = new Date();
Date exp = new Date(now.getTime() + expirationMs);
return Jwts.builder()
.subject(user.getUsername())
.issuedAt(now)
.expiration(exp)
.signWith(key())
.compact();
}
public String extractUsername(String token) {
return Jwts.parser().verifyWith(key()).build()
.parseSignedClaims(token).getPayload().getSubject();
}
public boolean isValid(String token, UserDetails user) {
String username = extractUsername(token);
return username.equals(user.getUsername()) && !isExpired(token);
}
private boolean isExpired(String token) {
Date exp = Jwts.parser().verifyWith(key()).build()
.parseSignedClaims(token).getPayload().getExpiration();
return exp.before(new Date());
}
}
We intentionally keep authorities out of the token body for InventoryMS: privileges can change without waiting for token expiry. On each request we load UserPrincipal (and privileges) from the database using the username claim.
4. Auth DTOs, register and login endpoints
Step 1: Simple request/response records:
public record AuthRequest(String username, String password) {}
public record AuthResponse(String token, String tokenType) {
public AuthResponse(String token) { this(token, "Bearer"); }
}
public record RegisterRequest(String username, String password, String email) {}
Step 2: AuthController — keep paths matching your existing permitAll rules (/login, /register). Adjust to /api/v1/auth/... if that is how you designed routing.
@RestController
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
private final JwtService jwtService;
@PostMapping("/login")
public AuthResponse login(@RequestBody AuthRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.username(), request.password()));
UserDetails user = userDetailsService.loadUserByUsername(request.username());
return new AuthResponse(jwtService.generateToken(user));
}
@PostMapping("/register")
public AuthResponse register(@RequestBody RegisterRequest request) {
User user = new User();
user.setUsername(request.username());
user.setEmail(request.email());
user.setPassword(passwordEncoder.encode(request.password()));
userRepository.save(user);
UserDetails details = userDetailsService.loadUserByUsername(user.getUsername());
return new AuthResponse(jwtService.generateToken(details));
}
}
Wire AuthenticationManager and PasswordEncoder beans if you have not already (standard Spring Security setup from the InventoryMS security package).
5. JwtAuthenticationFilter
This filter runs once per request, reads the Bearer header, validates the JWT, and sets SecurityContextHolder.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
String token = header.substring(7);
String username = jwtService.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails user = userDetailsService.loadUserByUsername(username);
if (jwtService.isValid(token, user)) {
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(request, response);
}
}
Because UserDetailsService returns your UserPrincipal, getAuthorities() still comes from UserPrivilegeAssignment — same as Part 3.
6. Wire SecurityFilterChain (stateless)
Update the chain you built in the privileges tutorial:
@Bean
SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter) throws Exception {
http.csrf(csrf -> csrf.disable())
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/register", "/login", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").hasAuthority("VIEW_PRODUCT")
.requestMatchers(HttpMethod.POST, "/api/v1/products/**").hasAuthority("CREATE_PRODUCT")
.anyRequest().authenticated())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
Disable HTTP Basic for the SPA path (or leave it only for local debugging). CSRF stays off for pure Bearer JWT APIs.
7. Test with Postman
POST /registerwith JSON{"username":"kindson","password":"Secret123","email":"k@example.com"}→ copytoken.- Or
POST /loginwith username/password → copytoken. GET /api/v1/productswith headerAuthorization: Bearer <token>.- Assign
VIEW_PRODUCTvia your privilege assignment API if the call returns 403. - Call again without the header → expect 401.
8. Next steps (React UI)
- On successful login, store the token (prefer memory + refresh flow in production).
- Axios/fetch interceptor:
config.headers.Authorization = `Bearer ${token}`. - On 401, clear token and redirect to login.
That wires cleanly into InventoryMS Complete Application – React UI.
Common mistakes
- 401 on every call: missing
Bearerprefix, wrong secret between instances, or filter not registered withaddFilterBefore. - 403 after login: token is valid but privileges are empty — assign
VIEW_PRODUCT(etc.) via the UserPrivilegeAssignment API from Part 2/3. - Weak secret: JJWT requires a long enough key for HS256; short secrets throw at runtime.
- Putting roles only in the JWT: fine for demos; for InventoryMS we reload
UserPrincipalso privilege changes apply immediately. - Leaving CSRF on while using Bearer-only SPA calls — disable CSRF for the API chain as shown above.
What you should have now
- JWT issued on register/login
- Stateless filter that authenticates Bearer tokens
- Same privilege authorities as before protecting InventoryMS modules
If anything fails, check clock skew / secret length, and confirm UserDetailsService loads the same username you put in the JWT subject. When this works end-to-end, move on to sending the token from the React UI.
Final Thought
Adding JWT authentication gives InventoryMS a stateless security model that fits naturally with a React SPA. The token proves the user’s identity, while Spring Security continues to enforce the same privilege-based authorization rules already established in the application.
A particularly useful design choice in this tutorial is keeping privileges out of the JWT and loading the current UserPrincipal on each request. This means changes to a user’s privileges can take effect without waiting for an existing token to expire.
Once registration, login, token validation, and protected API calls work end-to-end, the next step is connecting the JWT flow to the React UI. From there, you can build on the foundation with refresh-token flows, stronger token management, error handling, and production-ready secret management.