Week 6: Spring Security (Part 1) — Authentication & JWT

This two-week module locks down the Task API you've been building since Week 3. This first week is about mechanics: how the Spring Security filter chain intercepts every request before it reaches your controllers, how passwords should actually be stored, and how to build stateless, JWT-based authentication suited to a REST API. Week 7 picks up from here with OAuth2/OIDC login and method-level authorization.

Module 5 of 12 Week 6 of 15 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Understand how the Spring Security filter chain processes every incoming request
  • Encode passwords correctly with BCrypt and load users through UserDetailsService
  • Issue and validate stateless JWTs to authenticate requests to a REST API

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.

pom.xml
<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.

conceptual request flow
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:

SecurityConfig.java
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.

SecurityConfig.java — encoder bean
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:

usage
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.

User.java
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; }
}
UserRepository.java
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);
}
AppUserDetailsService.java
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();
    }
}
Same repository pattern as Week 4/5

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:

the flow
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.

decoded example (not encrypted -- encoded)
// 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:

pom.xml — JJWT
<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>
JwtService.java
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.

JwtAuthenticationFilter.java
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:

SecurityConfig.java — complete
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

Hands-on

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:

  1. Add a User JPA entity (username, BCrypt-hashed password) and a UserRepository, following the same entity/repository pattern as your Week 4/5 Task model. Seed at least one user with a properly hashed password.
  2. Implement UserDetailsService against that repository, and register a PasswordEncoder bean using BCryptPasswordEncoder.
  3. Build a JwtService that issues signed tokens (JJWT or Nimbus JOSE, your choice) with a subject claim and expiry, plus a POST /auth/login endpoint that accepts a username/password, verifies the password with the encoder, and returns { "token": "..." } on success or a 401 on failure.
  4. Write a JwtAuthenticationFilter extending OncePerRequestFilter that reads the Authorization: Bearer <token> header, validates it, and populates the SecurityContext when the token is valid.
  5. Wire it all together in a SecurityFilterChain bean: /auth/login is permitAll(), every Task endpoint from Weeks 3-5 requires authentication, sessions are STATELESS, and the JWT filter runs before UsernamePasswordAuthenticationFilter. Confirm with curl that GET /api/tasks returns 401 without a token and 200 with a valid one from /auth/login.
Hint

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?

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?

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?

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?

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.