Spring Security – A Simple and Easy Guide
Security is one of the most important parts of any application. Whether it is a small website or a large enterprise system, protecting user data and controlling who can do what is critical. Spring Security is the standard framework for adding security to Spring and Spring Boot applications in a clean and reliable way.
In this guide you will secure an online bookstore step by step: logging in, storing passwords safely, loading users from a database, giving customers and admins different permissions, protecting REST APIs with JWT, handling CORS and CSRF, and testing that your rules really work.
1. What Is Spring Security?
Spring Security is a Java framework that provides authentication, authorization and protection against common attacks. It is part of the Spring family and works smoothly with Spring Boot, which configures most of it for you.
In simple words, Spring Security helps you:
- Check who the user is (authentication), for example with a username and password or a token.
- Decide what the user may do (authorization), for example only admins can delete books.
- Protect pages and APIs from unauthorized access and well-known attacks.
Think of it as the security desk of an office building. The guard checks your ID card at the entrance (authentication), and your card only opens the floors you are allowed to visit (authorization).
2. Why Do We Need Spring Security?
Without proper security, anyone could call sensitive APIs, read private data or perform actions they should not be allowed to. Writing security code yourself is risky: small mistakes in password storage or session handling can expose every user.
Spring Security gives you tested, ready-made solutions for common needs:
| Need | What Spring Security provides |
|---|---|
| Login and logout | Form login, HTTP Basic, logout handling and “remember me” |
| Different permissions | Role-based access for URLs and individual methods |
| Safe password storage | Password encoders such as BCrypt |
| Securing REST APIs | Token-based security with JWT and OAuth 2 |
| “Login with Google” and similar | OAuth 2 and OpenID Connect login |
| Protection against attacks | CSRF protection, session fixation protection and secure HTTP headers |
3. Authentication vs Authorization
These two words sound alike, but they answer different questions.
| Authentication | Authorization | |
|---|---|---|
| Question | Who are you? | What are you allowed to do? |
| When | First, usually at login | After authentication, on every request |
| Example | Alice enters her username and password | Alice is a customer, so she cannot open the admin page |
| HTTP status when it fails | 401 Unauthorized | 403 Forbidden |
A useful way to remember the status codes: 401 means “we do not know who you are, please log in”, while 403 means “we know who you are, and the answer is no”.
4. How Spring Security Works Internally
4.1 The security filter chain
Spring Security works through a chain of servlet filters. Every incoming request passes through these filters before it reaches your controller. If a filter decides the request is not allowed, it stops the request right there.
HTTP request
|
v
DelegatingFilterProxy
|
v
FilterChainProxy
|
v
SecurityFilterChain (your rules)
|
v
DispatcherServlet -> @Controller
| Component | Job |
|---|---|
DelegatingFilterProxy | Connects the web server’s filter system to Spring |
FilterChainProxy | Chooses which SecurityFilterChain applies to the request |
CsrfFilter | Rejects state-changing requests without a valid CSRF token |
UsernamePasswordAuthenticationFilter | Handles form login |
BasicAuthenticationFilter | Handles HTTP Basic credentials |
BearerTokenAuthenticationFilter | Handles JWT bearer tokens |
ExceptionTranslationFilter | Turns security errors into a login redirect, 401 or 403 |
AuthorizationFilter | Checks your access rules, such as hasRole("ADMIN") |
4.2 What happens at login
- A filter reads the username and password from the request.
- It passes them to the
AuthenticationManager. - The manager asks a
UserDetailsServiceto load the user by username. - A
PasswordEncoderchecks the typed password against the stored hash. - If they match, the user is stored in the
SecurityContext, so later code knows who is logged in. - On each request, the
AuthorizationFilterchecks the rules for the requested URL.
You rarely touch the filters yourself. You mostly provide three things: the rules (a SecurityFilterChain bean), the users (a UserDetailsService) and a password encoder.
5. Adding Spring Security and Its Defaults
Add the starter to your pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Start the application, and Spring Boot immediately applies a safe default setup:
- Every endpoint requires login.
- A login page is generated at
/login, and logout works at/logout. - HTTP Basic login is also enabled, which is handy for tools such as
curl. - One user named
useris created with a random password printed in the console:
Using generated security password: 6f1c2a7e-93b4-4d0e-b2f1-0c8a5d4e7b19
This generated password is for development use only. Your security
configuration must be updated before running your application in production.
For quick experiments you can set a fixed user in application.properties:
spring.security.user.name=admin
spring.security.user.password=change-me
spring.security.user.roles=ADMIN
These defaults are useful for testing, but real applications need their own rules and users. That is what the rest of this guide covers.
6. Your First Security Configuration
You describe your security rules by defining a SecurityFilterChain bean. Here the bookstore lets anyone browse the home page and the book list, lets only admins use the admin area or delete books, and requires login for everything else:
package com.example.bookstore.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
// Public pages and static files
.requestMatchers("/", "/about", "/css/**", "/images/**").permitAll()
// Anyone can read the book list
.requestMatchers(HttpMethod.GET, "/api/books/**").permitAll()
// Only admins can delete books or open the admin area
.requestMatchers(HttpMethod.DELETE, "/api/books/**").hasRole("ADMIN")
.requestMatchers("/admin/**").hasRole("ADMIN")
// Everything else needs a logged-in user
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults())
.logout(logout -> logout.logoutSuccessUrl("/"));
return http.build();
}
}
Important points:
- Rules are checked from top to bottom, and the first match wins. Put specific rules first and
anyRequest()last. /css/**matches everything under/css, at any depth.- To use your own login page, write
.formLogin(form -> form.loginPage("/login").permitAll())and create a controller and template for/login.
WebSecurityConfigurerAdapter and use antMatchers(). Both were removed in Spring Security 6 (Spring Boot 3). Use a SecurityFilterChain bean and requestMatchers(), as shown above.7. Users and Password Encoding
7.1 Never store plain-text passwords
If your database is ever stolen, plain-text passwords expose every user, and because people reuse passwords, their other accounts too. Always store a hash: a one-way scrambled version of the password that cannot be turned back into the original.
BCrypt is the most common choice. It is deliberately slow, which makes guessing passwords very expensive for an attacker, and it adds a random “salt” so that two users with the same password get different hashes.
package com.example.bookstore.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Encoding the same password twice gives two different results, yet both can be checked:
String hash1 = encoder.encode("alice-pass"); // $2a$10$Xk7Q... (different every time)
String hash2 = encoder.encode("alice-pass"); // $2a$10$9fLm...
encoder.matches("alice-pass", hash1); // true
encoder.matches("wrong-pass", hash1); // false
7.2 Users in memory (for learning and demos)
package com.example.bookstore.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
public class UserConfig {
@Bean
public UserDetailsService users(PasswordEncoder encoder) {
UserDetails customer = User.withUsername("alice")
.password(encoder.encode("alice-pass"))
.roles("CUSTOMER")
.build();
UserDetails admin = User.withUsername("admin")
.password(encoder.encode("admin-pass"))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(customer, admin);
}
}
As soon as you define your own UserDetailsService, Spring Boot stops creating the default user with the random password. Users in memory are lost on restart, so real applications load them from a database.
8. Loading Users from a Database
To log users in from your own database, implement UserDetailsService. Spring Security calls it with the username at login time.
Step 1: an entity for the users table:
package com.example.bookstore.user;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@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 hash, never the plain password
@Column(nullable = false)
private String role; // "CUSTOMER" or "ADMIN"
private boolean enabled = true;
protected AppUser() {
}
public AppUser(String username, String password, String role) {
this.username = username;
this.password = password;
this.role = role;
}
public String getUsername() { return username; }
public String getPassword() { return password; }
public String getRole() { return role; }
public boolean isEnabled() { return enabled; }
}
Step 2: a Spring Data repository:
package com.example.bookstore.user;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findByUsername(String username);
boolean existsByUsername(String username);
}
Step 3: the UserDetailsService that turns your entity into a Spring Security user:
package com.example.bookstore.user;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class DatabaseUserDetailsService implements UserDetailsService {
private final AppUserRepository repository;
public DatabaseUserDetailsService(AppUserRepository repository) {
this.repository = repository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
AppUser user = repository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return User.withUsername(user.getUsername())
.password(user.getPassword())
.roles(user.getRole())
.disabled(!user.isEnabled())
.build();
}
}
Step 4: hash the password when a user registers:
package com.example.bookstore.user;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class RegistrationService {
private final AppUserRepository repository;
private final PasswordEncoder passwordEncoder;
public RegistrationService(AppUserRepository repository, PasswordEncoder passwordEncoder) {
this.repository = repository;
this.passwordEncoder = passwordEncoder;
}
public void register(String username, String rawPassword) {
if (repository.existsByUsername(username)) {
throw new IllegalArgumentException("Username is already taken");
}
String hash = passwordEncoder.encode(rawPassword);
repository.save(new AppUser(username, hash, "CUSTOMER"));
}
}
That is all. Spring Security finds your UserDetailsService and PasswordEncoder beans and uses them for form login and HTTP Basic automatically.
9. Roles and Authorities
Every logged-in user has a list of authorities: simple strings that describe what they may do. A role is just an authority whose name starts with ROLE_.
| You write | Stored authority | Checked with |
|---|---|---|
.roles("ADMIN") | ROLE_ADMIN | hasRole("ADMIN") or hasAuthority("ROLE_ADMIN") |
.authorities("books:delete") | books:delete | hasAuthority("books:delete") |
hasRole("ADMIN")adds theROLE_prefix for you. Never writehasRole("ROLE_ADMIN").- Use roles for broad groups of people, such as customers and admins.
- Use fine-grained authorities, such as
books:delete, when permissions need to be mixed and matched.
| Rule | Meaning |
|---|---|
permitAll() | Everyone, even without logging in |
authenticated() | Any logged-in user |
hasRole("ADMIN") | Users with the ADMIN role |
hasAnyRole("ADMIN", "STAFF") | Users with at least one of these roles |
hasAuthority("books:delete") | Users with this exact authority |
denyAll() | Nobody |
10. Method Security with @PreAuthorize
URL rules protect the entrance. Method security protects individual service methods, which is useful when the same method is reached from several places. Turn it on once:
package com.example.bookstore.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}
Then annotate your methods:
package com.example.bookstore.order;
import java.util.List;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
// Only admins can see every order
@PreAuthorize("hasRole('ADMIN')")
public List<Order> findAll() {
return repository.findAll();
}
// Users can see their own orders; admins can see anyone's
@PreAuthorize("hasRole('ADMIN') or #username == authentication.name")
public List<Order> findByCustomer(String username) {
return repository.findByCustomerUsername(username);
}
// Checked after the method runs, using the returned order
@PostAuthorize("hasRole('ADMIN') or returnObject.customerUsername == authentication.name")
public Order findById(Long id) {
return repository.findById(id).orElseThrow();
}
}
If the check fails, Spring Security throws an AccessDeniedException, which becomes a 403 Forbidden response. #username refers to the method parameter, and authentication.name is the logged-in username.
11. Getting the Logged-In User
The simplest way to get the current user in a controller is to add an Authentication parameter. It works for form login, HTTP Basic and JWT alike:
package com.example.bookstore.account;
import java.util.List;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import com.example.bookstore.order.Order;
import com.example.bookstore.order.OrderService;
@RestController
public class AccountController {
private final OrderService orderService;
public AccountController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/api/me/orders")
public List<Order> myOrders(Authentication authentication) {
return orderService.findByCustomer(authentication.getName());
}
}
If you need more details about the user, use @AuthenticationPrincipal instead:
- With form login or HTTP Basic:
@AuthenticationPrincipal UserDetails user - With JWT tokens:
@AuthenticationPrincipal Jwt jwt
Outside a controller, read it from the SecurityContextHolder:
Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
String username = authentication.getName();
boolean isAdmin = authentication.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
Never trust a username or user ID sent in the request body to decide whose data to show. Always use the authenticated user from Spring Security.
12. Securing REST APIs with JWT
12.1 Sessions or tokens?
Classic web applications keep the user logged in with a session cookie. REST APIs used by mobile apps or single-page applications usually use tokens instead. The most popular token format is JWT (JSON Web Token).
- The user logs in once and receives a signed token.
- The client sends the token with every request in the
Authorization: Bearer <token>header. - Spring Security checks the signature and expiry, then trusts the user details inside the token. No session is stored on the server.
Spring Security has built-in JWT support through the OAuth 2 Resource Server module, so you do not need to write your own token filter.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
12.2 Option A: tokens from an identity provider (recommended)
If your company uses an identity provider such as Keycloak, Okta, Auth0 or Microsoft Entra ID, it issues the tokens. Your application only checks them, and one property is enough:
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://login.example.com/realms/bookstore
12.3 Option B: issuing your own tokens
For a small, self-contained application you can issue tokens yourself. Keep the signing secret out of your code, and make it at least 32 characters long:
# Pass the real value as an environment variable: APP_JWT_SECRET
app.jwt.secret=${APP_JWT_SECRET}
package com.example.bookstore.config;
import java.nio.charset.StandardCharsets;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import com.nimbusds.jose.jwk.source.ImmutableSecret;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
@Configuration
public class JwtConfig {
private final SecretKey key;
public JwtConfig(@Value("${app.jwt.secret}") String secret) {
this.key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
}
@Bean
public JwtEncoder jwtEncoder() {
return new NimbusJwtEncoder(new ImmutableSecret<>(key));
}
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withSecretKey(key).build();
}
}
package com.example.bookstore.auth;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.stream.Collectors;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;
@Service
public class TokenService {
private final JwtEncoder encoder;
public TokenService(JwtEncoder encoder) {
this.encoder = encoder;
}
public String createToken(Authentication authentication) {
Instant now = Instant.now();
// "ROLE_ADMIN ROLE_CUSTOMER" becomes "ADMIN CUSTOMER"
String scope = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.map(authority -> authority.replace("ROLE_", ""))
.collect(Collectors.joining(" "));
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("bookstore")
.issuedAt(now)
.expiresAt(now.plus(1, ChronoUnit.HOURS))
.subject(authentication.getName())
.claim("scope", scope)
.build();
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}
}
A small controller hands out tokens. The user logs in to it with HTTP Basic, using the UserDetailsService from section 8:
package com.example.bookstore.auth;
import java.util.Map;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TokenController {
private final TokenService tokenService;
public TokenController(TokenService tokenService) {
this.tokenService = tokenService;
}
@PostMapping("/token")
public Map<String, String> token(Authentication authentication) {
return Map.of("token", tokenService.createToken(authentication));
}
}
12.4 The security configuration for an API
For a pure REST API, use this configuration instead of the one in section 6:
package com.example.bookstore.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class ApiSecurityConfig {
@Bean
public SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/books/**").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/books/**").hasAuthority("SCOPE_ADMIN")
.anyRequest().authenticated()
)
// No cookies or sessions are used, so CSRF attacks are not possible
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// HTTP Basic is used to log in at POST /token
.httpBasic(Customizer.withDefaults())
// Every other request is authenticated with the JWT
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));
return http.build();
}
}
Spring Security turns each value in the token’s scope claim into an authority with the prefix SCOPE_. That is why the admin rule checks hasAuthority("SCOPE_ADMIN").
12.5 Trying it out
# 1. Log in and receive a token
curl -X POST -u admin:admin-pass http://localhost:8080/token
# {"token":"eyJhbGciOiJIUzI1NiJ9.eyJpc3Mi..."}
# 2. Call a protected endpoint with the token
curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3Mi..." \
http://localhost:8080/api/me/orders
# 3. Without a token you get 401 Unauthorized
curl -i http://localhost:8080/api/me/orders
13. CORS and CSRF
13.1 CSRF (Cross-Site Request Forgery)
Imagine you are logged in to the bookstore, and you visit a malicious website. That site secretly submits a form to the bookstore. Your browser automatically sends your session cookie along, so the bookstore thinks you placed the order. This is a CSRF attack.
Spring Security blocks it by requiring a secret CSRF token on every request that changes data (POST, PUT, PATCH, DELETE). The malicious site cannot know this token.
- CSRF protection is on by default. Thymeleaf and Spring’s form tags add the token to your forms automatically.
- Keep it on whenever the browser logs users in with cookies, which includes form login and sessions.
- You may turn it off only for stateless APIs where the client sends a token in the
Authorizationheader and no cookies are used, as in section 12.
13.2 CORS (Cross-Origin Resource Sharing)
Browsers block JavaScript on one website, such as https://shop.example.com, from calling an API on another, such as https://api.example.com, unless the API explicitly allows it. That permission is called CORS.
package com.example.bookstore.config;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://shop.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
Then enable CORS in your security configuration by adding .cors(Customizer.withDefaults()) to the http builder. It picks up the CorsConfigurationSource bean automatically. Avoid allowing every origin (*) in production.
14. Built-in Protection Against Common Attacks
Spring Security protects against several common threats without any extra code:
| Threat | What could happen | How Spring Security helps |
|---|---|---|
| CSRF | A malicious site performs actions as a logged-in user | Requires a CSRF token on state-changing requests |
| Session fixation | An attacker plants a session ID and takes over the session after login | Creates a new session ID when the user logs in |
| Password theft | A stolen database reveals every password | Password encoders such as BCrypt store only salted hashes |
| Clickjacking | Your page is shown invisibly inside another site to trick clicks | Sends X-Frame-Options: DENY |
| Content sniffing | The browser treats an uploaded file as a script | Sends X-Content-Type-Options: nosniff |
| Leaking pages from the cache | Private pages are kept in a shared browser cache | Sends Cache-Control: no-cache, no-store headers |
| Downgrade to HTTP | Traffic is intercepted on an insecure connection | Sends Strict-Transport-Security on HTTPS responses |
Spring Security does not protect you from everything. You still need parameterized queries (JPA does this for you) against SQL injection, output escaping against XSS, HTTPS in production, and up-to-date dependencies.
15. Testing Security Rules
Security rules are easy to break by accident, so test them. Add the test library:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
This test checks the rules from section 6 for the book API. It assumes the controller’s delete method returns 204 No Content:
package com.example.bookstore.book;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import com.example.bookstore.config.SecurityConfig;
@WebMvcTest(BookController.class)
@Import(SecurityConfig.class)
class BookControllerSecurityTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private BookService bookService;
@Test
void anyoneCanListBooks() throws Exception {
mockMvc.perform(get("/api/books"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "CUSTOMER")
void customerCannotDeleteBooks() throws Exception {
mockMvc.perform(delete("/api/books/1").with(csrf()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "ADMIN")
void adminCanDeleteBooks() throws Exception {
mockMvc.perform(delete("/api/books/1").with(csrf()))
.andExpect(status().isNoContent());
}
}
@WithMockUserruns the test as a fake logged-in user with the given roles..with(csrf())adds a valid CSRF token, because the configuration in section 6 keeps CSRF protection on.- For the JWT API, use
.with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_ADMIN")))instead.
@WebMvcTest, to new packages; the code itself stays the same. See the Spring Boot Testing guide for more.16. Best Practices
- Deny by default. End your rules with
anyRequest().authenticated()and open up only what must be public. - Always hash passwords with BCrypt or Argon2. Never store or log plain-text passwords.
- Use HTTPS everywhere in production, especially for logins and tokens.
- Keep CSRF protection on for browser applications that use cookies.
- Give users the least access they need. Most people should not be admins.
- Protect methods too, with
@PreAuthorize, not only URLs. - Keep secrets out of code: signing keys and passwords belong in environment variables or a secret store.
- Use short-lived tokens and prefer an established identity provider over writing your own login system.
- Return generic error messages such as “Bad credentials”, not “user not found”.
- Write tests for your security rules, including the cases that must be denied.
- Keep Spring Boot up to date to get security fixes quickly.
17. Common Mistakes and Troubleshooting
| Problem | Likely cause | Fix |
|---|---|---|
| Every page suddenly asks for a login | Spring Security’s defaults are active | Add your own SecurityFilterChain with permitAll() for public pages |
POST or DELETE returns 403 although logged in | The CSRF token is missing | Send the token from your form, or disable CSRF only for stateless token APIs |
| “There is no PasswordEncoder mapped for the id null” | Plain-text passwords are stored, but a delegating encoder expects a prefix such as {bcrypt} | Store BCrypt hashes and define a PasswordEncoder bean |
| Login always fails with “Bad credentials” | Passwords were saved without hashing, or with a different encoder | Hash with the same PasswordEncoder bean that checks them |
hasRole("ROLE_ADMIN") never matches | hasRole adds ROLE_ again | Write hasRole("ADMIN") |
| A public URL still requires login | A broader rule above it matched first | Move specific rules above general ones |
@PreAuthorize is ignored | Method security is not enabled, or the method is called from inside the same class | Add @EnableMethodSecurity; call the method through another bean |
| Browser shows a CORS error | The API does not allow the front end’s origin | Add a CorsConfigurationSource bean and .cors(Customizer.withDefaults()) |
JWT requests return 401 | The token is expired, the signature key differs, or the header is wrong | Check the expiry, the key and the Bearer prefix |
WebSecurityConfigurerAdapter cannot be found | It was removed in Spring Security 6 | Define a SecurityFilterChain bean |
To see exactly why a request was allowed or denied, turn on detailed security logging while you investigate:
logging.level.org.springframework.security=TRACE
18. Summary
Spring Security protects your application through a chain of filters that run before every request reaches your code. Adding the starter secures everything by default; you then describe your own rules in a SecurityFilterChain bean, load users through a UserDetailsService, and store passwords safely with BCrypt. Roles and @PreAuthorize control who may do what, JWT and the OAuth 2 Resource Server secure stateless REST APIs, and built-in CSRF protection and security headers guard against common attacks. Combine these with HTTPS, short-lived tokens and tests for your rules, and your application will be secure, maintainable and ready for production.
19. Frequently Asked Questions (FAQ)
Q1. Why does my whole application ask for a login after adding Spring Security?
That is Spring Boot’s safe default: once spring-boot-starter-security is on the classpath, every URL requires login. The username is user, and the random password is printed in the console at startup.
To decide for yourself which pages are public, define a SecurityFilterChain bean with permitAll() for those paths and anyRequest().authenticated() for the rest.
Q2. What is the difference between 401 and 403?
- 401 Unauthorized: Spring Security does not know who you are. You are not logged in, or your credentials or token are invalid.
- 403 Forbidden: Spring Security knows who you are, but you lack permission, for example a customer calling an admin endpoint. A missing CSRF token also causes a 403.
A 401 means “log in”. A 403 means “logging in again will not help”.
Q3. Why do my POST requests return 403 even though I am logged in?
Almost always because of CSRF protection. Spring Security rejects POST, PUT, PATCH and DELETE requests that do not include a valid CSRF token.
- In Thymeleaf forms, use
th:action; the token is added automatically. - In JavaScript, read the token and send it in the
X-XSRF-TOKENorX-CSRF-TOKENheader. - In tests, add
.with(csrf())to the request. - For a stateless API that uses bearer tokens and no cookies, disabling CSRF is acceptable.
Q4. Is it safe to disable CSRF protection?
Only when the browser does not log users in with cookies. CSRF attacks work because browsers send cookies automatically.
| Application type | CSRF protection |
|---|---|
| Server-rendered pages with form login | Keep it on |
| Single-page app using a session cookie | Keep it on |
REST API using Authorization: Bearer tokens, no cookies | Safe to turn off |
Q5. What is the difference between hasRole and hasAuthority?
Both check the user’s authorities. The only difference is the ROLE_ prefix:
.requestMatchers("/admin/**").hasRole("ADMIN") // checks "ROLE_ADMIN"
.requestMatchers("/admin/**").hasAuthority("ROLE_ADMIN") // same thing
.requestMatchers("/reports/**").hasAuthority("reports:read")
Use hasRole for roles and hasAuthority for fine-grained permissions or JWT scopes such as SCOPE_ADMIN.
Q6. Which password encoder should I use?
Use BCrypt (BCryptPasswordEncoder) unless you have a reason not to. Argon2 and SCrypt are also strong choices.
For long-lived applications, consider a delegating encoder. It stores the algorithm in front of each hash, such as {bcrypt}$2a$10$..., so you can switch algorithms later without breaking existing passwords:
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
Never use plain text, MD5 or SHA-1 for passwords. They are far too fast to guess.
Q7. What does "There is no PasswordEncoder mapped for the id null" mean?
Spring Security is using a delegating password encoder, which expects each stored password to start with an algorithm ID in braces, such as {bcrypt}. Your stored password has no such prefix, usually because it was saved as plain text.
Fix it in one of these ways:
- Define your own
PasswordEncoderbean, such asBCryptPasswordEncoder, and store BCrypt hashes. - Or keep the delegating encoder and store passwords created with it, which include the prefix.
For quick demos only, {noop}secret marks a plain-text password. Never use it in real applications.
Q8. Are JWT tokens better than sessions?
Neither is better in general; they suit different applications.
| Sessions | JWT | |
|---|---|---|
| Best for | Server-rendered web apps | APIs for mobile apps and other services |
| Server stores | Session data | Nothing; the token carries the data |
| Logout | Instant: delete the session | Harder: the token stays valid until it expires |
| Scaling | Needs shared session storage across servers | Any server can check the token |
For a classic website, sessions are simpler and safer. Use JWT when you need stateless APIs.
Q9. Where should a browser application store a JWT?
There is no perfect answer, but there are clear trade-offs:
- localStorage is easy, but any injected script (XSS) can read the token.
- An
HttpOnly,Securecookie cannot be read by JavaScript, but it brings back CSRF, so keep CSRF protection on. - Memory only is safest against theft but is lost when the page reloads.
Many teams avoid the problem with the backend-for-frontend pattern: the browser uses a normal session cookie with its own server, and that server keeps the tokens.
Q10. How do I log a user out when using JWT?
A JWT stays valid until it expires, because the server does not store it. Common approaches:
- Short expiry times, such as 15 minutes, plus a refresh token that can be revoked on the server.
- Delete the token on the client when the user logs out.
- Keep a server-side block list of revoked token IDs until they expire, if you must revoke instantly.
If instant logout is essential everywhere, sessions may be a better fit than JWT.
Q11. Can I still use WebSecurityConfigurerAdapter?
No. It was deprecated in Spring Security 5.7 and removed in Spring Security 6, which Spring Boot 3 uses. Replace it with beans:
| Old way | New way |
|---|---|
Override configure(HttpSecurity) | Define a SecurityFilterChain bean |
Override configure(AuthenticationManagerBuilder) | Define a UserDetailsService and a PasswordEncoder bean |
antMatchers(), mvcMatchers() | requestMatchers() |
authorizeRequests() | authorizeHttpRequests() |
@EnableGlobalMethodSecurity | @EnableMethodSecurity |
Q12. Do I need @EnableWebSecurity in a Spring Boot application?
Strictly speaking, no. Spring Boot switches web security on automatically when Spring Security is on the classpath, and your SecurityFilterChain bean is picked up either way.
Many teams still add it to their security configuration class because it makes the purpose of the class obvious. Adding it does no harm.
Q13. How do I allow the H2 console or Swagger UI?
Permit their paths. The H2 console also runs inside a frame and posts forms, so it needs two extra settings:
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/h2-console/**").permitAll()
.anyRequest().authenticated()
)
.csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**"))
.headers(headers -> headers.frameOptions(frame -> frame.sameOrigin()));
Only open these tools in development. In production, remove them or protect them with a login.
Q14. Can I have different security rules for different parts of the application?
Yes. Define several SecurityFilterChain beans, each limited to some URLs with securityMatcher, and give them an order:
@Bean
@Order(1)
public SecurityFilterChain api(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain web(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
Requests to /api/** use JWT; everything else uses form login. The chain without a securityMatcher must come last.
Q15. How can I lock an account after too many failed logins?
Spring Security does not do this automatically, but it publishes events you can react to:
@Component
public class LoginAttemptListener {
private final LoginAttemptService attempts;
public LoginAttemptListener(LoginAttemptService attempts) {
this.attempts = attempts;
}
@EventListener
public void onFailure(AuthenticationFailureBadCredentialsEvent event) {
attempts.recordFailure(event.getAuthentication().getName());
}
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
attempts.reset(event.getAuthentication().getName());
}
}
Your LoginAttemptService counts failures and marks the user as locked; your UserDetailsService then returns the user with .accountLocked(true). Unlock automatically after a while so attackers cannot lock out real users forever.
Q16. How do I add "Login with Google" or GitHub?
Use the OAuth 2 client starter and register your application with the provider to get a client ID and secret:
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2Login(Customizer.withDefaults());
Add the spring-boot-starter-oauth2-client dependency, and Spring Boot shows a “Login with Google” option and handles the whole redirect flow for you.
Q17. How do I find out why a request is being denied?
Turn on detailed security logging while you investigate:
logging.level.org.springframework.security=TRACE
The log shows each filter the request passes through, which rule matched, and why access was granted or denied. Use DEBUG for less output, and switch it off again afterwards, because the output is very verbose.
Q18. Can I return a JSON error instead of the default 401 or 403 response?
Yes. Provide your own AuthenticationEntryPoint for 401 errors and AccessDeniedHandler for 403 errors:
http.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, e) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Please log in\"}");
})
.accessDeniedHandler((request, response, e) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Access denied\"}");
})
);
Keep the messages generic, so they do not reveal details that could help an attacker.
Q19. Does Spring Security protect against SQL injection and XSS?
- SQL injection: no. That is the job of your data access code. JPA, Spring Data and
JdbcTemplatewith?parameters are safe; building SQL by joining strings is not. - XSS: partly. Spring Security sends helpful headers and you can add a Content Security Policy, but the main defense is escaping output. Thymeleaf escapes text automatically with
th:text.
Security works in layers. Spring Security covers authentication, authorization and several web attacks, but safe coding is still required.
Q20. Why is @PreAuthorize not working on my method?
Check these common causes:
@EnableMethodSecurityis missing from your configuration.- The method is called from inside the same class. Spring applies security through a proxy, so
this.someMethod()skips the check. Call it through another bean. - The class is not a Spring bean, for example it was created with
new. - The expression has a typo, such as
hasRole('ROLE_ADMIN')instead ofhasRole('ADMIN').