Java · lesson 56 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Also part of: Web Security (OWASP Top 10, fix-side)
Spring Security — Authentication and Authorization
Secure a Spring Boot 3 API and probe it with curl: default lockdown, SecurityFilterChain rules, BCrypt, 401 vs 403, @PreAuthorize and tests.
By SkillByExample · Updated
Spring Security protects a Spring application with a chain of servlet filters that run before any
controller: they authenticate the caller and then check whether the request is allowed. This lesson
secures a small Spring Boot 3.5 API, starts it with java -jar target/secure-0.0.1-SNAPSHOT.jar, and
probes it with curl; every response below is real. Code blocks start at the type declaration, with
imports omitted.
Adding the starter locks everything
@RestController
@RequestMapping("/api")
public class ApiController {
@GetMapping("/public/status")
public String status() { return "up"; }
@GetMapping("/orders")
public String orders() { return "your orders"; }
@GetMapping("/admin/stats")
public String stats() { return "admin stats"; }
}
With only spring-boot-starter-security added to the POM and no configuration:
$ java -jar target/secure-0.0.1-SNAPSHOT.jar
2026-10-07T22:32:45.744Z WARN 10271 --- [ main] .s.s.UserDetailsServiceAutoConfiguration :
Using generated security password: 6c1dc672-89e5-4c35-974b-f1722200f30b
This generated password is for development use only. Your security configuration must be updated before running your application in production.
$ curl -si localhost:8080/api/public/status | grep -iE '^(HTTP|WWW-Authenticate)'
HTTP/1.1 401
WWW-Authenticate: Basic realm="Realm"
$ curl -s -w '\n' -u user:6c1dc672-89e5-4c35-974b-f1722200f30b localhost:8080/api/public/status
up
$ curl -s -o /dev/null -w '%{http_code}\n' -u user:wrong localhost:8080/api/orders
401
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' -H 'Accept: text/html' localhost:8080/api/orders
302 http://localhost:8080/login
Secure by default: even the “public” status endpoint now answers 401 Unauthorized, with a
WWW-Authenticate header inviting HTTP Basic credentials. Spring Boot generated one user named user
with a random password (a new one on every start). The same URL requested as a browser would
(Accept: text/html) is redirected to a generated login form instead. None of this is suitable for
production; the rest of the lesson replaces it.
A SecurityFilterChain with users and roles
@Configuration
@EnableMethodSecurity // enables @PreAuthorize
public class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()) // first match wins: most specific first
.httpBasic(Customizer.withDefaults())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable()); // stateless API, no cookies to forge
return http.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder(); // bcrypt by default
}
@Bean
UserDetailsService users(PasswordEncoder encoder) {
return new InMemoryUserDetailsManager(
User.withUsername("alice").password(encoder.encode("alice-pw")).roles("USER").build(),
User.withUsername("bob").password(encoder.encode("bob-pw")).roles("USER", "ADMIN").build());
}
}
$ curl -s -w '\n' localhost:8080/api/public/status
up
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/api/orders
401
$ curl -s -w '\n' -u alice:alice-pw localhost:8080/api/orders
your orders
$ curl -s -o /dev/null -w '%{http_code}\n' -u alice:wrong localhost:8080/api/orders
401
$ curl -s -o /dev/null -w '%{http_code}\n' -u alice:alice-pw localhost:8080/api/admin/stats
403
$ curl -s -w '\n' -u bob:bob-pw localhost:8080/api/admin/stats
admin stats
$ curl -si -u alice:alice-pw localhost:8080/api/orders | grep -iE '^(X-|Cache-Control|Set-Cookie)'
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
X-Frame-Options: DENY
Defining a SecurityFilterChain and a UserDetailsService switched off the generated user and
login form. Rules are checked top to bottom and the first match wins, so specific paths go before
anyRequest(). Note the two different failures: no credentials or a wrong password is 401 (“who
are you?”), while alice correctly logged in but asking for an admin page is 403 Forbidden (“I know
who you are; no”). roles("ADMIN") stores the authority ROLE_ADMIN, which hasRole("ADMIN")
checks. Spring Security also added protective headers to every response, and with a STATELESS
policy no session cookie is set — each request carries its own credentials, which is why disabling
CSRF protection is safe here.
Password hashing
class PasswordEncoderTest {
@Test
void hashesAreSaltedAndPrefixed() {
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
String a = encoder.encode("alice-pw");
String b = encoder.encode("alice-pw");
System.out.println("prefix: " + a.substring(0, 15)); // {bcrypt}$2a$10$
System.out.println("length: " + a.length());
System.out.println("a equals b: " + a.equals(b)); // random salt each time
System.out.println("matches: " + encoder.matches("alice-pw", a));
System.out.println("wrong pw: " + encoder.matches("alice-PW", a));
assertTrue(encoder.matches("alice-pw", b));
}
}
$ mvn -B test -Dtest=PasswordEncoderTest 2>&1 | grep -E '^[a-z ]+: |Tests run:|BUILD'
prefix: {bcrypt}$2a$10$
length: 68
a equals b: false
matches: true
wrong pw: false
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.613 s -- in com.example.secure.PasswordEncoderTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Hashing the same password twice gave different strings, because BCrypt mixes in a random salt that
it stores inside the hash — so you can never compare hashes with equals, only with matches.
$2a$10$ is the BCrypt version and cost factor (210 rounds), deliberately slow to make
brute-forcing expensive. The {bcrypt} prefix comes from the delegating encoder: stored hashes
record their algorithm, so you can move to a stronger one later without invalidating old passwords.
The current user and method security
@RestController
@RequestMapping("/api")
public class ProfileController {
record Me(String name, List<String> authorities) {}
@GetMapping("/me")
public Me me(Authentication auth) { // the logged-in user, injected
return new Me(auth.getName(), auth.getAuthorities().stream().map(GrantedAuthority::getAuthority).sorted().toList());
}
@GetMapping("/users/{username}/profile")
@PreAuthorize("hasRole('ADMIN') or #username == authentication.name")
public String profile(@PathVariable String username) {
return "profile of " + username;
}
}
$ curl -s -w '\n' -u alice:alice-pw localhost:8080/api/me
{"name":"alice","authorities":["ROLE_USER"]}
$ curl -s -w '\n' -u bob:bob-pw localhost:8080/api/me
{"name":"bob","authorities":["ROLE_ADMIN","ROLE_USER"]}
$ curl -s -w '\n' -u alice:alice-pw localhost:8080/api/users/alice/profile
profile of alice
$ curl -s -w '\n' -u alice:alice-pw localhost:8080/api/users/bob/profile
{"timestamp":"2026-10-07T22:36:08.689+00:00","status":403,"error":"Forbidden","path":"/api/users/bob/profile"}
$ curl -s -w '\n' -u bob:bob-pw localhost:8080/api/users/alice/profile
profile of alice
Declaring an Authentication parameter hands the controller the logged-in user. URL rules cannot
express “users may see their own profile”, so @PreAuthorize (switched on by
@EnableMethodSecurity) evaluates an expression before the method runs: #username is the method
argument and authentication.name the caller. Method security works on any Spring bean, so the same
annotation can guard a service method that several controllers share.
Testing secured endpoints
@WebMvcTest(ProfileController.class)
@Import(SecurityConfig.class) // test with the real rules
class ProfileControllerTest {
@Autowired MockMvc mvc;
@Test
void anonymousIsRejected() throws Exception {
mvc.perform(get("/api/me")).andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "carol") // a fake authenticated user, ROLE_USER
void userSeesOwnProfileOnly() throws Exception {
mvc.perform(get("/api/users/carol/profile")).andExpect(status().isOk());
mvc.perform(get("/api/users/dave/profile")).andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "root", roles = "ADMIN")
void adminSeesAnyProfile() throws Exception {
mvc.perform(get("/api/users/dave/profile"))
.andExpect(status().isOk())
.andExpect(content().string("profile of dave"));
}
}
$ mvn -B test -Dtest=ProfileControllerTest 2>&1 | grep -E 'Tests run:|BUILD'
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.721 s -- in com.example.secure.ProfileControllerTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
@WithMockUser (from the spring-security-test dependency) puts a fake authenticated user in the
security context, so no passwords are involved and the users need not exist. Importing the real
SecurityConfig means the tests check your rules, not Spring Boot’s defaults.
Practice
- Add an
AUDITORrole and a usercarolwho may read/api/reports/**, which admins may also read. Carol must not reach/api/admin/**.
Solution
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/reports/**").hasAnyRole("ADMIN", "AUDITOR")
// ...
User.withUsername("carol").password(encoder.encode("carol-pw")).roles("AUDITOR").build()
@RestController
public class ReportController {
@GetMapping("/api/reports/monthly")
public String monthly() { return "monthly report"; }
}
$ curl -s -w '\n' -u carol:carol-pw localhost:8080/api/reports/monthly
monthly report
$ curl -s -o /dev/null -w '%{http_code}\n' -u alice:alice-pw localhost:8080/api/reports/monthly
403
$ curl -s -o /dev/null -w '%{http_code}\n' -u carol:carol-pw localhost:8080/api/admin/stats
403
- Real applications load users from a database. Replace
InMemoryUserDetailsManagerwith your ownUserDetailsServicethat looks users up in aMapof password hashes.
Solution
@Bean
UserDetailsService users(PasswordEncoder encoder) {
Map<String, String> passwordHashes = Map.of( // stand-in for a users table
"alice", encoder.encode("alice-pw"),
"bob", encoder.encode("bob-pw"));
return username -> {
String hash = passwordHashes.get(username);
if (hash == null) throw new UsernameNotFoundException(username);
String[] roles = username.equals("bob") ? new String[] {"USER", "ADMIN"} : new String[] {"USER"};
return User.withUsername(username).password(hash).roles(roles).build();
};
}
$ curl -s -w '\n' -u bob:bob-pw localhost:8080/api/me
{"name":"bob","authorities":["ROLE_ADMIN","ROLE_USER"]}
$ curl -s -o /dev/null -w '%{http_code}\n' -u mallory:x localhost:8080/api/me
401
UserDetailsService has one method, loadUserByUsername, so a lambda implements it. Spring Security
checks the password itself with the PasswordEncoder; your code only finds the user. With JPA, the
lambda body becomes a repository lookup.
- Move
.anyRequest().authenticated()to the top of the rules. Predict what happens.
Solution
$ java -jar target/secure-0.0.1-SNAPSHOT.jar 2>&1 | grep -m1 -o 'Caused by: java.lang.IllegalStateException.*'
Caused by: java.lang.IllegalStateException: Can't configure mvcMatchers after anyRequest
The application refuses to start. Since the first matching rule wins, anything after anyRequest()
could never apply, so Spring Security treats that order as a bug instead of silently making
/api/public/** require a login.
The message says mvcMatchers even though the code calls requestMatchers: with Spring MVC on the
classpath, requestMatchers(String...) builds MVC-aware matchers through an internal
createMvcMatchers helper, whose check still uses the Spring Security 5 method name.