DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

OrderHub Day 35: a signed HS256 JWT means log in once, verify per request, and no session — 401 vs 403

Day 34 of building OrderHub authenticated every request with HTTP Basic — the password re-sent on every call, re-hashed and re-checked server-side each time. It works, but the long-lived password is repeatedly exposed and (with sessions) the server has to remember who's logged in. Day 35 swaps that for a JWT: the client logs in once at POST /auth/login to get a short-lived, HS256-signed token, then sends it as Authorization: Bearer <token>. The server keeps no session — it just re-verifies the signature and expiry on each request. That's what "stateless" means, and it's why any instance can serve any request. Here's how I wired issuing and validating in Spring Boot.

Issue: mint and SIGN the token

The heart of issuing is building the claims and HMAC-signing them. The subject is the username, a roles claim carries the authorities, and iat/exp bound the token's life:

public String issue(String subject, List<String> roles, Duration ttl) {
  Instant now = Instant.now();
  return Jwts.builder()
      .subject(subject).claim("roles", roles)
      .issuedAt(Date.from(now))
      .expiration(Date.from(now.plus(ttl)))   // short exp
      .signWith(key)                          // HS256 over header+payload
      .compact();                             // header.payload.signature
}
// key = Keys.hmacShaKeyFor(secret.getBytes(UTF_8))  // >= 32 bytes required
Enter fullscreen mode Exit fullscreen mode

signWith computes an HMAC-SHA256 of the header and payload using a key derived from a server-only secret, and Keys.hmacShaKeyFor enforces a ≥ 256-bit key so a too-short secret fails fast at startup. AuthController exposes the one public credential exchange: it loads the same Day-34 users, checks the password against the stored BCrypt hash with passwordEncoder.matches(), and on success returns the token. On bad credentials it returns a bare 401 via ResponseEntity — deliberately not by throwing, so it never routes through the @RestControllerAdvice catch-all that maps unexpected exceptions to 500.

Validate: a filter that verifies signature + expiry per request

The validating half is a JwtAuthenticationFilter — a OncePerRequestFilter that runs before Spring's authorization filter. It reads the Bearer header, and if there's a token, parse() recomputes the HMAC with the secret and checks exp:

String h = req.getHeader(HttpHeaders.AUTHORIZATION);
if (h != null && h.startsWith("Bearer ")) {
  try {
    Claims c = jwtService.parse(h.substring(7).trim());   // verify sig + exp (throws)
    var authorities = ((List<?>) c.get("roles", List.class)).stream()
        .map(Object::toString).map(SimpleGrantedAuthority::new).toList();
    var auth = new UsernamePasswordAuthenticationToken(c.getSubject(), null, authorities);
    SecurityContextHolder.getContext().setAuthentication(auth);
  } catch (JwtException | IllegalArgumentException e) {
    SecurityContextHolder.clearContext();                 // tampered / expired / malformed
  }
}
chain.doFilter(req, res);
Enter fullscreen mode Exit fullscreen mode

On success it builds an Authentication from the subject and roles claim and puts it in the SecurityContext, so the downstream hasRole rules apply exactly as they did under Basic. If parse() throws — bad signature, past exp — it clears the context and continues unauthenticated.

Stateless is the defining choice

JwtSecurityConfig sets SessionCreationPolicy.STATELESS, so Spring creates and consults no HttpSession — identity comes only from the token on each request, which is what lets any instance verify any token and scale horizontally. The filter is added before UsernamePasswordAuthenticationFilter so the token becomes an Authentication before authorization runs; CSRF is disabled (a token API has no cookie session to ride); and an explicit HttpStatusEntryPoint(401) means a missing or invalid token yields a clean 401 instead of a login redirect. /auth/login, health and docs are permitAll; reads need ROLE_USER, writes ROLE_ADMIN.

Why 401 and 403 are different signals

The two failure codes come from two different gates, in order. Verification (in the filter) asks "is this a genuine, current token?" — a missing token, a tampered payload (SignatureException) or a past exp (ExpiredJwtException) all fail here, and because no identity could be established, produce a 401. Only a token that verifies advances to authorization, which asks "does this identity hold the required role?" — a valid user token hitting an admin-only write fails there with a 403. It's the same distinction Day 34 drew (401 = who are you, 403 = you may not), just relocated to the token. Retrying a 401 means getting a fresh token; retrying a 403 with the same token never helps. The whole feature is gated behind orderhub.security.jwt.enabled, so exactly one filter chain is ever active and all 107 prior tests stay green — a @WebMvcTest then asserts token→200, none/tampered/expired→401, wrong-role→403, taking the reactor to 115.

Send tokens through the chain and watch where each one stops:
https://dev48v.infy.uk/orderhub.php
Repo: https://github.com/dev48v/order-hub-from-zero

Top comments (0)