September 3, 2026

InventoryMS – User Authentication with JWT

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_PRODUCT and CREATE_PRODUCT on 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:

A general JWT walkthrough (good companion) is here: Secure a REST API using JWT in Spring Boot.

We will cover:

  1. Why JWT for InventoryMS
  2. Dependencies and application properties
  3. JwtService – create and validate tokens
  4. Auth DTOs, register and login endpoints
  5. JwtAuthenticationFilter
  6. Wire SecurityFilterChain (stateless)
  7. Test with Postman
  8. 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

  1. POST /register with JSON {"username":"kindson","password":"Secret123","email":"k@example.com"} → copy token.
  2. Or POST /login with username/password → copy token.
  3. GET /api/v1/products with header Authorization: Bearer <token>.
  4. Assign VIEW_PRODUCT via your privilege assignment API if the call returns 403.
  5. 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 Bearer prefix, wrong secret between instances, or filter not registered with addFilterBefore.
  • 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 UserPrincipal so 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.

Kindson Munonye

Kindson Munonye is a software engineer and technical author covering machine learning, statistics, REST APIs, Python, and software engineering. He publishes free tutorials on The Genius Blog and live classes on Alkademy. GitHub · LinkedIn · About · Alkademy

View all posts by Kindson Munonye →
0 0 votes
Article Rating
Subscribe
Notify of
guest

0 Comments
Oldest
Newest Most Voted