Spring Security Basics
Reviewed & published by Brayan K
Spring Security protects your application with a chain of filters that first authenticate who a user is and then authorize what they may do — with safely hashed passwords at the core.
Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
Before You Start
You should understand dependency injection and how Spring beans are wired, plus the basics of building a Spring Boot app and its profiles. Familiarity with HTTP requests and roles helps.
What You'll Learn
- Authentication vs authorization
- Registering a SecurityFilterChain bean (Spring Security 6)
- Securing endpoints by path and role
- Why and how passwords are hashed with BCrypt
- Defining in-memory users for demos and tests
- Where the authenticated principal lives (SecurityContext)
The Big Idea — The Doorman and the Guest List
💡 Analogy: Picture a private club. At the door, a doorman checks your ID — that is authentication: proving you are who you claim to be. Inside, a guest list says which rooms each member may enter — that is authorization. Spring Security is the whole security desk: a line of staff (the filter chain) where the first checks your ID, the next checks the guest list, and only then are you let through to the party (your controller). And crucially, the club never writes your real password on the list — it keeps a scrambled, un-reversible BCrypt version.
Two different questions, two different jobs: "who are you?" then "what may you do?".
1️⃣ Authentication vs Authorization
These two are constantly confused. Authentication verifies identity (login). Authorization grants or denies access based on that identity's roles/authorities. We can model both in plain Java to make the distinction concrete.
import java.util.*;
public class Main {
// A plain-Java model of AUTHENTICATION vs AUTHORIZATION — the two ideas at
// the heart of Spring Security, with no framework required.
record User(String name, Set<String> roles) {}
// AUTHENTICATION: prove who you are (here: a name + matching token).
static Optional<User> authenticate(String name, String token,
Map<String, String> tokens,
Map<String, Set<String>> roles) {
if (token.equals(tokens.get(name))) {
return Optional.of(new User(name, roles.getOrDefault(name, Set.of())));
}
return Optional.empty(); // bad credentials
}
// AUTHORIZATION: given a known user, may they reach this endpoint?
static boolean authorize(User u, String requiredRole) {
return u.roles().contains(requiredRole);
}
public static void main(String[] args) {
var tokens = Map.of("alice", "secretA", "bob", "secretB");
var roles = Map.of("alice", Set.of("ADMIN"), "bob", Set.of("USER"));
var alice = authenticate("alice", "secretA", tokens, roles).orElseThrow();
System.out.println("authenticated: " + alice.name());
System.out.println("alice -> /admin allowed? " + authorize(alice, "ADMIN"));
var bob = authenticate("bob", "secretB", tokens, roles).orElseThrow();
System.out.println("bob -> /admin allowed? " + authorize(bob, "ADMIN"));
boolean badLogin = authenticate("alice", "wrong", tokens, roles).isEmpty();
System.out.println("alice with wrong token rejected? " + badLogin);
}
}2️⃣ Never Store Plaintext — Hash with a Salt
Spring's BCryptPasswordEncoder is the recommended choice: it produces a salted, adaptive, one-way hash. We can't run BCrypt without Spring on the classpath, but the demo below shows the universal principle — a salt makes equal passwords hash differently, and the original is never stored.
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
public class Main {
// We can't run Spring's BCryptPasswordEncoder here, but we CAN show the
// core idea every password hasher shares: never store plaintext, always
// store a SALTED, one-way hash so two equal passwords look different.
static String hashWithSalt(String password, byte[] salt) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(salt); // salt makes each hash unique
byte[] digest = md.digest(password.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(salt) + "$"
+ Base64.getEncoder().encodeToString(digest);
}
static byte[] newSalt() {
byte[] salt = new byte[16];
new SecureRandom().nextBytes(salt);
return salt;
}
public static void main(String[] args) throws Exception {
String pw = "hunter2";
String stored1 = hashWithSalt(pw, newSalt());
String stored2 = hashWithSalt(pw, newSalt());
System.out.println("same password, different stored hashes:");
System.out.println(" " + (stored1.equals(stored2) ? "EQUAL" : "DIFFERENT"));
System.out.println("plaintext stored anywhere? false");
}
}3️⃣ Configuring the SecurityFilterChain
In Spring Security 6 you configure security by registering a SecurityFilterChain bean (the old WebSecurityConfigurerAdapter is gone). You declare which paths are public, which need a role, and that everything else needs authentication.
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()); // first match wins// SPRING SECURITY 6 configuration (needs spring-boot-starter-security to run).
// The modern style: register a SecurityFilterChain BEAN — no
// WebSecurityConfigurerAdapter (removed in Spring Security 6).
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/public/**").permitAll() // open
.requestMatchers("/admin/**").hasRole("ADMIN") // admins only
.anyRequest().authenticated() // all else: login
)
.httpBasic(Customizer.withDefaults()); // simple auth
return http.build();
}
// BCrypt: the recommended adaptive, salted, one-way password hash.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// A tiny fixed set of users — great for demos and tests, not production.
@Bean
public UserDetailsService users(PasswordEncoder encoder) {
UserDetails admin = User.withUsername("alice")
.password(encoder.encode("secretA")) // store the HASH, not plaintext
.roles("ADMIN")
.build();
UserDetails user = User.withUsername("bob")
.password(encoder.encode("secretB"))
.roles("USER")
.build();
return new InMemoryUserDetailsManager(admin, user);
}
}🧠 Quick Recall
Predict before you peek.
Answer: "What may you do?" — it grants or denies access for an already-identified user, based on roles/authorities.
Answer: a SecurityFilterChain bean defined in a @Configuration class with @EnableWebSecurity.
Answer: each hash uses a unique random salt, so identical passwords map to different stored values.
🎯 YOUR TURN — Role-Based Access
Write canAccess(userRoles, requiredRole) and check two endpoints against Bob's roles.
import java.util.*;
public class Main {
// YOUR TURN: model role-based authorization.
// Write canAccess(userRoles, requiredRole) that returns true only when the
// user's role set contains the required role. Then check two paths.
//
// Expected output:
// /reports needs USER -> true
// /admin needs ADMIN -> false
// static boolean canAccess(Set<String> userRoles, String requiredRole) { ... }
public static void main(String[] args) {
Set<String> bobsRoles = Set.of("USER");
// System.out.println("/reports needs USER -> " + canAccess(bobsRoles, "USER"));
// System.out.println("/admin needs ADMIN -> " + canAccess(bobsRoles, "ADMIN"));
}
}🧩 MINI-CHALLENGE — A Tiny Filter Chain
Walk an ordered list of path rules and apply the first match to decide ALLOW / DENY / NEEDS LOGIN.
import java.util.*;
public class Main {
// MINI-CHALLENGE: a tiny "filter chain". Each rule is a path prefix plus the
// role it requires (or null for permitAll). Walk the rules in order and use
// the FIRST matching rule to decide. Anything unmatched requires login.
//
// Expected output:
// GET /public/info -> ALLOW
// GET /admin/users -> DENY (needs ADMIN)
// GET /account -> NEEDS LOGIN
record Rule(String prefix, String requiredRole) {}
public static void main(String[] args) {
List<Rule> chain = List.of(
new Rule("/public/", null), // permitAll
new Rule("/admin/", "ADMIN") // role required
);
Set<String> anonymousRoles = Set.of(); // not logged in
// For each path, find the first matching Rule and print ALLOW / DENY /
// NEEDS LOGIN as shown above.
}
}Common Errors
- ❌ Extending WebSecurityConfigurerAdapter. It was removed in Spring Security 6. Fix: register a SecurityFilterChain bean.
- ❌ Storing plaintext (or NoOpPasswordEncoder). A leak exposes every password. Fix: use BCryptPasswordEncoder.
- ❌ Confusing roles and authorities. hasRole("ADMIN") expects an authority ROLE_ADMIN. Fix: know the ROLE_ prefix convention.
- ❌ Forgetting anyRequest().authenticated(). Unmatched paths may be left open. Fix: add a catch-all rule last.
- ❌ Encoding passwords twice. Encoding an already-encoded value breaks login. Fix: encode once, when the user is created.
- ❌ Using InMemoryUserDetailsManager in production. It is for demos/tests. Fix: back UserDetailsService with a real store.
Pro Tips
- 💡 Order matters in authorizeHttpRequests — the first matching rule wins, so put specifics before the catch-all.
- 💡 Use a single PasswordEncoder bean so encoding and matching always agree.
- 💡 Read the current user from the SecurityContext via SecurityContextHolder rather than passing it around.
- 💡 Keep security as beans — it makes the configuration composable and unit-testable.
📋 Quick Reference
| Concept | Syntax / idea | Note |
|---|---|---|
| authentication | verify credentials | "Who are you?" |
| authorization | hasRole("ADMIN") | "What may you do?" |
| config bean | SecurityFilterChain | Spring Security 6 |
| enable | @EnableWebSecurity | On @Configuration |
| rules DSL | authorizeHttpRequests | First match wins |
| open path | permitAll() | No auth needed |
| catch-all | anyRequest().authenticated() | Put last |
| hashing | BCryptPasswordEncoder | Salted, adaptive |
| user source | UserDetailsService | loadUserByUsername |
| demo users | InMemoryUserDetailsManager | Not production |
| current user | SecurityContextHolder | Holds principal |
📚 Resources / Keep Learning
- 📘 Spring Security Reference (docs.spring.io)
- 📗 Password Storage — Spring Security docs
- 📗 Securing a Web Application — Spring guide
🎉 Lesson Complete!
You can now distinguish authentication from authorization, configure a SecurityFilterChain bean in Spring Security 6, secure endpoints by path and role, store passwords safely with BCrypt, and stand up in-memory users for testing.
Practice quiz
In Spring Security 6, how do you configure HTTP request rules?
- By defining a SecurityFilterChain bean
- By extending WebSecurityConfigurerAdapter
- By editing web.xml
- By annotating main() with @Secure
Answer: By defining a SecurityFilterChain bean. WebSecurityConfigurerAdapter was removed; you now register a SecurityFilterChain bean using a component-based, lambda DSL configuration.
What does authentication establish?
- What a user is allowed to do
- Who the user is
- How fast the request is
- Which database to use
Answer: Who the user is. Authentication answers 'who are you?' by verifying credentials; authorization is the separate question of what you may do.
What does authorization decide?
- The password hash algorithm
- The user's identity
- Whether an authenticated user may access a resource
- The server's port
Answer: Whether an authenticated user may access a resource. Authorization governs access: given a known identity, may this principal perform this action or reach this endpoint?
Which PasswordEncoder is the recommended default for hashing passwords?
- NoOpPasswordEncoder
- Base64Encoder
- MD5PasswordEncoder
- BCryptPasswordEncoder
Answer: BCryptPasswordEncoder. BCryptPasswordEncoder applies an adaptive, salted one-way hash; NoOp stores plaintext and MD5 is broken for passwords.
Why is BCrypt 'adaptive'?
- It changes the password automatically
- Its work factor (strength) can be increased as hardware speeds up
- It compresses the hash
- It caches results
Answer: Its work factor (strength) can be increased as hardware speeds up. BCrypt has a configurable cost/strength parameter, so you can raise it over time to keep brute-forcing expensive.
Which interface supplies user details (username, password, authorities) during authentication?
- UserDetailsService
- JdbcTemplate
- RestController
- EntityManager
Answer: UserDetailsService. UserDetailsService.loadUserByUsername returns a UserDetails the AuthenticationManager checks against the supplied credentials.
In the SecurityFilterChain DSL, which call requires authentication on every request?
- permitAll()
- denyAll()
- anyRequest().authenticated()
- ignore()
Answer: anyRequest().authenticated(). authorizeHttpRequests(a -> a.anyRequest().authenticated()) means every unmatched request needs an authenticated principal.
What is InMemoryUserDetailsManager useful for?
- Defining a small fixed set of users in memory for demos/tests
- Production user storage at scale
- Encrypting the database
- Serving static files
Answer: Defining a small fixed set of users in memory for demos/tests. It holds UserDetails in memory — handy for examples, tests, and prototypes, but not a real persistent user store.
Spring Security is built primarily as...
- a single servlet
- a chain of servlet Filters
- a JSON parser
- a thread pool
Answer: a chain of servlet Filters. Security is applied via a filter chain; each filter handles one concern (e.g. authentication) before the request reaches your controller.
Where is the authenticated principal stored for the current request?
- In a public static field
- In the URL
- In the SecurityContext (held in SecurityContextHolder)
- In application.properties
Answer: In the SecurityContext (held in SecurityContextHolder). After authentication, the Authentication is placed in the SecurityContext, accessible thread-locally via SecurityContextHolder.
Continue this course
- Previous: Profiles & Environments
- Next: JWT Authentication — Stateless auth with JSON Web Tokens and a security filter
- Quick reference: Java cheat sheet
Frequently asked questions
What is the difference between authentication and authorization?
Authentication answers 'who are you?' — it verifies a user's identity by checking credentials such as a username and password. Authorization answers 'what are you allowed to do?' — given an already-authenticated identity, it decides whether that principal may access a particular resource or perform an action. Spring Security handles both: a filter authenticates the request (populating the SecurityContext), and authorization rules in your SecurityFilterChain then permit or deny access to each endpoint.
How do I configure Spring Security in version 6?
You register a SecurityFilterChain bean. The old WebSecurityConfigurerAdapter class was deprecated and then removed, so the component-based style is now the only way. In a @Configuration class annotated with @EnableWebSecurity, you define a method that takes HttpSecurity, configures authorizeHttpRequests(...) with a lambda DSL, chooses an authentication mechanism (e.g. httpBasic or formLogin), and returns http.build(). This makes security configuration just another set of beans you can compose and test.
Why should I use BCryptPasswordEncoder instead of storing passwords directly?
Storing plaintext passwords is catastrophic: a single database leak exposes every user's real password. BCryptPasswordEncoder applies a salted, adaptive, one-way hash, so the stored value cannot be reversed back to the password, and two users with the same password get different hashes thanks to the per-password salt. Because BCrypt has a configurable work factor (strength), you can increase the cost over time as hardware gets faster, keeping brute-force attacks expensive. Never use NoOpPasswordEncoder or fast hashes like MD5/SHA-1 for passwords.
What is InMemoryUserDetailsManager for?
InMemoryUserDetailsManager is a UserDetailsService implementation that holds a fixed set of UserDetails objects in memory. It is ideal for demos, tests, and prototypes where you want a couple of known users without wiring up a database. For real applications you implement UserDetailsService yourself (often backed by a JPA repository) so users come from persistent storage, but the authentication flow — load the UserDetails, compare the encoded password — is identical.
How does Spring Security actually intercept requests?
Spring Security is implemented as a chain of servlet Filters that runs before your controllers. Each filter has one job: one authenticates the request, another applies authorization rules, others handle CSRF, CORS, logout, and so on. After authentication succeeds, the resulting Authentication object is stored in the SecurityContext (held thread-locally by SecurityContextHolder) so the rest of the request — including your controller — can read who the current user is. Your SecurityFilterChain bean configures exactly which filters apply and how.