Security: Authentication & Authorization with JWT

A stateless filter chain that proves who you are and method rules that decide what you may do
Big idea: Spring Security is a chain of servlet filters that runs before your controllers. Authentication establishes identity; authorization checks permission. This project is stateless: there is no server session — each request carries a signed JWT in the Authorization header, a custom filter validates it and puts an Authentication into the SecurityContext for the duration of that one request.

What You Will Learn

  • The SecurityFilterChain bean and the key toggles: CSRF off, CORS on, stateless sessions, entry point, authorization rules.
  • Password hashing with BCryptPasswordEncoder.
  • What a JWT is; how JwtUtil signs and parses one.
  • OncePerRequestFilter and how JwtAuthFilter populates the SecurityContextHolder.
  • Authorities, the ROLE_ convention, @EnableMethodSecurity and @PreAuthorize.
  • Logout as token revocation (a denylist of jti).
  • Why CORS is configured and how.

Chapter Structure

6.1 The filter chain6.2 Login & password hashing6.3 What is a JWT6.4 JwtUtil6.5 JwtAuthFilter6.6 Authorities & @PreAuthorize6.7 Logout / revocation6.8 CORS6.9 Wrap-up

6.1 The Security Filter Chain

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthFilter jwtAuthFilter;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .exceptionHandling(ex -> ex.authenticationEntryPoint(jwtAuthenticationEntryPoint))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                .requestMatchers("/api/auth/**", "/error").permitAll()
                .anyRequest().authenticated())
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }
}
LineWhy
csrf(... disable)CSRF tokens defend cookie-based sessions. This API has no session and no auth cookie — the credential is a header the browser will not attach automatically — so CSRF protection is unnecessary.
cors(...)Enable the CORS filter using the config in §6.8.
SessionCreationPolicy.STATELESSNever create an HttpSession; never store the SecurityContext between requests. Identity is rebuilt from the token every time.
authenticationEntryPoint(...)What to do when an unauthenticated request hits a protected route — here, return 401 (see JwtAuthenticationEntryPoint).
authorizeHttpRequests(...)URL-level rules, evaluated top-down. /api/auth/** and /error are open; pre-flight OPTIONS is open; everything else requires authentication.
addFilterBefore(jwtAuthFilter, ...)Slot the custom filter into the chain before the username/password filter, so the token is processed early.

@EnableWebSecurity activates the chain; @EnableMethodSecurity activates @PreAuthorize (§6.6).

6.2 Login and Password Hashing

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

Passwords are stored as bcrypt hashes, never plaintext. Bcrypt is deliberately slow and salts each hash, so two users with the same password get different stored values and brute force is expensive. AuthService.login verifies with:

if (!passwordEncoder.matches(request.getPassword(), user.getPassword()))
    throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials");

matches(raw, hashed) re-hashes the incoming password with the stored salt and compares. On full success the service mints a token:

String token = jwtUtil.generateToken(user.getEmail(), user.getRole().getName());
return new AuthResponse(user.getId(), token, user.getEmail(),
        user.getRole().getName(), user.getRole().getId(), user.getName(), branchId);

6.3 What Is a JWT

A JSON Web Token is three base64url parts joined by dots: header.payload.signature.

header {alg, typ}.payload (claims: sub, role, iat, exp, jti).HMAC-SHA256(header + payload, secret)
  • The payload is encoded, not encrypted — anyone can read it. Put no secrets in it.
  • The signature proves the token was issued by someone holding the secret and has not been altered.
  • exp makes it expire; jti is a unique id used here for revocation (§6.7).
Config: jwt.expiration: 86400000 ms = 24 h. jwt.secret is a base64 value at least 256 bits long (required for HS256).

6.4 JwtUtil — Signing and Parsing

@Component
public class JwtUtil {

    @Value("${jwt.secret}") private String secret;
    @Value("${jwt.expiration}") private long expiration;

    private SecretKey getKey() {
        return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
    }

    public String generateToken(String email, String role) {
        return Jwts.builder()
                .subject(email)
                .id(UUID.randomUUID().toString())          // jti
                .claim("role", role)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getKey())
                .compact();
    }

    public boolean isTokenValid(String token) {
        try { parseClaims(token); return true; }
        catch (JwtException | IllegalArgumentException e) { return false; }
    }

    private Claims parseClaims(String token) {
        return Jwts.parser().verifyWith(getKey()).build()
                .parseSignedClaims(token).getPayload();
    }

    public String extractEmail(String t)      { return parseClaims(t).getSubject(); }
    public String extractRole(String t)       { return parseClaims(t).get("role", String.class); }
    public String extractJti(String t)        { return parseClaims(t).getId(); }
    public Date   extractExpiration(String t) { return parseClaims(t).getExpiration(); }
}

parseSignedClaims throws if the signature is wrong or the token is expired — so isTokenValid catching JwtException covers "tampered", "wrong key" and "expired" in one place. The library is jjwt 0.12 (jjwt-api / -impl / -jackson in pom.xml).

6.5 JwtAuthFilter — Identity per Request

@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserRepository userRepository;
    private final RevokedTokenRepository revokedTokenRepository;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            chain.doFilter(request, response);          // no token → carry on unauthenticated
            return;
        }

        String token = authHeader.substring(7);
        if (!jwtUtil.isTokenValid(token)
                || revokedTokenRepository.existsByJti(jwtUtil.extractJti(token))) {
            chain.doFilter(request, response);          // bad or revoked → stay unauthenticated
            return;
        }

        String email = jwtUtil.extractEmail(token);
        if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            userRepository.findByEmail(email).ifPresent(user -> {
                String roleName = user.getRole().getName();
                var auth = new UsernamePasswordAuthenticationToken(
                        email, null, List.of(new SimpleGrantedAuthority("ROLE_" + roleName)));
                auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(auth);
            });
        }
        chain.doFilter(request, response);
    }
}
  • OncePerRequestFilter guarantees the logic runs exactly once per request even with internal dispatches (e.g. the forward to /error).
  • Fail open, not closed. A missing or bad token does not reject the request here — the filter just leaves the context empty and calls chain.doFilter. The authorization rules (anyRequest().authenticated()) plus the entry point produce the 401 later. This keeps public routes reachable without a token.
  • Role is re-read from the DB every request, and so is branch (see CurrentUserService, Chapter 7) — nothing but identity is trusted from the token body. An admin changing a user's role takes effect on that user's next call.
  • The Authentication is placed in the SecurityContextHolder, which is backed by a ThreadLocal — visible to the controller, the method security layer and CurrentUserService for this thread only, then cleared.
Authorization: Bearer …valid signature & not expired?jti not in revoked_token?load User by emailset Authentication with ROLE_<name>controller

6.6 Authorities, ROLE_ and @PreAuthorize

The filter grants one authority: new SimpleGrantedAuthority("ROLE_" + roleName) — e.g. ROLE_Admin, ROLE_Doctor, ROLE_Receptionist. Spring's hasRole("Admin") automatically prepends ROLE_, so the two line up.

@RestController
@RequestMapping("/api/doctors")
public class DoctorController {

    @GetMapping                                   // any authenticated user
    public ResponseEntity<List<DoctorResponse>> getAllDoctors(...) { ... }

    @PostMapping
    @PreAuthorize("hasRole('Admin')")             // Admins only
    public ResponseEntity<DoctorResponse> createDoctor(@RequestBody @Valid DoctorRequest r) { ... }

    @PutMapping("/{id}")
    @PreAuthorize("hasRole('Admin')")
    public ResponseEntity<DoctorResponse> updateDoctor(@PathVariable Long id, ...) { ... }
}
LayerWhereExample
URL rulesSecurityConfig/api/auth/** → permitAll; rest → authenticated
Method rules@PreAuthorize on handlershasRole('Admin') to create a doctor
Data scopingCurrentUserService.resolveEffectiveBranchIdNon-admins forced to their own branch

@PreAuthorize takes a SpEL expression evaluated before the method runs; other useful forms: hasAnyRole('Admin','Receptionist'), isAuthenticated(), #id == authentication.name. A failed check throws AccessDeniedException → 403.

6.7 Logout as Token Revocation

A signed JWT is valid until it expires — the server cannot "delete" it. To support logout, the project keeps a denylist. AuthService.logout:

public void logout(String token) {
    if (!jwtUtil.isTokenValid(token))
        throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid token");

    String jti = jwtUtil.extractJti(token);
    if (revokedTokenRepository.existsByJti(jti)) return;   // already revoked, idempotent

    RevokedToken revokedToken = new RevokedToken();
    revokedToken.setJti(jti);
    revokedToken.setExpiresAt(jwtUtil.extractExpiration(token)
            .toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime());
    revokedTokenRepository.save(revokedToken);
}

Every subsequent request is checked against it in JwtAuthFilter: revokedTokenRepository.existsByJti(jwtUtil.extractJti(token)). Storing expiresAt lets a scheduled job prune rows once the underlying token would have expired anyway, so the table stays small.

Trade-off: pure JWT statelessness is relaxed by one DB lookup per request. That is the standard price of server-side logout. Alternatives: very short token lifetimes + refresh tokens, or a cache (Redis) denylist instead of a table.

6.8 CORS

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of(
        "http://localhost:3000", "http://localhost:3001",
        "https://main.d287d0opqvrvdj.amplifyapp.com",
        "https://sms-bhima.duckdns.org"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(List.of("*"));
    config.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);
    return source;
}

A browser calling this API from a different origin (the React frontend) first sends a pre-flight OPTIONS. The server must answer with the right Access-Control-Allow-* headers or the browser blocks the real call. Notes:

  • The allowed origins are an explicit allowlist — the deployed frontends plus local dev.
  • allowCredentials(true) permits the Authorization header; it is incompatible with allowedOrigins("*"), which is why origins are listed.
  • SecurityConfig also permitAll()s OPTIONS /** so pre-flight is never itself rejected for lack of a token.

Quick Concept Map

Filter chain → runs before controllers; CSRF off, CORS on, stateless

Authentication = who · Authorization = what

bcrypt → salted, slow password hashing; encoder.matches(raw, hash)

JWT = header.payload.signature; encoded not encrypted; has exp, jti

JwtUtil → sign with HS256 + secret; parse verifies signature & expiry

JwtAuthFilter (OncePerRequestFilter) → validate → check revoked → load user → set SecurityContext

Authority ROLE_<name>hasRole('name'); @PreAuthorize for method rules

Logout → save jti to revoked_token denylist

CORS → explicit origin allowlist + OPTIONS permitted

Common Mistakes

  1. Storing sensitive data in JWT claims (the payload is readable).
  2. Committing a real jwt.secret / DB password (as this repo does) instead of injecting per-environment.
  3. Authority "Admin" instead of "ROLE_Admin", so hasRole('Admin') never matches.
  4. Not registering the filter with addFilterBefore, so it never runs.
  5. Forgetting @EnableMethodSecurity — every @PreAuthorize is silently ignored.
  6. Locking down OPTIONS and breaking all browser CORS calls.
  7. allowedOrigins("*") together with allowCredentials(true) — rejected by the spec.
  8. Trusting role/branch from the token body instead of re-loading from the DB.
  9. Not clearing / not scoping the SecurityContext (Spring handles this per request only because sessions are stateless).

Revision Questions

  1. Define authentication vs authorization.
  2. Why is CSRF protection disabled here, and when would that be wrong?
  3. What does SessionCreationPolicy.STATELESS change?
  4. What are the three parts of a JWT and what does the signature guarantee?
  5. Which failures does isTokenValid collapse into false?
  6. Why does JwtAuthFilter extend OncePerRequestFilter?
  7. Why does the filter call chain.doFilter even when there is no token?
  8. How do "ROLE_" + roleName and hasRole('Admin') connect?
  9. How is logout implemented without server sessions, and what does it cost?
  10. Why must OPTIONS /** be permitAll()?

Practice

1. Add a rule. Restrict GET /api/reports/** to hasAnyRole('Admin','Receptionist') and verify a Doctor token gets 403.
2. Shorten the token. Set jwt.expiration to 60000 and watch a request fail with 401 after a minute; trace where the 401 is produced.
3. Prune job. Write a @Scheduled method that deletes RevokedToken rows whose expiresAt is in the past.

Chapter Wrap-Up

Security is a filter chain that runs first. AuthService checks a bcrypt password and issues a signed JWT; JwtAuthFilter validates that token on every request, rejects revoked ones, reloads the user, and installs an Authentication with a ROLE_ authority. URL rules in SecurityConfig and @PreAuthorize on handlers together decide access. Logout is a jti denylist, and CORS is an explicit origin allowlist with pre-flight left open.

Next chapter: the cross-cutting glue — CORS, configuration & profiles, request-scoped context, auditing columns, activity logging, and how one request flows through every layer.

Teaching note: all snippets are quoted from config/SecurityConfig.java, auth/jwt/* and auth/service/AuthService.java, lightly trimmed with // ....