00:00

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:

NeedWhat Spring Security provides
Login and logoutForm login, HTTP Basic, logout handling and “remember me”
Different permissionsRole-based access for URLs and individual methods
Safe password storagePassword encoders such as BCrypt
Securing REST APIsToken-based security with JWT and OAuth 2
“Login with Google” and similarOAuth 2 and OpenID Connect login
Protection against attacksCSRF protection, session fixation protection and secure HTTP headers

3. Authentication vs Authorization

These two words sound alike, but they answer different questions.

AuthenticationAuthorization
QuestionWho are you?What are you allowed to do?
WhenFirst, usually at loginAfter authentication, on every request
ExampleAlice enters her username and passwordAlice is a customer, so she cannot open the admin page
HTTP status when it fails401 Unauthorized403 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.

Request flow
HTTP request
     |
     v
DelegatingFilterProxy
     |
     v
FilterChainProxy
     |
     v
SecurityFilterChain (your rules)
     |
     v
DispatcherServlet -> @Controller
ComponentJob
DelegatingFilterProxyConnects the web server’s filter system to Spring
FilterChainProxyChooses which SecurityFilterChain applies to the request
CsrfFilterRejects state-changing requests without a valid CSRF token
UsernamePasswordAuthenticationFilterHandles form login
BasicAuthenticationFilterHandles HTTP Basic credentials
BearerTokenAuthenticationFilterHandles JWT bearer tokens
ExceptionTranslationFilterTurns security errors into a login redirect, 401 or 403
AuthorizationFilterChecks your access rules, such as hasRole("ADMIN")

4.2 What happens at login

  1. A filter reads the username and password from the request.
  2. It passes them to the AuthenticationManager.
  3. The manager asks a UserDetailsService to load the user by username.
  4. A PasswordEncoder checks the typed password against the stored hash.
  5. If they match, the user is stored in the SecurityContext, so later code knows who is logged in.
  6. On each request, the AuthorizationFilter checks 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:

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 user is created with a random password printed in the console:
Console output
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:

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:

SecurityConfig.java
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.
Version note: Older tutorials extend 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.

PasswordConfig.java
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:

Example
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)

UserConfig.java
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:

AppUser.java
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:

AppUserRepository.java
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:

DatabaseUserDetailsService.java
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:

RegistrationService.java
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.

Note: When a login fails, Spring Security returns the same “Bad credentials” error whether the username or the password was wrong. This is deliberate: it stops attackers from finding out which usernames exist.

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 writeStored authorityChecked with
.roles("ADMIN")ROLE_ADMINhasRole("ADMIN") or hasAuthority("ROLE_ADMIN")
.authorities("books:delete")books:deletehasAuthority("books:delete")
  • hasRole("ADMIN") adds the ROLE_ prefix for you. Never write hasRole("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.
RuleMeaning
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:

MethodSecurityConfig.java
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:

OrderService.java
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:

AccountController.java
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:

Example
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).

  1. The user logs in once and receives a signed token.
  2. The client sends the token with every request in the Authorization: Bearer <token> header.
  3. 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.

pom.xml
<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:

application.properties
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:

application.properties
# Pass the real value as an environment variable: APP_JWT_SECRET
app.jwt.secret=${APP_JWT_SECRET}
JwtConfig.java
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();
    }
}
TokenService.java
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:

TokenController.java
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:

ApiSecurityConfig.java
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

Terminal
# 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
Keep in mind: Anyone can read the contents of a JWT; the signature only prevents changes. Never put passwords or other secrets in a token, keep expiry times short, and always use HTTPS.

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 Authorization header 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.

CorsConfig.java
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:

ThreatWhat could happenHow Spring Security helps
CSRFA malicious site performs actions as a logged-in userRequires a CSRF token on state-changing requests
Session fixationAn attacker plants a session ID and takes over the session after loginCreates a new session ID when the user logs in
Password theftA stolen database reveals every passwordPassword encoders such as BCrypt store only salted hashes
ClickjackingYour page is shown invisibly inside another site to trick clicksSends X-Frame-Options: DENY
Content sniffingThe browser treats an uploaded file as a scriptSends X-Content-Type-Options: nosniff
Leaking pages from the cachePrivate pages are kept in a shared browser cacheSends Cache-Control: no-cache, no-store headers
Downgrade to HTTPTraffic is intercepted on an insecure connectionSends 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:

pom.xml
<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:

BookControllerSecurityTest.java
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());
    }
}
  • @WithMockUser runs 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.
Note: The imports are for Spring Boot 3.4 and later. Spring Boot 4 moved some test annotations, such as @WebMvcTest, to new packages; the code itself stays the same. See the Spring Boot Testing guide for more.

16. Best Practices

  1. Deny by default. End your rules with anyRequest().authenticated() and open up only what must be public.
  2. Always hash passwords with BCrypt or Argon2. Never store or log plain-text passwords.
  3. Use HTTPS everywhere in production, especially for logins and tokens.
  4. Keep CSRF protection on for browser applications that use cookies.
  5. Give users the least access they need. Most people should not be admins.
  6. Protect methods too, with @PreAuthorize, not only URLs.
  7. Keep secrets out of code: signing keys and passwords belong in environment variables or a secret store.
  8. Use short-lived tokens and prefer an established identity provider over writing your own login system.
  9. Return generic error messages such as “Bad credentials”, not “user not found”.
  10. Write tests for your security rules, including the cases that must be denied.
  11. Keep Spring Boot up to date to get security fixes quickly.

17. Common Mistakes and Troubleshooting

ProblemLikely causeFix
Every page suddenly asks for a loginSpring Security’s defaults are activeAdd your own SecurityFilterChain with permitAll() for public pages
POST or DELETE returns 403 although logged inThe CSRF token is missingSend 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 encoderHash with the same PasswordEncoder bean that checks them
hasRole("ROLE_ADMIN") never matcheshasRole adds ROLE_ againWrite hasRole("ADMIN")
A public URL still requires loginA broader rule above it matched firstMove specific rules above general ones
@PreAuthorize is ignoredMethod security is not enabled, or the method is called from inside the same classAdd @EnableMethodSecurity; call the method through another bean
Browser shows a CORS errorThe API does not allow the front end’s originAdd a CorsConfigurationSource bean and .cors(Customizer.withDefaults())
JWT requests return 401The token is expired, the signature key differs, or the header is wrongCheck the expiry, the key and the Bearer prefix
WebSecurityConfigurerAdapter cannot be foundIt was removed in Spring Security 6Define a SecurityFilterChain bean

To see exactly why a request was allowed or denied, turn on detailed security logging while you investigate:

application.properties
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.