Search code examples
javaspringspring-bootspring-securitythymeleaf

Spring Security CSRF 403 Forbidden on successful login


I'm using Spring Security on a basic Thymeleaf setup with index.html and login.html, however the default login page always returns 403 Forbidden when the credentials are valid. (It gives a UI error when the credentials don't match, as expected).

I believe it's due to the CSRF token which is already included as a cookie (XSRF-TOKEN) in every request to backend. I'd rather not simply disable CSRF, so I've tried including this token into the POST request in almost every way I could find online:

  • changing target to /login?_csrf=token
  • inserting <input type="hidden" name="_csrf" ... into the Thymeleaf form (this is default behaviour and I checked that it does send, but backend rejects it??)
  • swapping from normal form submission to AJAX/fetch and inserting X-XSRF-TOKEN header. Doesn't work too, including both JSON and x-www-form-urlencoded encoded requests.

Any ideas? What does the default Spring Security /login POST endpoint expect in the request? How does it expect the CSRF token? Authentication seems to be working, it's just that CSRF fails on successful login. Or is it something else entirely that I'm missing that's giving me a 403 Forbidden?

Thanks in advance!!

My setup

Spring Boot version: 2.6.2

pom.xml dependencies

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.xerial</groupId>
            <artifactId>sqlite-jdbc</artifactId>
        </dependency>

        <dependency>
            <groupId>com.zsoltfabok</groupId>
            <artifactId>sqlite-dialect</artifactId>
            <version>1.0</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.hibernate.validator</groupId>
            <artifactId>hibernate-validator</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

index.html

<form th:action="@{/login}" method="post">
    <label for="username">Username</label>
    <input type="text" name="username" />
    <label for="password">Password</label>
    <input type="password" name="password" />
    <button type="submit">Submit</button>
<!-- there's also a hidden CSRF token generated automatically -->
</form>

WebSecurityConfigurerAdapter

@Configuration
@EnableGlobalAuthentication
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    // ...
    
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
            .antMatchers("/favicon.ico").anonymous()
            .antMatchers("/static/**").anonymous()
            .mvcMatchers("/", "/login", "/signup").anonymous()
            .anyRequest().authenticated()
            .and()
            .formLogin()
                .loginPage("/login")
                .defaultSuccessUrl("/")
            .and()
            .httpBasic()
            .and()
            .csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
    }
    
}

WebMvcConfigurer

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/login").setViewName("login");
        registry.setOrder(Ordered.HIGHEST_PRECEDENCE);
    }
    
}

Controller

@Controller
@RequestMapping("/")
public class WebController {

    @GetMapping
    public String index() {
        return "index";
    }
    
}

Solution

  • The issue is your security rules.

    .antMatchers("/favicon.ico").anonymous()
    .antMatchers("/static/**").anonymous()
    .mvcMatchers("/", "/login", "/signup").anonymous()
    

    You configured them for anonymous access. Which means someone who isn't authenticated yet. It will prevent an authenticated user to access any of those URLs (or un-authenticated if you disabled anonymous access).

    To fix allow access for anyone using permitAll() instead of anonymous().

    .antMatchers("/favicon.ico").permitAll()
    .antMatchers("/static/**").permitAll()
    .mvcMatchers("/", "/login", "/signup").permitAll()