Spring Security Basics
At the moment anyone who can reach the academy API can add or delete players. Real applications must know who is calling (authentication) and decide what they may do (authorisation). Spring Security is the standard library for both, and Spring Boot switches it on the moment you add its starter.
In this lesson you will see what Spring Security does by default, write your own SecurityFilterChain with role-based rules, define users with properly hashed passwords, and test everything with curl.
Authentication and authorisation
- Authentication answers "who are you?": a username and password, a token, a certificate.
- Authorisation answers "are you allowed to do this?": coaches may add players, parents may only read.
- A failed authentication gives 401 Unauthorized; an authenticated user who lacks permission gets 403 Forbidden.
Spring Security works as a chain of filters that every request passes through before it reaches the DispatcherServlet and your controllers.
What you get by default
Add the starter (Initializr: "Spring Security"):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>Without any configuration, every URL now needs a login. Spring Boot creates one user called user and prints a random password at startup:
Using generated security password: 5d3bcfce-7a0d-4f59-9bad-b66740bea9dd This generated password is for development use only. Your security configuration must be updated before running your application in production.
curl -i http://localhost:8080/api/players
curl -u user:5d3bcfce-7a0d-4f59-9bad-b66740bea9dd http://localhost:8080/api/playersHTTP/1.1 401 WWW-Authenticate: Basic realm="Realm", charset="UTF-8" X-Content-Type-Options: nosniff X-Frame-Options: DENY Content-Length: 0 ["Aarav Patil","Priya Deshmukh"]
(Some headers are left out.) curl -u sends HTTP Basic credentials in the Authorization header. The defaults also add security headers, offer a login form to browsers (a browser request is redirected to /login), and turn on CSRF protection. CSRF (cross-site request forgery) protection stops another website from making a logged-in browser submit forms; it rejects POST, PUT and DELETE requests that lack a CSRF token. That is why, with the defaults, even a correct curl -u ... -X POST was refused in our test. Good for browser forms; not what a token-based JSON API needs.
Your own SecurityFilterChain
Define a SecurityFilterChain bean to replace the defaults. Spring Security 7 uses the lambda DSL: each part is configured inside a lambda.
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/players").permitAll()
.requestMatchers(HttpMethod.POST, "/api/players").hasRole("COACH")
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}authorizeHttpRequestslists rules in order; the first matching rule wins, so put specific rules first andanyRequest()last.permitAll()allows everyone,hasRole("COACH")requires that role,authenticated()requires any logged-in user.httpBasic(...)accepts Basic credentials on each request.- CSRF is disabled and sessions are
STATELESSbecause this is an API whose clients send credentials (or tokens) with every request instead of relying on a browser cookie. Keep CSRF on for apps that use browser sessions and forms.
Users and password hashing
Never store passwords as plain text. Store a hash: a one-way, deliberately slow fingerprint. Spring Security's delegating encoder uses BCrypt by default and writes the algorithm name in front of the hash, so you can switch algorithms later:
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public UserDetailsService users(PasswordEncoder encoder) {
var coach = User.withUsername("rohan")
.password(encoder.encode("nets-at-6am"))
.roles("COACH")
.build();
var parent = User.withUsername("meera")
.password(encoder.encode("parent-pass-1"))
.roles("PARENT")
.build();
return new InMemoryUserDetailsManager(coach, parent);
}Encoding the same password twice and checking matches prints:
{bcrypt}$2a$10$Eyg0n8TxN6ubkOiPc.381.DCF5WGM8u/wsIG..Gp5G29315nFNFAO
{bcrypt}$2a$10$xbfPRZiB0kJ1Omrg0qEmXueOLjUwWKVTp23X3Ft2Iv.l/7uUPlCmO
Same hash twice? false
Correct password matches? true
Wrong password matches? falseThe hashes differ because BCrypt adds a random salt each time, so two users with the same password still get different hashes. encoder.matches(raw, hash) is the only correct way to check a password. The $10$ is the cost factor: higher is slower and harder to crack. In a real application, users live in a database table: implement UserDetailsService with a repository lookup and store only the encoded password.
Testing the rules
GET /api/players (no login) -> 200 ["Aarav Patil","Priya Deshmukh"]
GET /api/me (no login) -> 401 WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
GET /api/me as meera -> 200 {"user":"meera"}
POST /api/players as meera (PARENT) -> 403 {"timestamp":"2026-11-01T19:31:40.100Z","status":403,"error":"Forbidden","path":"/api/players"}
POST /api/players as rohan (COACH) -> 201 {"by":"rohan","added":"Isha Kale"}
GET /api/me as rohan, wrong password -> 401(Real responses, one per line.) In a controller, accept a Principal (or Authentication) parameter to know who is calling; the /api/me endpoint simply returns principal.getName().
Method security
Rules can also sit on service methods. Add @EnableMethodSecurity to a configuration class and annotate methods:
@PreAuthorize("hasRole('COACH')")
public PlayerResponse create(CreatePlayerRequest request) { ... }This protects the action even if a new controller forgets the URL rule.
Common mistakes
- Plain-text or
{noop}passwords outside quick experiments. Always encode with thePasswordEncoder. - Rule order. Putting
anyRequest().authenticated()before specific rules makes them unreachable; Spring Security rejects that configuration at startup. hasRole("ROLE_COACH").hasRoleadds theROLE_prefix itself; writehasRole("COACH")orhasAuthority("ROLE_COACH").- Disabling CSRF in a browser-session app. Only disable it for stateless APIs that do not use cookies for login.
- Basic auth over plain HTTP. Basic credentials are only encoded, not encrypted. Always use HTTPS outside your laptop.
Key points
- Adding
spring-boot-starter-securitysecures every URL, with a generateduserpassword for development. - A
SecurityFilterChainbean with the lambda DSL defines rules:permitAll(),hasRole(),authenticated(); the first matching rule wins. - 401 means "not authenticated", 403 means "authenticated but not allowed".
- Store passwords with
PasswordEncoderFactories.createDelegatingPasswordEncoder()(BCrypt with a random salt); check them withmatches. - Stateless APIs disable CSRF and sessions; browser apps keep CSRF on.
Exercise
Extend the rules: everyone may read GET /api/players/**, only COACH may POST, PUT or DELETE under /api/players/**, only a new role ADMIN may use anything under /api/admin/**, and everything else needs a login. Add an admin user isha with a hashed password. Write the authorizeHttpRequests part and the new user.
Show solution
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/players/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/players/**").hasRole("COACH")
.requestMatchers(HttpMethod.PUT, "/api/players/**").hasRole("COACH")
.requestMatchers(HttpMethod.DELETE, "/api/players/**").hasRole("COACH")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())var admin = User.withUsername("isha")
.password(encoder.encode("change-this-admin-pass"))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(coach, parent, admin);/** matches the path and everything below it. The specific rules come before anyRequest(), so a parent calling DELETE /api/players/3 gets 403 while reading stays open. In a real system the admin password would come from a database or secret store, never from source code.
Quick Quiz
1. What status code means "you are logged in but not allowed to do this"?
401 is for missing or wrong credentials; 403 is for insufficient permission.
2. Why do two encode("nets-at-6am") calls give different hashes?
The salt is stored inside the hash, so matches can still verify the password.
3. Which style does Spring Security 7 require for HttpSecurity configuration?
.and() was removed in Spring Security 7; each section is configured in a lambda.
4. Why is CSRF disabled in the API configuration?
CSRF attacks abuse browser cookies; keep protection on for session-based browser apps.
Learn Spring Boot with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
