Security: Authentication & Authorization with JWT
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
SecurityFilterChainbean and the key toggles: CSRF off, CORS on, stateless sessions, entry point, authorization rules. - Password hashing with
BCryptPasswordEncoder. - What a JWT is; how
JwtUtilsigns and parses one. OncePerRequestFilterand howJwtAuthFilterpopulates theSecurityContextHolder.- Authorities, the
ROLE_convention,@EnableMethodSecurityand@PreAuthorize. - Logout as token revocation (a denylist of
jti). - Why CORS is configured and how.
Chapter Structure
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();
}
}| Line | Why |
|---|---|
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.STATELESS | Never 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.
- 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.
expmakes it expire;jtiis a unique id used here for revocation (§6.7).
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);
}
}OncePerRequestFilterguarantees 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
Authenticationis placed in theSecurityContextHolder, which is backed by aThreadLocal— visible to the controller, the method security layer andCurrentUserServicefor this thread only, then cleared.
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, ...) { ... }
}| Layer | Where | Example |
|---|---|---|
| URL rules | SecurityConfig | /api/auth/** → permitAll; rest → authenticated |
| Method rules | @PreAuthorize on handlers | hasRole('Admin') to create a doctor |
| Data scoping | CurrentUserService.resolveEffectiveBranchId | Non-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.
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 theAuthorizationheader; it is incompatible withallowedOrigins("*"), which is why origins are listed.SecurityConfigalsopermitAll()sOPTIONS /**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
- Storing sensitive data in JWT claims (the payload is readable).
- Committing a real
jwt.secret/ DB password (as this repo does) instead of injecting per-environment. - Authority
"Admin"instead of"ROLE_Admin", sohasRole('Admin')never matches. - Not registering the filter with
addFilterBefore, so it never runs. - Forgetting
@EnableMethodSecurity— every@PreAuthorizeis silently ignored. - Locking down
OPTIONSand breaking all browser CORS calls. allowedOrigins("*")together withallowCredentials(true)— rejected by the spec.- Trusting role/branch from the token body instead of re-loading from the DB.
- Not clearing / not scoping the
SecurityContext(Spring handles this per request only because sessions are stateless).
Revision Questions
- Define authentication vs authorization.
- Why is CSRF protection disabled here, and when would that be wrong?
- What does
SessionCreationPolicy.STATELESSchange? - What are the three parts of a JWT and what does the signature guarantee?
- Which failures does
isTokenValidcollapse intofalse? - Why does
JwtAuthFilterextendOncePerRequestFilter? - Why does the filter call
chain.doFiltereven when there is no token? - How do
"ROLE_" + roleNameandhasRole('Admin')connect? - How is logout implemented without server sessions, and what does it cost?
- Why must
OPTIONS /**bepermitAll()?
Practice
GET /api/reports/** to hasAnyRole('Admin','Receptionist') and verify a Doctor token gets 403.jwt.expiration to 60000 and watch a request fail with 401 after a minute; trace where the 401 is produced.@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.
Teaching note: all snippets are quoted from config/SecurityConfig.java, auth/jwt/* and auth/service/AuthService.java, lightly trimmed with // ....