Skip to main content
Java · lesson 56 of 66
Open the Java page →
Java intermediate Lesson 56 of 66

Also 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

  1. Add an AUDITOR role and a user carol who 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
  1. Real applications load users from a database. Replace InMemoryUserDetailsManager with your own UserDetailsService that looks users up in a Map of 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.

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

Frequently Asked Questions

What is the difference between authentication and authorization?
Authentication establishes who the caller is, for example by checking a username and password. Authorization decides what that caller may do, for example whether they hold the ADMIN role. In HTTP terms a failed authentication is 401 Unauthorized and a failed authorization for a known user is 403 Forbidden.
What happens when I add spring-boot-starter-security?
Every endpoint immediately requires authentication. Spring Boot creates a single user named user with a random password printed in the startup log, enables HTTP Basic and a login form, and adds security headers. Defining your own SecurityFilterChain and UserDetailsService beans replaces those defaults.
Why does Spring Security need a PasswordEncoder?
Passwords must be stored as slow, salted one-way hashes, never as plain text. The delegating encoder from PasswordEncoderFactories hashes with BCrypt and prefixes the result with {bcrypt}, so you can migrate to a newer algorithm later. Two hashes of the same password differ because each has a random salt; use matches() to check a password.
What is the difference between hasRole and hasAuthority?
Roles are authorities with a ROLE_ prefix. roles("ADMIN") on a user stores the authority ROLE_ADMIN, and hasRole('ADMIN') checks for ROLE_ADMIN, while hasAuthority('ROLE_ADMIN') checks the full string. Use authorities without the prefix for fine-grained permissions such as orders:write.
Should I disable CSRF protection?
Disable it only for stateless APIs that authenticate every request with a header, such as HTTP Basic or a bearer token, and never create a session cookie. CSRF attacks rely on the browser sending cookies automatically, so keep CSRF protection enabled for any application that uses session cookies or form login.