1. The Security Filter Chain
The moment you add spring-boot-starter-security to the project you've
been building since Week 3, everything changes: every endpoint — including the
Task endpoints from Weeks 3 through 5 — suddenly requires a login, and Spring Boot
prints a randomly generated password to the console on startup. That's not a bug,
it's the starter's deliberately paranoid default: secure by default, then
you loosen it explicitly.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Under the hood, Spring Security works by inserting a chain of servlet
filters in front of Spring MVC's dispatcher. A single incoming HTTP request
passes through this chain — filter by filter — before it ever reaches a
@RestController method. Each filter has one job: extracting
credentials, validating a session, checking CSRF tokens, enforcing authorization
rules, and so on. If any filter rejects the request, it short-circuits the chain and
a response goes straight back to the client — your controller code never runs.
Client request
│
▼
[ Filter 1: extract credentials (e.g. our JwtAuthenticationFilter) ]
│
▼
[ Filter 2: check the SecurityContext is populated ]
│
▼
[ Filter N: authorization decision -- is this path allowed? ]
│
▼
DispatcherServlet -> @RestController method
You configure this chain declaratively with a SecurityFilterChain
@Bean, using HttpSecurity's fluent builder. This single
bean is where almost all of this week's configuration lives:
package com.codeverse.week06.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http.build();
// We'll fill this builder in section 4 -- for now, know that this
// bean is *the* place Spring Security reads its rules from.
}
}
Once you define your own SecurityFilterChain bean, Spring Boot's
auto-configured default (that random console password) steps aside and your rules
take over completely.
2. Password Encoding & UserDetailsService
A rule with zero exceptions: never store a plaintext password, and never "encrypt" one either. Encryption is reversible by design — anyone with the key can recover the original password, which means a database breach or a rogue key holder exposes every user's real password. What you want instead is hashing: a one-way function where recovering the input from the output is computationally infeasible.
PasswordEncoder and BCrypt
Spring Security's PasswordEncoder interface abstracts this, and
BCryptPasswordEncoder is the standard implementation: it's a
purpose-built, adaptive hashing algorithm that's deliberately slow (to resist
brute-force attacks) and automatically salts every hash so two users with the same
password get different stored values.
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
You call encode() once, when a user registers or sets a password, and
matches() at login time -- Spring Security never decodes a hash back
into a password, because it can't:
String hashed = passwordEncoder.encode("correct-horse-battery-staple");
// $2a$10$N9qo8uLOickgx2ZMRZoMy... -- different every time, even for the same input
boolean ok = passwordEncoder.matches("correct-horse-battery-staple", hashed);
// true -- BCrypt re-derives the hash from the raw password and the embedded
// salt, then compares, instead of "decrypting" anything
The User entity and UserDetailsService
Spring Security needs to load a user's credentials and authorities somehow — that's
the job of UserDetailsService, a single-method interface you implement
against your own data. This is where the JPA work from Weeks 4 and 5 pays off
directly: a User entity is just another @Entity backed by
a Spring Data JpaRepository, exactly like the Task entity
you've already built.
package com.codeverse.week06.user;
import jakarta.persistence.*;
@Entity
@Table(name = "app_user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String passwordHash;
// getters and setters omitted for brevity
public Long getId() { return id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
}
package com.codeverse.week06.user;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}
package com.codeverse.week06.user;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;
@Service
public class AppUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public AppUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() ->
new UsernameNotFoundException("No user: " + username));
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPasswordHash())
.authorities("ROLE_USER")
.build();
}
}
UserRepository here is nothing new mechanically -- it's the same JpaRepository pattern you used for TaskRepository in Week 4, persisted the same way and queried with the same derived-query-method conventions. Spring Security only cares that you can hand it a hashed password and some authorities; how you fetch them is ordinary JPA.
3. Building Stateless JWT Authentication
A traditional web app authenticates once at login, then stores a session on the server and hands the browser a session cookie to prove who it is on every later request. That model assumes one server holding state in memory. A REST API, especially one you intend to scale horizontally or call from a mobile app or SPA, is better served by being stateless: the server keeps nothing between requests, and each request carries everything needed to authenticate it. That's exactly what a JWT (JSON Web Token) is for.
The login flow
The client sends a username and password once, to a public /auth/login
endpoint. If they check out, the server doesn't create a session — it issues a
signed token and hands it back. From then on, the client attaches that token to
every request instead of re-sending credentials or relying on a cookie:
1. POST /auth/login { "username": "ada", "password": "correct-horse..." }
-> server verifies credentials against the hashed password
-> server returns { "token": "eyJhbGciOiJIUzI1NiJ9..." }
2. GET /api/tasks
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
-> server validates the token's signature and expiry
-> server treats the request as authenticated -- no session lookup needed
What's actually inside a JWT
A JWT is three base64url-encoded segments joined by dots:
header.payload.signature. The header names the signing algorithm; the
payload is a set of claims — arbitrary JSON like the username and an expiry
timestamp; the signature is a cryptographic hash of the header and payload, computed
with a secret key only the server knows.
// header
{ "alg": "HS256", "typ": "JWT" }
// payload (claims)
{ "sub": "ada", "iat": 1735689600, "exp": 1735776000 }
// signature = HMAC-SHA256(base64(header) + "." + base64(payload), SECRET_KEY)
This is why the JWT dependency and secret matter: the header and payload are only
encoded, not encrypted — anyone can base64-decode and read them, so never
put secrets in the claims. The signature is what protects integrity: if a
client tampers with the payload (say, changing "sub": "ada" to
"sub": "admin"), the signature no longer matches, and the server
rejects the token the moment it re-derives and compares the signature.
Issuing a token
A small service wraps the JJWT library to build and later parse tokens, signed with a secret key loaded from configuration:
<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>
package com.codeverse.week06.security;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.util.Date;
@Service
public class JwtService {
private final SecretKey key;
private static final long EXPIRATION_MS = 24 * 60 * 60 * 1000; // 24 hours
public JwtService(@Value("${app.jwt.secret}") String secret) {
this.key = Keys.hmacShaKeyFor(secret.getBytes());
}
public String generateToken(String username) {
Date now = new Date();
Date expiry = new Date(now.getTime() + EXPIRATION_MS);
return Jwts.builder()
.subject(username)
.issuedAt(now)
.expiration(expiry)
.signWith(key)
.compact();
}
public String extractUsername(String token) {
return parseClaims(token).getSubject();
}
public boolean isTokenValid(String token) {
try {
Date expiration = parseClaims(token).getExpiration();
return expiration.after(new Date());
} catch (Exception ex) {
return false; // malformed, expired, or bad signature
}
}
private io.jsonwebtoken.Claims parseClaims(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
}
Validating the token on every request: JwtAuthenticationFilter
A custom filter, plugged into the chain from section 1, runs on every request: it
reads the Authorization header, validates the token, and — if valid —
populates Spring Security's SecurityContext so the rest of the chain
(and your controller) sees an authenticated user. No database session lookup, no
cookie — the token itself is the proof.
package com.codeverse.week06.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final org.springframework.security.core.userdetails.UserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtService jwtService,
org.springframework.security.core.userdetails.UserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
filterChain.doFilter(request, response); // no token -- let it through unauthenticated
return;
}
String token = header.substring(7);
if (jwtService.isTokenValid(token)) {
String username = jwtService.extractUsername(token);
UserDetails user = userDetailsService.loadUserByUsername(username);
var authToken = new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
filterChain.doFilter(request, response);
}
}
Note what this filter deliberately does not do: it never rejects a request
itself. It either populates the SecurityContext or leaves it empty --
the authorization rules configured in section 4 are what actually turn "no
authenticated user" into a 401.
4. Securing Endpoints
With the filter in place, the last piece is telling Spring Security which paths are
public and which require a valid token, and wiring the JWT filter into the chain
ahead of the built-in username/password filter. This is where the
SecurityFilterChain bean from section 1 gets filled in for real:
package com.codeverse.week06.config;
import com.codeverse.week06.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // no cookies/sessions -- no CSRF to defend against
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login", "/auth/register").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
Three settings here matter more than the rest:
SessionCreationPolicy.STATELESS tells Spring Security
to never create or read an HttpSession — consistent with the stateless
API you've been building since Week 3, where every request is self-contained.
permitAll() on /auth/login is what lets a
client obtain a token in the first place — without it, logging in would itself
require being already logged in. Everything else falls through to
authenticated(), which is where a request with no
valid token gets rejected — not by the JWT filter, but by Spring Security's
authorization stage, after the filter has run and found nothing to authenticate.
401 vs. 403
Spring Security distinguishes two failure modes, and getting them right matters for any client consuming your API. A 401 Unauthorized means "I don't know who you are" — no token, an expired token, or a token with a bad signature. A 403 Forbidden means "I know exactly who you are, and you're not allowed to do this" — a valid, authenticated user hitting a resource their authorities don't cover. With today's config every unauthenticated request to a protected path returns 401; 403 becomes relevant once Week 7 adds role- and method-level authorization on top of this.
5. Hands-on Exercise
Add JWT login and lock down the Task API
Turn the Task API you've built since Week 3 into an authenticated API: a login endpoint that issues tokens, a filter that validates them, and endpoints that actually require one.
Requirements:
- Add a
UserJPA entity (username, BCrypt-hashed password) and aUserRepository, following the same entity/repository pattern as your Week 4/5Taskmodel. Seed at least one user with a properly hashed password. - Implement
UserDetailsServiceagainst that repository, and register aPasswordEncoderbean usingBCryptPasswordEncoder. - Build a
JwtServicethat issues signed tokens (JJWT or Nimbus JOSE, your choice) with a subject claim and expiry, plus aPOST /auth/loginendpoint that accepts a username/password, verifies the password with the encoder, and returns{ "token": "..." }on success or a 401 on failure. - Write a
JwtAuthenticationFilterextendingOncePerRequestFilterthat reads theAuthorization: Bearer <token>header, validates it, and populates theSecurityContextwhen the token is valid. - Wire it all together in a
SecurityFilterChainbean:/auth/loginispermitAll(), every Task endpoint from Weeks 3-5 requires authentication, sessions areSTATELESS, and the JWT filter runs beforeUsernamePasswordAuthenticationFilter. Confirm withcurlthatGET /api/tasksreturns 401 without a token and 200 with a valid one from/auth/login.
Keep the JWT secret out of source control -- load it via @Value("${app.jwt.secret}") from application.properties or an environment variable, exactly the way you've externalized datasource credentials since Week 4.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why must passwords be hashed with something like BCrypt rather than encrypted or stored in plain text?
Why must passwords be hashed with something like BCrypt rather than encrypted or stored in plain text?
Plain text stores the real password outright, so any database leak hands attackers every credential directly. Encryption is reversible by design -- whoever holds the key can decrypt every password back to its original form, so a leaked key (or a rogue insider) is just as catastrophic. BCrypt hashing is deliberately one-way: it derives a fixed-length value from the password that can't be reversed, is automatically salted so identical passwords produce different stored hashes, and is intentionally slow to compute, which makes brute-forcing a stolen hash database computationally expensive even at scale. Verifying a login only ever means re-hashing the submitted password and comparing, never decrypting anything.
Q2
Why does a REST API typically favor stateless JWT authentication over server-side session cookies?
Why does a REST API typically favor stateless JWT authentication over server-side session cookies?
Session cookies require the server to store session state in memory (or a shared session store) and look it up on every request, which ties a client to infrastructure that remembers it -- awkward once you're running multiple instances behind a load balancer, and a poor fit for non-browser clients like mobile apps that don't always handle cookies gracefully. A JWT flips the model: the server issues a signed token once at login and then verifies it independently on every subsequent request using nothing but the token itself and a secret key -- no session store, no server-side lookup, no shared state between instances. That statelessness is exactly what makes horizontal scaling and cross-client support simpler, which is why it fits a REST API like the Task service built since Week 3.
Q3
What's actually inside a JWT, and why does the signature matter if the payload itself isn't encrypted?
What's actually inside a JWT, and why does the signature matter if the payload itself isn't encrypted?
A JWT is three base64url segments joined by dots: a header naming the signing algorithm, a payload of claims (like the username and an expiry timestamp), and a signature. The header and payload are only base64-encoded, not encrypted, so anyone -- including the client itself -- can decode and read them; that's why sensitive data never belongs in the claims. The signature is a cryptographic hash of the header and payload computed with a secret key only the server knows, and it's what makes the token trustworthy: if anyone tampers with the payload, the signature no longer matches what the server recomputes on validation, and the token is rejected. In short, a JWT is readable by anyone but forgeable by no one who lacks the signing secret.
Q4
What's the practical difference between a 401 Unauthorized and a 403 Forbidden response?
What's the practical difference between a 401 Unauthorized and a 403 Forbidden response?
401 Unauthorized means the server doesn't know who's making the request at all -- no token was sent, or the token is missing, expired, or fails signature validation, so authentication itself failed. 403 Forbidden means the opposite: the request was successfully authenticated -- the server knows exactly which user it is -- but that user's authorities don't permit the action being requested. With this week's configuration, every unauthenticated request to a protected Task endpoint returns 401, since there's no role checking yet; 403 becomes meaningful once Week 7 introduces role- and method-level authorization on top of authentication.