Project: Securing a Web App
Throughout this tutorial you have studied each pillar of Spring Security in isolation: the filter chain, UserDetailsService, password encoding, form login, HTTP Basic, authorization rules, and method-level security. Now you will wire all of them together into a single, production-realistic Spring Boot 3 application. The goal is not just to make things work, but to understand why each decision was made and what the security implications are when you deviate from it.
Project Overview
The sample application is a Task Manager API with a small Thymeleaf front-end. It exposes:
GET / — public landing page
GET /tasks — list tasks for the authenticated user (ROLE_USER or ROLE_ADMIN)
POST /tasks — create a task (ROLE_USER or ROLE_ADMIN)
DELETE /tasks/{id} — delete any task (ROLE_ADMIN only)
GET /admin/dashboard — admin statistics page (ROLE_ADMIN only)
GET /api/tasks — REST endpoint returning JSON (stateless, HTTP Basic)
This mix of a session-based web UI and a stateless REST API in the same application is intentional — it is the most common real-world configuration and the one that trips developers up most often.
Step 1 — Dependencies and Database Setup
Start from a Spring Initializr project with spring-boot-starter-security, spring-boot-starter-web, spring-boot-starter-thymeleaf, thymeleaf-extras-springsecurity6, spring-boot-starter-data-jpa, and the H2 in-memory database for development.
Define the AppUser entity that will back UserDetailsService:
@Entity
@Table(name = "app_users")
public class AppUser {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password; // BCrypt-encoded
@Column(nullable = false)
private String roles; // comma-separated: "ROLE_USER" or "ROLE_USER,ROLE_ADMIN"
// getters / setters omitted for brevity
}
Why store roles as a plain string here? A production app would normalise roles into a separate table. The flat string is used here to keep the focus on Spring Security wiring rather than JPA schema design — a topic already covered in the Spring Data tutorial.
Step 2 — UserDetailsService Implementation
Create a service that loads users from the database and adapts them to Spring Security's UserDetails contract:
@Service
@RequiredArgsConstructor
public class AppUserDetailsService implements UserDetailsService {
private final AppUserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
AppUser user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(
"No user found: " + username));
String[] roles = user.getRoles().split(",");
return User.withUsername(user.getUsername())
.password(user.getPassword())
.roles(roles) // strips "ROLE_" prefix automatically
.build();
}
}
Never expose the reason for login failure in error messages. Returning "password wrong" versus "user not found" is an enumeration vulnerability — attackers can probe which usernames exist. Always surface a single generic message like "Bad credentials."
Step 3 — Password Encoding and Data Initialization
Declare the PasswordEncoder bean (required by DaoAuthenticationProvider) and seed two users for local development:
@Configuration
public class SecurityBeans {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // cost factor 12
}
}
@Component
@RequiredArgsConstructor
public class DataInitializer implements ApplicationRunner {
private final AppUserRepository repo;
private final PasswordEncoder encoder;
@Override
public void run(ApplicationArguments args) {
if (repo.count() == 0) {
repo.save(new AppUser(null, "alice",
encoder.encode("secret123"), "ROLE_USER"));
repo.save(new AppUser(null, "bob",
encoder.encode("admin456"), "ROLE_USER,ROLE_ADMIN"));
}
}
}
Step 4 — The Security Configuration
This is the centrepiece. Two SecurityFilterChain beans handle the two distinct security surfaces — the stateless REST API and the session-based web UI — with separate rules and authentication mechanisms:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // activates @PreAuthorize / @PostAuthorize
public class SecurityConfig {
private final AppUserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
public SecurityConfig(AppUserDetailsService uds, PasswordEncoder pe) {
this.userDetailsService = uds;
this.passwordEncoder = pe;
}
// ── Chain 1: REST API (stateless, HTTP Basic) ──────────────────────────
@Bean
@Order(1)
public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable()); // no browser clients on /api
return http.build();
}
// ── Chain 2: Web UI (session-based, form login) ────────────────────────
@Bean
@Order(2)
public SecurityFilterChain webChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/css/**", "/js/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/tasks", true)
.permitAll())
.logout(logout -> logout
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID"))
.sessionManagement(sm -> sm
.sessionFixation().migrateSession() // prevent session fixation
.maximumSessions(1)); // one active session per user
return http.build();
}
@Bean
public DaoAuthenticationProvider authProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return provider;
}
}
Order matters. The API chain is annotated @Order(1) so it is evaluated first. The securityMatcher("/api/**") confines it to that path prefix. Any request not matching /api/** falls through to the web chain. Without securityMatcher, the first chain would intercept everything and the second would be unreachable.
Step 5 — Method-Level Security on the Service Layer
Rather than duplicating URL patterns in every controller, enforce ownership at the service boundary using @PreAuthorize:
@Service
@RequiredArgsConstructor
public class TaskService {
private final TaskRepository taskRepository;
public List<Task> getTasksForCurrentUser(String username) {
return taskRepository.findByOwnerUsername(username);
}
@PreAuthorize("hasRole('ADMIN')")
public void deleteTask(Long taskId) {
taskRepository.deleteById(taskId);
}
@PostAuthorize("returnObject.ownerUsername == authentication.name"
+ " or hasRole('ADMIN')")
public Task getById(Long id) {
return taskRepository.findById(id)
.orElseThrow(EntityNotFoundException::new);
}
}
The @PostAuthorize on getById demonstrates a powerful pattern: fetch the entity first, then verify the caller owns it or is an admin. This is safer than pre-checking IDs, because the ownership check is done on real persisted data, not on a user-supplied identifier.
Step 6 — CSRF Protection for the Web UI
Spring Security enables CSRF protection by default for the web chain. Your Thymeleaf forms inherit it automatically via the th:action attribute, which injects the hidden token:
<!-- Thymeleaf form — CSRF token injected automatically -->
<form th:action="@{/tasks}" method="post">
<input type="text" name="title" placeholder="New task" />
<button type="submit">Add</button>
</form>
Disabling CSRF on the web chain is a serious mistake. It exposes every state-changing endpoint to cross-site request forgery. Only disable it for stateless endpoints (like /api/**) that are protected by tokens rather than cookies.
Step 7 — Thymeleaf Security Integration
Use the sec: namespace from thymeleaf-extras-springsecurity6 to conditionally render UI elements based on roles — so the "Admin Dashboard" link only appears for admins:
<!-- Show only to authenticated users -->
<span sec:authentication="name"></span>
<!-- Show delete button only to admins -->
<form th:if="${#authorization.expression('hasRole(''ADMIN'')')}"
th:action="@{/tasks/{id}(id=${task.id})}" method="post">
<input type="hidden" name="_method" value="DELETE" />
<button>Delete</button>
</form>
Bringing It All Together — Security Decisions Audit
Before shipping a secured application, run through this checklist:
- Passwords: BCrypt with cost ≥ 10. Never MD5, SHA-1, or plain text.
- Session fixation:
migrateSession() rotates the session ID on login.
- Maximum sessions: limit concurrent sessions to prevent credential sharing.
- CSRF: enabled for browser clients, disabled only for stateless API paths.
- HTTPS: add
http.requiresChannel().anyRequest().requiresSecure() or enforce at the load-balancer.
- Security headers: Spring Security adds
X-Frame-Options, X-Content-Type-Options, and Cache-Control headers by default — do not disable them without a reason.
- Least privilege: start with
denyAll() and open up explicitly, rather than starting open and closing down.
Summary
You have built a full end-to-end secured web application: a database-backed UserDetailsService, BCrypt password encoding, dual security filter chains for a session-based UI and a stateless REST API, method-level authorization with @PreAuthorize/@PostAuthorize, CSRF protection, and Thymeleaf role-aware rendering. Each of these layers defends against a different attack surface, and together they form the defence-in-depth posture that production applications require. The patterns here apply directly to microservices as well — the primary difference being that the stateless chain becomes the default and JWT replaces the session cookie.