Spring Security Integration

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Thymeleaf 3.1.5.RELEASE (2026-04-21), thymeleaf-extras-springsecurity6 3.1.5.RELEASE, Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring Security 7.1.1) and Java 21, checked on 2026-10-08. Every example was compiled and run on that stack in a scratch Bookshelf application (Maven wrapper, JDK 26 with --release 21): 27 tests, 25 Spring Boot tests with MockMvc, @WithMockUser and Spring Security’s test request post-processors, and 2 against a real embedded Tomcat on a random port with a real session cookie. The output shown is the real output of those runs, with CSRF token values replaced by TOKEN. The integration applies to Thymeleaf with Spring MVC (the examples use the servlet stack). The module also supports Spring WebFlux (its README says so, and Boot configures it there too), but nothing on this page was run on WebFlux, and standalone Thymeleaf cannot use it because the dialect needs a web environment and a security context.

Spring Security decides who may do what, and the template only has to reflect that decision: show the user’s name, hide the admin link from people who cannot follow it, and render a logout form that carries a CSRF token. This page covers the small Thymeleaf-specific part (the sec dialect, the two expression objects and the automatic CSRF token) and the Spring Security configuration around it that a Bookshelf login needs: a custom login page, a POST logout, a 403 page, a SecurityFilterChain, a UserDetailsService and method security. It continues the Bookshelf scenario, builds on the forms and error pages pages, and leaves the Spring Security reference itself (Spring Security) and OAuth theory (OAuth and Spring Boot) to their own pages.

What the integration consists of

The integration is one extra library, thymeleaf-extras-springsecurity6, maintained in the Thymeleaf monorepo. It contains a dialect, SpringSecurityDialect, whose default prefix is sec, with two kinds of contribution:

  • Attributes: sec:authorize (and its alias sec:authorize-expr), sec:authorize-url, sec:authorize-acl and sec:authentication.

  • Expression utility objects: #authentication, the current Spring Security Authentication, and #authorization, with methods that answer authorisation questions.

Despite the 6 in its name, the module is the current one for Spring Security 7: there is no thymeleaf-extras-springsecurity7 on Maven Central (the same is true for thymeleaf-spring7), and the 3.1.5.RELEASE artifact, the latest, ran unchanged against Spring Security 7.1.1 in the scratch application. The 5 variant targets Spring Security 5. Its README still lists the old 3.0.x requirements, so take the supported combinations from the Spring Boot dependency management instead.

Table 1. Versions in the scratch application (Spring Boot 4.1.1 BOM)
Artifact Version Note

org.thymeleaf.extras:thymeleaf-extras-springsecurity6

3.1.5.RELEASE

Managed by the Boot BOM, so no version in the build file. Java 17 or later.

org.springframework.security:spring-security-*

7.1.1

Pulled in by spring-boot-starter-security.

org.springframework.security:spring-security-acl

7.1.1

Only needed for sec:authorize-acl.

io.github.wimdeblauwe:htmx-spring-boot

5.2.0

Only for the htmx section; not managed by the BOM, so the version is explicit.

Dependencies and Boot auto-configuration

With Spring Boot you add the Thymeleaf starter, the Security starter and the extras module. The starters do not pull the extras module in, so it is a separate line (Spring Initializr adds it when you choose both Spring Security and Thymeleaf):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security-test</artifactId>
    <scope>test</scope>
</dependency>

Nothing else is needed. Spring Boot’s ThymeleafAutoConfiguration (module spring-boot-thymeleaf) has a nested ThymeleafSecurityDialectConfiguration that is active when SpringSecurityDialect and Spring Security’s CsrfToken are both on the classpath, and registers a SpringSecurityDialect bean unless you define your own. The SpringTemplateEngine picks up every dialect bean. The first test confirms the dialect is in the engine:

@Test
void secDialectIsRegisteredByBoot() {
    assertThat(engine.getDialects())
            .anyMatch(d -> d instanceof org.thymeleaf.extras.springsecurity6.dialect.SpringSecurityDialect);
}

Without Boot, add the dialect to the engine yourself, next to the template resolver and the Spring Security configuration (the README shows the same thing as an XML bean with an additionalDialects property). This test builds a bare engine and registers it:

@Test
void manualRegistrationWithoutBoot() {
    SpringTemplateEngine manual = new SpringTemplateEngine();
    manual.addDialect(new org.thymeleaf.extras.springsecurity6.dialect.SpringSecurityDialect());
    assertThat(manual.getDialects())
            .anyMatch(d -> d instanceof org.thymeleaf.extras.springsecurity6.dialect.SpringSecurityDialect);
}

The namespace is http://www.thymeleaf.org/extras/spring-security. It is an XML namespace identifier, not a page (the URL returns 404), and a wrong or missing xmlns:sec does not change how the template is processed, only what an IDE offers while you type:

<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">

The Bookshelf security model

The application has two roles, USER and ADMIN. Users are stored in the database as an entity with a password hash and a comma-separated role list. The password is never stored in clear text, and the application has no built-in account: the first administrator is created by a seeding step you control (an environment-driven bootstrap, a migration, an admin command), never by a literal password in the source.

@Entity
public class AppUser {
    @Id @GeneratedValue private Long id;
    @Column(unique = true, nullable = false) private String username;
    @Column(nullable = false) private String passwordHash;
    @Column(nullable = false) private String roles; // comma separated, without the ROLE_ prefix

    protected AppUser() { }
    public AppUser(String username, String passwordHash, String roles) {
        this.username = username; this.passwordHash = passwordHash; this.roles = roles;
    }
    public String getUsername() { return username; }
    public String getPasswordHash() { return passwordHash; }
    public String[] getRoleNames() { return roles.split(","); }
}
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
    Optional<AppUser> findByUsername(String username);
}

A UserDetailsService turns that entity into the UserDetails Spring Security authenticates against. Its job is to look the user up and hand back the stored hash; the comparison with the submitted password is done later by the authentication provider using the PasswordEncoder:

@Service
class BookshelfUserDetailsService implements UserDetailsService {

    private final AppUserRepository users;

    BookshelfUserDetailsService(AppUserRepository users) {
        this.users = users;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        AppUser user = users.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("Unknown user"));
        return User.withUsername(user.getUsername())
                .password(user.getPasswordHash())
                .roles(user.getRoleNames())
                .build();
    }
}

The roles(...) builder method adds the ROLE_ prefix, so the authorities of a user with the stored role USER are ROLE_USER. The exception message is deliberately generic, so the login failure does not reveal whether the user name exists. Declaring a UserDetailsService bean makes Boot back off its generated default user (the one with the password printed at startup).

Passwords are encoded with the DelegatingPasswordEncoder, which stores an identifier with the hash, for example {bcrypt} followed by the hash, and so lets you change the algorithm later without invalidating existing hashes. The tests in this page create their only user with encoder.encode(...) and a value that exists in test code only. Do not use the {noop} prefix outside such test code: it means "store the password in plain text".

A sign-up form with a password confirmation

When users register themselves, the form asks for the password twice and the server must compare the two values. That rule involves two fields, so it is a class-level constraint, built as shown on Validation & Error Messages. The validator reports the violation on the confirmation property, so the error appears next to the input that must be corrected:

@PasswordsMatch
public class SignUpForm {

    @NotBlank private String username;
    @Size(min = 8) private String password;
    private String confirmation;

    // getters and setters
}

public class PasswordsMatchValidator implements ConstraintValidator<PasswordsMatch, SignUpForm> {

    @Override
    public boolean isValid(SignUpForm form, ConstraintValidatorContext context) {
        if (form.getPassword() == null || form.getPassword().equals(form.getConfirmation())) {
            return true;
        }
        context.disableDefaultConstraintViolation();
        context.buildConstraintViolationWithTemplate("{PasswordsMatch.message}")
                .addPropertyNode("confirmation")
                .addConstraintViolation();
        return false;
    }
}

The annotation @PasswordsMatch is declared like any custom constraint, with @Target(ElementType.TYPE), and the message lives in messages.properties as PasswordsMatch.message. The controller validates with @Valid and a BindingResult; only a valid form reaches the code that encodes the password with the PasswordEncoder and saves the user. Posting two different passwords redisplayed the form with the confirmation input marked and this message, and two equal passwords redirected to the login page:

<input type="password" th:field="*{confirmation}" th:errorclass="is-invalid">
<p th:if="${#fields.hasErrors('confirmation')}" th:errors="*{confirmation}" class="error">Passwords differ</p>
Rendered when the two passwords differ
<input type="password" id="confirmation" name="confirmation" value="" class="is-invalid">
<p class="error">The two passwords must be identical</p>

A password input is never redisplayed with its value (value="" above), which is the behaviour you want. The sign-up URL has to be permitted for anonymous visitors in the SecurityFilterChain, like the login page.

The SecurityFilterChain

Spring Security 7 is configured with a SecurityFilterChain bean and the lambda DSL. The WebSecurityConfigurerAdapter class that older tutorials extend was removed in Spring Security 6, antMatchers and mvcMatchers are gone in favour of requestMatchers, and @EnableGlobalMethodSecurity is replaced by @EnableMethodSecurity. The configuration of the Bookshelf (imports omitted):

@Configuration
@EnableMethodSecurity
class SecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
                .requestMatchers("/login", "/error").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .requestMatchers(HttpMethod.GET, "/reports/**").hasAnyRole("USER", "ADMIN")
                .requestMatchers(HttpMethod.POST, "/reports/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .formLogin(form -> form
                .loginPage("/login")
                .permitAll())
            .logout(logout -> logout
                .logoutSuccessUrl("/login?logout"))
            .oauth2Login(oauth -> oauth
                .loginPage("/login"))
            .exceptionHandling(exceptions -> exceptions
                .authenticationEntryPoint(new HxRedirectToPageAuthenticationEntryPoint(
                        new LoginUrlAuthenticationEntryPoint("/login")))
                .accessDeniedHandler(new HxRedirectToPageAccessDeniedHandler()));
        return http.build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

What each part does, in the order the rules are evaluated:

  • PathRequest.toStaticResources().atCommonLocations() is Spring Boot’s matcher for its standard static locations: CSS, JavaScript, images, webjars, the favicon and fonts (/css/**, /js/**, /images/**, /webjars/**, /favicon.*, /fonts/**). Without it an anonymous visitor cannot load the stylesheet of the login page.

  • /login and /error are open to everyone. /error matters: an exception or a sendError is handled by a second, container-level dispatch to /error, and that dispatch goes through the security filters again, so without this rule an anonymous visitor sees the login page instead of the error page (see the error pages).

  • Rules are checked in order and the first match wins, so specific rules come before the catch-all. anyRequest().authenticated() comes last, which makes every URL you forget to list private by default.

  • The GET and POST rules for /reports/** exist to show an HTTP-method-aware rule to sec:authorize-url below.

  • formLogin with loginPage("/login") turns off Boot’s generated login page and tells Spring Security where yours is. permitAll() there also opens the URL that processes the submission, POST /login.

  • logout leaves the default POST /logout in place and says where to go afterwards.

  • oauth2Login and exceptionHandling belong to the last two sections of the page and are in this file because the tests run against one chain.

CSRF protection is not mentioned because it is on by default for every unsafe method. Switching it off to "fix" a 403 is never the answer on a browser application; see the CSRF section.

Login, logout and the 403 page

The flow

sequenceDiagram participant B as Browser participant F as Security filter chain participant L as Login controller and template participant U as UserDetailsService participant C as Book controller B->>F: GET /books (no session) F-->>B: 302 to /login (the request is saved) B->>L: GET /login L-->>B: 200 form with a hidden _csrf field B->>F: POST /login (username, password, _csrf) F->>U: loadUserByUsername(username) U-->>F: UserDetails (password hash and roles) alt the password matches the hash F-->>B: 302 to /books?continue (new session) B->>C: GET /books (session cookie) C-->>B: 200 page rendered with the sec attributes else bad credentials F-->>B: 302 to /login?error B->>L: GET /login?error L-->>B: 200 form and the error message (param.error) end

The two controller-level pieces are trivial. Spring Security processes POST /login in a filter, so the application only has to serve the page:

@Controller
class LoginController {

    @GetMapping("/login")
    String login() {
        return "login";
    }

    @GetMapping("/")
    String home() {
        return "redirect:/books";
    }
}

The login template

The template is an ordinary Thymeleaf page. Two things are specific to Spring Security: the form field names are the defaults, username and password (change them with usernameParameter and passwordParameter in the DSL), and the redirect after a failed login or a logout carries a request parameter that the template reads with the param object.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/head :: head('Log in')}"></head>
<body>
<h1>Log in</h1>
<p th:if="${param.error}" role="alert">Invalid username or password.</p>
<p th:if="${param.logout}" role="status">You have been logged out.</p>
<form th:action="@{/login}" method="post">
  <label for="username">Username</label>
  <input type="text" id="username" name="username" autofocus="autofocus"/>
  <label for="password">Password</label>
  <input type="password" id="password" name="password"/>
  <button type="submit">Log in</button>
</form>
<p><a th:href="@{/oauth2/authorization/example}">Log in with Example</a></p>
</body>
</html>
  • ${param.error} is true when the request has an error parameter, whatever its value, and ${param.logout} likewise. Spring Security redirects to /login?error and /login?logout by default, which is why the controller needs no model attributes. The article on this integration (written for an older Security) uses a model attribute and a second mapping instead; the parameter approach needs neither.

  • th:action="@{/login}" is what adds the hidden _csrf field: see the CSRF section. A plain action="/login" would render a form whose submission is rejected with a 403.

  • The OAuth2 link is the standard Spring Security authorisation endpoint for the registration id example; the last section explains it.

Rendered form (the page head, which is the head fragment, is shown in the CSRF section):

<form action="/login" method="post"><input type="hidden" name="_csrf" value="TOKEN"/>
  <label for="username">Username</label>
  <input type="text" id="username" name="username" autofocus="autofocus"/>
  <label for="password">Password</label>
  <input type="password" id="password" name="password"/>
  <button type="submit">Log in</button>
</form>

The tests render the page with and without the parameters:

@Test
void loginPageShowsErrorAndLogoutMessagesFromParams() throws Exception {
    mvc.perform(get("/login"))
            .andExpect(status().isOk())
            .andExpect(content().string(not(containsString("Invalid username"))))
            .andExpect(content().string(not(containsString("logged out"))))
            .andExpect(content().string(containsString("name=\"_csrf\"")));
    mvc.perform(get("/login").param("error", ""))
            .andExpect(content().string(containsString("Invalid username or password.")));
    mvc.perform(get("/login").param("logout", ""))
            .andExpect(content().string(containsString("You have been logged out.")));
}

Logging out is a POST

With CSRF protection on, Spring Security’s LogoutFilter only processes POST. A GET /logout link does nothing useful, which is deliberate: any site could otherwise log your users out with an image tag. The logout control is therefore a form, in the navigation fragment every page shares (the fragment is shown in the next section). th:action adds the token:

<form th:action="@{/logout}" method="post"><button type="submit">Log out</button></form>

The 403 page

When an authenticated user lacks the authority for a URL, Spring Security’s AccessDeniedHandlerImpl calls response.sendError(403). The container then dispatches to /error, Boot’s BasicErrorController finds templates/error/403.html (the most specific name wins) and renders it as a Thymeleaf template. No Spring Security configuration is needed, only the template:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/head :: head('Access denied')}"></head>
<body>
<h1>Access denied</h1>
<p>Your account is not allowed to open this page.</p>
<p><a th:href="@{/books}">Back to the books</a></p>
</body>
</html>

An unauthenticated request never reaches this page: it is sent to the login page, because there is nobody to deny yet. The end-to-end test drives the whole sequence of the diagram against a real server (a real session cookie, a real error dispatch), then logs out. MockMvc does not perform the container’s error dispatch, which is why this scenario uses Tomcat:

@Test
void loginRedirectsBackThenLogoutAndThe403Page() throws Exception {
    // 1. anonymous request is redirected to the login page
    var books = get("/books");
    assertThat(books.statusCode()).isEqualTo(302);
    assertThat(books.headers().firstValue("Location")).hasValueSatisfying(l -> assertThat(l).endsWith("/login"));

    // 2. the login form carries a CSRF token
    var login = get("/login");
    assertThat(login.statusCode()).isEqualTo(200);

    // 3. bad credentials go back to /login?error
    var bad = postForm("/login", "username=alice&password=nope&_csrf=" + csrfToken(login.body()));
    assertThat(bad.headers().firstValue("Location")).hasValueSatisfying(l -> assertThat(l).endsWith("/login?error"));
    var again = get("/login?error");
    assertThat(again.body()).contains("Invalid username or password.");

    // 4. good credentials: redirect to the saved request (Spring Security appends ?continue), then the page
    var ok = postForm("/login", "username=alice&password=test-only-password&_csrf=" + csrfToken(again.body()));
    assertThat(ok.statusCode()).isEqualTo(302);
    assertThat(ok.headers().firstValue("Location")).hasValueSatisfying(l -> assertThat(l).endsWith("/books?continue"));
    var page = get("/books");
    assertThat(page.statusCode()).isEqualTo(200);
    assertThat(page.body()).contains("<strong>alice</strong>").doesNotContain("Delete");

    // 5. a user without ROLE_ADMIN gets the custom 403 template through the container error dispatch
    var admin = get("/admin");
    assertThat(admin.statusCode()).isEqualTo(403);
    assertThat(admin.body()).contains("Access denied", "Back to the books");

    // 6. logout is a POST with the token from the page, and ends on /login?logout
    var out = postForm("/logout", "_csrf=" + csrfToken(page.body()));
    assertThat(out.headers().firstValue("Location")).hasValueSatisfying(l -> assertThat(l).endsWith("/login?logout"));
    assertThat(get("/login?logout").body()).contains("You have been logged out.");
    assertThat(get("/books").statusCode()).isEqualTo(302);
}

The helper methods (get, postForm and csrfToken) are plain java.net.http code that sends the cookies and extracts the token from the page. Three details of this stack show in it: the redirect back after login carries ?continue (the saved-request marker), a GET to /logout does not log out (the LogoutFilter ignores it, Spring MVC has no handler and answers 404, as the second test of the class checks), and the 403 body is the Thymeleaf template, not the container’s page.

Because MockMvc skips the error dispatch, the fast version of the 403 test requests /error with the status attribute that the container would have set:

@Test
void forbiddenStatusRendersTheCustom403Template() throws Exception {
    mvc.perform(get("/error").requestAttr(RequestDispatcher.ERROR_STATUS_CODE, 403)
                    .accept(MediaType.TEXT_HTML))
            .andExpect(view().name("error/403"))
            .andExpect(content().string(containsString("Access denied")));
}

The sec attributes

All the attributes act like th:if on the element that carries them: when the check fails, the element and its content are removed from the output, and when it passes the element is rendered normally with the sec:* attribute stripped. (The README describes them as rendering the element’s children. The output below shows the whole element removed, for instance the <a> of the Admin link, so write them on the element you want to hide.) They run during Thymeleaf’s processing of the template on the server, so they hide markup, they do not protect anything: the /admin URL must still be protected in the filter chain, as it is above.

The navigation fragment that all the pages share uses four of them:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:fragment="head(title)">
  <meta charset="utf-8"/>
  <meta name="_csrf" th:content="${_csrf.token}"/>
  <meta name="_csrf_header" th:content="${_csrf.headerName}"/>
  <title th:text="${title}">Bookshelf</title>
</head>
<body>
<nav th:fragment="nav" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
  <a th:href="@{/books}">Books</a>
  <a th:href="@{/admin}" sec:authorize-url="/admin">Admin</a>
  <span sec:authorize="isAuthenticated()">
    Signed in as <strong sec:authentication="name">user</strong>
    (<span sec:authentication="authorities">[ROLE_USER]</span>)
    <form th:action="@{/logout}" method="post"><button type="submit">Log out</button></form>
  </span>
  <a th:href="@{/login}" sec:authorize="isAnonymous()">Log in</a>
</nav>
</body>
</html>
Table 2. Which attribute for which question
Attribute Renders the element when Example

sec:authorize, sec:authorize-expr (aliases)

a Spring Security expression is true

sec:authorize="hasRole('ADMIN')"

sec:authorize-url

the current user may call the URL; prefix it with an HTTP method to ask about that method

sec:authorize-url="POST /reports/monthly"

sec:authorize-acl

the user has the permissions on a domain object in the ACL system

sec:authorize-acl="${book} :: 'WRITE'"

sec:authentication

(it always renders) outputs a property of the Authentication, replacing the element’s body

sec:authentication="principal.username"

sec:authorize and sec:authorize-expr

The value is a Spring Security expression (the same language as in @PreAuthorize and requestMatchers(...).access(...)), evaluated against Spring Security’s security expression root: hasRole('ADMIN'), hasAnyRole(...), hasAuthority(...), isAuthenticated(), isAnonymous(), permitAll(), principal, authentication. It is not a Thymeleaf expression, though you may wrap it in ${...} and then use #vars to read model variables, as in sec:authorize="${hasRole(#vars.expectedRole)}" (the security root has no direct access to model variables, which is why #vars is needed). sec:authorize-expr is an exact alias of sec:authorize.

Two traps with roles. hasRole('ADMIN') and hasRole('ROLE_ADMIN') are the same check, because hasRole adds the ROLE_ prefix if it is missing, while hasAuthority('ADMIN') is a different, exact check that fails for a user whose authority is ROLE_ADMIN. The template below and its output show both:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head th:replace="~{fragments/head :: head('Account')}"></head>
<body>
<nav th:replace="~{fragments/head :: nav}"></nav>
<p id="by-attribute">Name: <span sec:authentication="name">user</span></p>
<p id="by-object">Name: <span th:text="${#authentication.name}">user</span></p>
<p id="authorities" sec:authentication="authorities">[ROLE_USER]</p>
<p id="username" sec:authentication="principal.username">user</p>
<p id="admin-expr" th:if="${#authorization.expression('hasRole(''ADMIN'')')}">You are an administrator.</p>
<p id="admin-url" th:if="${#authorization.url('/admin')}">You can open /admin.</p>
<p id="post-url" th:if="${#authorization.url('POST', '/books/1/delete')}">You can POST to the delete URL.</p>
<p id="role-prefix" sec:authorize="hasRole('ROLE_ADMIN')">Same as hasRole('ADMIN').</p>
<p id="authority" sec:authorize="hasAuthority('ROLE_ADMIN')">Authority check.</p>
<p id="bare-authority" sec:authorize="hasAuthority('ADMIN')">Never shown.</p>
<p id="expr-alias" sec:authorize-expr="isAuthenticated()">Authenticated (authorize-expr).</p>
<p id="get-reports" sec:authorize-url="GET /reports/monthly">You can read the reports.</p>
<p id="post-reports" sec:authorize-url="POST /reports/monthly">You can create reports.</p>
<p id="method-first" th:if="${#authorization.url('POST', '/reports/monthly')}">Same check as an expression.</p>
<p id="default-method" sec:authorize-url="/reports/monthly">URL alone is checked as GET.</p>
</body>
</html>

Rendered for the USER user (blank lines left by removed elements are dropped from the listing):

<p id="by-attribute">Name: <span>alice</span></p>
<p id="by-object">Name: <span>alice</span></p>
<p id="authorities">[ROLE_USER]</p>
<p id="username">alice</p>
<p id="post-url">You can POST to the delete URL.</p>
<p id="expr-alias">Authenticated (authorize-expr).</p>
<p id="get-reports">You can read the reports.</p>
<p id="default-method">URL alone is checked as GET.</p>

And for the ADMIN user:

<p id="by-attribute">Name: <span>root</span></p>
<p id="by-object">Name: <span>root</span></p>
<p id="authorities">[ROLE_ADMIN]</p>
<p id="username">root</p>
<p id="admin-expr">You are an administrator.</p>
<p id="admin-url">You can open /admin.</p>
<p id="post-url">You can POST to the delete URL.</p>
<p id="role-prefix">Same as hasRole('ADMIN').</p>
<p id="authority">Authority check.</p>
<p id="expr-alias">Authenticated (authorize-expr).</p>
<p id="get-reports">You can read the reports.</p>
<p id="post-reports">You can create reports.</p>
<p id="method-first">Same check as an expression.</p>
<p id="default-method">URL alone is checked as GET.</p>

sec:authorize-url and the HTTP method

sec:authorize-url asks the security filter chain whether the current user may access a URL, so the template can mirror the URL rules instead of repeating them as roles. Giving the URL alone asks about GET; putting a method before it asks about that method. The rules in the SecurityFilterChain above allow GET /reports/** to both roles and POST /reports/** only to administrators, and the page answers accordingly:

@Test
@WithMockUser(roles = "USER")
void authorizeUrlHonoursTheHttpMethod() throws Exception {
    mvc.perform(get("/account"))
            .andExpect(content().string(containsString("You can read the reports.")))
            .andExpect(content().string(containsString("URL alone is checked as GET.")))
            .andExpect(content().string(not(containsString("You can create reports."))))
            .andExpect(content().string(not(containsString("Same check as an expression."))));
}

The check reads the URL rules of the filter chain only. Method security (@PreAuthorize) is invisible to it, so a button for an action protected only by an annotation is shown to everybody. The Bookshelf delete endpoint is protected by @PreAuthorize and not by a URL rule, so the delete button uses sec:authorize="hasRole('ADMIN')" and not sec:authorize-url. This test records the difference (the user passes the URL check for POST /books/1/delete yet is refused by the annotation, as the method security section shows):

@Test
@WithMockUser(roles = "USER")
void authorizeUrlDoesNotSeeMethodSecurity() throws Exception {
    // the URL rules allow every authenticated user to POST to the delete URL; only @PreAuthorize blocks it
    mvc.perform(get("/account"))
            .andExpect(content().string(containsString("You can POST to the delete URL.")));
}

sec:authorize-acl

The sec:authorize-acl attribute checks permissions on a domain object against Spring Security ACLs. Its value has two parts separated by a double colon, the object and a comma-separated list of permissions, and both are Thymeleaf Standard Expressions (see the example below). The permissions are given as names or as the integer masks of BasePermission (the README example uses the masks '1,3'; WRITE, used here, is mask 2). The attribute needs an AclService bean in the application context, which comes from the spring-security-acl module and is usually the JDBC-backed implementation with its ACL tables. In the book detail template the edit link is guarded this way:

<p><a th:href="@{/books/{id}/edit(id=${book.id})}" sec:authorize-acl="${book} :: 'WRITE'">Edit</a></p>

The scratch application did not set up the ACL tables. The test replaces AclService with a @MockitoBean whose Acl answers yes or no, which verifies what the attribute does with the answer and what it asks: one permission, WRITE, whose mask is 2.

@Test
@WithMockUser(username = "alice", roles = "USER")
void aclAttributeUsesTheAclService() throws Exception {
    Acl acl = mock(Acl.class);
    when(acl.isGranted(any(), any(), anyBoolean())).thenReturn(true);
    when(aclService.readAclById(any(), any())).thenReturn(acl);
    mvc.perform(get("/books/" + bookId))
            .andExpect(content().string(containsString("/books/" + bookId + "/edit")));
    org.mockito.ArgumentCaptor<List<org.springframework.security.acls.model.Permission>> asked =
            org.mockito.ArgumentCaptor.forClass(List.class);
    org.mockito.Mockito.verify(acl, org.mockito.Mockito.atLeastOnce()).isGranted(asked.capture(), any(), anyBoolean());
    assertThat(asked.getValue()).singleElement().extracting(org.springframework.security.acls.model.Permission::getMask).isEqualTo(2);
    when(acl.isGranted(any(), any(), anyBoolean())).thenReturn(false);
    mvc.perform(get("/books/" + bookId))
            .andExpect(content().string(not(containsString("/edit"))));
}

If you do not use ACLs, never write this attribute; if you do, remember that the object must have an id (the ACL object identity is the class name and the object’s id), so an unsaved entity cannot be checked.

sec:authentication

sec:authentication="prop" outputs a property of the Authentication object, replacing the element’s text. The property path is evaluated against the Authentication, so these work:

  • name: the user name (for OAuth2 login, the value of the configured user-name attribute).

  • authorities: the granted authorities, printed as a list such as [ROLE_USER].

  • principal.username (a UserDetails principal), principal.attributes['login'] (an OAuth2User principal), details, credentials.

The path is a Spring EL path, so a missing property is an error that fails the template at render time, not an empty string. Choose the property for the login mechanism in use, as the OAuth2 section shows. The output of the template above is <p id="username">alice</p> for principal.username.

The #authentication and #authorization objects

Where an attribute is awkward (inside th:if with other conditions, in th:text, in an inline expression) use the expression objects instead. They are in the same Standard Expression variable space as #strings or #dates (see the expression utility objects):

  • ${#authentication} is the Spring Security Authentication, so ${#authentication.name} equals sec:authentication="name".

  • ${#authorization} is an org.thymeleaf.extras.springsecurity6.auth.Authorization with expression(String) (and the alias expr), url(String) and url(String, String). The two-argument form takes the HTTP method first and the URL second: ${#authorization.url('POST', '/reports/monthly')}.

  • Quotes inside a Spring Security expression are doubled when it sits inside a Thymeleaf string literal: ${#authorization.expression('hasRole(''ADMIN'')')}.

All four calls appear in the account template above and produce the lines admin-expr, admin-url, post-url and method-first in the rendered output. The Javadoc lists every method.

Navigation and pages by role

The navigation fragment above and the list page together are what the user sees. Rendered for the USER user, with no Admin link and no delete button:

<nav>
  <a href="/books">Books</a>

  <span>
    Signed in as <strong>alice</strong>
    (<span>[ROLE_USER]</span>)
    <form action="/logout" method="post"><input type="hidden" name="_csrf" value="TOKEN"/><button type="submit">Log out</button></form>
  </span>

</nav>
<h1>Books</h1>
<ul>
  <li>
    <a href="/books/1">Dune</a>

  </li>
</ul>

And for the ADMIN user, with the Admin link and a delete form per book:

<nav>
  <a href="/books">Books</a>
  <a href="/admin">Admin</a>
  <span>
    Signed in as <strong>root</strong>
    (<span>[ROLE_ADMIN]</span>)
    <form action="/logout" method="post"><input type="hidden" name="_csrf" value="TOKEN"/><button type="submit">Log out</button></form>
  </span>

</nav>
<h1>Books</h1>
<ul>
  <li>
    <a href="/books/2">Dune</a>
    <form action="/books/2/delete" method="post"><input type="hidden" name="_csrf" value="TOKEN"/>
      <button type="submit">Delete</button>
    </form>
  </li>
</ul>

The template of the list page, which is where the delete form lives:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head th:replace="~{fragments/head :: head('Books')}"></head>
<body>
<nav th:replace="~{fragments/head :: nav}"></nav>
<h1>Books</h1>
<ul>
  <li th:each="book : ${books}">
    <a th:href="@{/books/{id}(id=${book.id})}" th:text="${book.title}">Title</a>
    <form sec:authorize="hasRole('ADMIN')" th:action="@{/books/{id}/delete(id=${book.id})}" method="post">
      <button type="submit">Delete</button>
    </form>
  </li>
</ul>
</body>
</html>

The tests assert exactly this:

@Test
@WithMockUser(username = "alice", roles = "USER")
void userSeesNavWithoutAdminLinkOrDeleteButton() throws Exception {
    mvc.perform(get("/books"))
            .andExpect(status().isOk())
            .andExpect(content().string(containsString("Signed in as")))
            .andExpect(content().string(containsString("<strong>alice</strong>")))
            .andExpect(content().string(containsString("[ROLE_USER]")))
            .andExpect(content().string(not(containsString("Admin</a>"))))
            .andExpect(content().string(not(containsString("Delete"))))
            .andExpect(content().string(not(containsString("Log in</a>"))))
            .andExpect(content().string(containsString("action=\"/logout\"")))
            .andExpect(content().string(containsString("name=\"_csrf\"")));
}
@Test
@WithMockUser(username = "root", roles = "ADMIN")
void adminSeesAdminLinkAndDeleteButton() throws Exception {
    mvc.perform(get("/books"))
            .andExpect(content().string(containsString("Admin</a>")))
            .andExpect(content().string(containsString("/books/" + bookId + "/delete")))
            .andExpect(content().string(containsString("Delete")));
}

Method security

@EnableMethodSecurity turns on @PreAuthorize, @PostAuthorize, @PreFilter and @PostFilter. @Secured and the JSR-250 annotations need securedEnabled = true and jsr250Enabled = true, and @PreAuthorize supersedes @Secured in the reference. The Bookshelf uses it for the one state-changing action that only administrators may take:

@Controller
@RequestMapping("/books")
class BookController {

    private final BookRepository books;

    BookController(BookRepository books) {
        this.books = books;
    }

    @GetMapping
    String list(Model model) {
        model.addAttribute("books", books.findAll());
        return "books/list";
    }

    @GetMapping("/{id}")
    String detail(@PathVariable Long id, Model model) {
        model.addAttribute("book", books.findById(id).orElseThrow());
        return "books/detail";
    }

    @PostMapping("/{id}/read")
    String markRead(@PathVariable Long id) {
        return "redirect:/books/" + id;
    }

    @PostMapping("/{id}/delete")
    @PreAuthorize("hasRole('ADMIN')")
    String delete(@PathVariable Long id) {
        books.deleteById(id);
        return "redirect:/books";
    }
}

Two tests cover both outcomes. A USER is refused with a 403 and the book is still there; an ADMIN can delete, but only with a CSRF token:

@Test
@WithMockUser(roles = "USER")
void methodSecurityBlocksDeleteForUsers() throws Exception {
    mvc.perform(post("/books/" + bookId + "/delete").with(csrf()))
            .andExpect(status().isForbidden());
    assertThat(books.count()).isEqualTo(1);
}
@Test
@WithMockUser(roles = "ADMIN")
void adminCanDeleteWithCsrfTokenButNotWithout() throws Exception {
    mvc.perform(post("/books/" + bookId + "/delete"))
            .andExpect(status().isForbidden());
    assertThat(books.count()).isEqualTo(1);
    mvc.perform(post("/books/" + bookId + "/delete").with(csrf()))
            .andExpect(redirectedUrl("/books"));
    assertThat(books.count()).isZero();
}

Why both a URL rule and an annotation in real applications? URL rules are easy to audit in one place; annotations stay with the code if the URL changes or a service is called from somewhere else. Use the URL rules for coarse areas (/admin/**) and the annotation for rules about a specific operation or object. Whichever protects the action, the template must use a check that sees it (sec:authorize here) to decide whether to show the control.

CSRF tokens

Cross-site request forgery protection is on by default for POST, PUT, PATCH and DELETE: a request without a valid token is answered with 403 before it reaches your controller. The token is available to the template as the request attribute _csrf (an object with token, parameterName and headerName), and there are three ways to send it back.

Automatic: th:action

Thymeleaf’s Spring integration calls the application’s RequestDataValueProcessor bean while it renders th:action, th:href, th:src, th:value and th:field. Spring Security registers a processor that adds a hidden _csrf input to any th:action form with an unsafe method. That is why the login form, the logout form and the delete form need no extra markup (see the rendered output above). The mechanism is the section of the Thymeleaf + Spring tutorial on RequestDataValueProcessor. It works for a form whose method is post; a get form gets no token, as it should not.

Manual: a hidden input

If the action is not written with th:action (it is set by th:attr, generated by JavaScript, written as a plain action, or posted by a custom component), the processor never sees it and no token is added. Add the input yourself. The book detail page has such a form, built with th:attr on purpose, and also holds the script and the htmx button of the next sections and the ACL link:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
      xmlns:hx="https://htmx.org/hx">
<head th:replace="~{fragments/head :: head(${book.title})}"></head>
<body>
<nav th:replace="~{fragments/head :: nav}"></nav>
<h1 th:text="${book.title}">Title</h1>
<p><a th:href="@{/books/{id}/edit(id=${book.id})}" sec:authorize-acl="${book} :: 'WRITE'">Edit</a></p>

<form id="read-form" method="post" th:attr="action=@{/books/{id}/read(id=${book.id})}">
  <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
  <button type="submit">Mark as read</button>
</form>
<button type="button" onclick="markRead()">Mark as read (fetch)</button>
<button type="button" hx:post="@{/books/{id}/read(id=${book.id})}">Mark as read (htmx)</button>
<script th:inline="javascript">
  const bookId = /*[[${book.id}]]*/ 0;
  const token = document.querySelector('meta[name="_csrf"]').content;
  const header = document.querySelector('meta[name="_csrf_header"]').content;
  function markRead() {
    fetch('/books/' + bookId + '/read', { method: 'POST', headers: { [header]: token } });
  }
</script>
</body>
</html>

Rendered, the th:attr form is a plain form and its input is exactly the one written in the template (the Edit link appears because the mocked ACL grants WRITE):

<p><a href="/books/1/edit">Edit</a></p>

<form id="read-form" method="post" action="/books/1/read">
  <input type="hidden" name="_csrf" value="TOKEN"/>
  <button type="submit">Mark as read</button>
</form>
<button type="button" onclick="markRead()">Mark as read (fetch)</button>
<button type="button" hx-post="/books/1/read" hx-headers="{&quot;X-CSRF-TOKEN&quot;:&quot;TOKEN&quot;}">Mark as read (htmx)</button>
<script>
  const bookId = 1;
  const token = document.querySelector('meta[name="_csrf"]').content;
  const header = document.querySelector('meta[name="_csrf_header"]').content;
  function markRead() {
    fetch('/books/' + bookId + '/read', { method: 'POST', headers: { [header]: token } });
  }
</script>

The test posts to the endpoint without a token (403) and with a token as a header and as a parameter:

@Test
@WithMockUser(username = "alice", roles = "USER")
void postToReadEndpointNeedsToken() throws Exception {
    mvc.perform(post("/books/" + bookId + "/read")).andExpect(status().isForbidden());
    mvc.perform(post("/books/" + bookId + "/read").with(csrf().asHeader()))
            .andExpect(status().isFound());
    mvc.perform(post("/books/" + bookId + "/read").with(csrf()))
            .andExpect(status().isFound());
}

Spring Security accepts the token in a request header (X-CSRF-TOKEN or X-XSRF-TOKEN) or in the _csrf request parameter. The token in the page is masked per request (BREACH protection), so two renderings show different values for the same session. That is expected, not a bug.

Meta tags for JavaScript

A script that sends fetch or XMLHttpRequest calls reads the token from meta tags and puts it in the header. The head fragment every page includes writes them:

<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>

and the script on the detail page (listed above) reads them, with th:inline="javascript" serialising the book id as a JavaScript number instead of splicing text into code. The rendered script is in the output above.

The test that renders the detail page checks all three: the meta tags, the header name X-CSRF-TOKEN and the hidden input:

@Test
@WithMockUser(username = "alice", roles = "USER")
void csrfTokenManualMetaAndAutomatic() throws Exception {
    String html = mvc.perform(get("/books/" + bookId)).andExpect(status().isOk())
            .andReturn().getResponse().getContentAsString();
    System.out.println(html);
    assertThat(html).contains("<meta name=\"_csrf\" content=\"", "<meta name=\"_csrf_header\" content=\"X-CSRF-TOKEN\"");
    assertThat(html).contains("<input type=\"hidden\" name=\"_csrf\" value=\"");
    assertThat(html).contains("hx-post=\"/books/" + bookId + "/read\"", "hx-headers=\"{&quot;X-CSRF-TOKEN&quot;:&quot;");
}

This is the same technique that htmx needs for its requests. With plain htmx, put the token in an hx-headers attribute on the body, or configure it once from the same meta tags in an htmx:configRequest listener, and send it as a header. The older trick of using hx-include to send the hidden _csrf input is wrong for hx-delete with htmx 2, which puts the parameters of a DELETE in the URL, so the token would end up in the query string and in the logs. The hx: attributes of htmx-spring-boot-thymeleaf 5.2.0 (hx:post, hx:put, hx:patch, hx:delete) add the token to the hx-headers attribute for you, as the hx-post button in the rendered output above shows (the template writes it as hx:post with an @{...} URL).

Never turn CSRF off for a browser application

A 403 on a POST is nearly always a missing token, not a reason to call csrf.disable(). Disable or narrow it only for endpoints that are not used by a browser session (a webhook with its own signature, a token-authenticated API) with csrf(c -> c.ignoringRequestMatchers("/api/**")), and keep the form pages protected. The login form is protected as well; a login without a token is rejected:

@Test
void loginWithoutCsrfTokenIsRejected() throws Exception {
    mvc.perform(post("/login").param("username", "alice").param("password", "x"))
            .andExpect(status().isForbidden());
}

htmx and an expired session

An htmx request made after the session has expired gets Spring Security’s normal answer, which is meant for a browser navigation and not for an htmx swap: a GET is redirected to the login page, and htmx would swap the whole login page into the target element; a POST fails its CSRF check with a 403 and nothing happens on screen. htmx-spring-boot has entry points that answer such a request with an HX-Redirect header instead. The one used in the chain above sends the browser to the page the request was made from, so it performs a normal navigation that your own entry point (here the login page) then handles:

            .exceptionHandling(exceptions -> exceptions
                .authenticationEntryPoint(new HxRedirectToPageAuthenticationEntryPoint(
                        new LoginUrlAuthenticationEntryPoint("/login")))
                .accessDeniedHandler(new HxRedirectToPageAccessDeniedHandler()));

The two tests: an htmx request without a session gets 401 and HX-Redirect, a normal request still gets the 302 to the login page, and a signed-in user whose POST lacks the token is answered with 403 and an HX-Redirect back to the page:

@Test
void htmxRequestWithoutSessionGetsAnHxRedirectInsteadOfTheLoginPage() throws Exception {
    mvc.perform(get("/books").header("HX-Request", "true")
                    .header("HX-Current-URL", "http://localhost/books"))
            .andExpect(status().isUnauthorized())
            .andExpect(header().string("HX-Redirect", "/books"));
    mvc.perform(get("/books"))
            .andExpect(redirectedUrl("/login"));
}
@Test
@WithMockUser(roles = "USER")
void htmxPostWithMissingCsrfTokenIsAnsweredWithHxRedirect() throws Exception {
    // a signed-in user without the token: the access denied handler answers the htmx request
    mvc.perform(post("/books/" + bookId + "/read").header("HX-Request", "true")
                    .header("HX-Current-URL", "http://localhost/books/" + bookId))
            .andExpect(status().isForbidden())
            .andExpect(header().string("HX-Redirect", "/books/" + bookId));
}

The library also has HxRefreshHeaderAuthenticationEntryPoint (answers 403 with HX-Refresh, so the current page reloads). It is simpler and older; the README of the project calls the redirect entry point recommended for new code, because the refresh variant leaves the htmx request as the saved request to return to after login. The htmx side of the integration (HtmxResponse, @HxRequest) is on its own pages and is not repeated here.

OAuth2 login

OAuth2 and OpenID Connect login is one more line of the same SecurityFilterChain (oauth2Login, already in the chain above) plus a client registration in application.properties. The Thymeleaf side does not change: the sign-in page offers a link to Spring Security’s authorisation endpoint, /oauth2/authorization/<registrationId>, and after login the Authentication is an OAuth2 or OIDC one, so sec:authentication, sec:authorize and the two expression objects keep working. The registration used by the tests uses placeholder endpoints and takes the client secret from an environment variable, never from the file:

spring.application.name=bookshelf
spring.thymeleaf.cache=false

# Example OAuth2 login registration (placeholder endpoints; the secret comes from the environment)
spring.security.oauth2.client.registration.example.client-id=bookshelf
spring.security.oauth2.client.registration.example.client-secret=${EXAMPLE_CLIENT_SECRET}
spring.security.oauth2.client.registration.example.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.example.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.example.scope=read:user
spring.security.oauth2.client.provider.example.authorization-uri=https://idp.example.com/oauth/authorize
spring.security.oauth2.client.provider.example.token-uri=https://idp.example.com/oauth/token
spring.security.oauth2.client.provider.example.user-info-uri=https://idp.example.com/api/user
spring.security.oauth2.client.provider.example.user-name-attribute=login

oauth2Login(...loginPage("/login")) keeps the custom login page, so the page lists the OAuth2 link next to the form. The tests check that the link is there and that the endpoint redirects to the provider’s authorisation URI:

@Test
void loginPageLinksToTheOAuth2Registration() throws Exception {
    mvc.perform(get("/login"))
            .andExpect(content().string(containsString("href=\"/oauth2/authorization/example\"")));
    mvc.perform(get("/oauth2/authorization/example"))
            .andExpect(status().isFound())
            .andExpect(header().string("Location", containsString("https://idp.example.com/oauth/authorize")));
}

What changes is the principal: it is an OAuth2User (or an OidcUser), not a UserDetails, so principal.username does not exist and fails the template. Read the user name with name (or the user-name attribute you configured) and other claims through principal.attributes:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head th:replace="~{fragments/head :: head('External account')}"></head>
<body>
<p id="login" sec:authentication="principal.attributes['login']">octocat</p>
</body>
</html>

The oauth2Login() request post-processor of Spring Security’s test support puts such a principal in the request, so the template is tested without a provider:

@Test
void oauth2LoginPrincipalIsAvailableToTheDialect() throws Exception {
    mvc.perform(get("/account/external")
                    .with(oauth2Login().attributes(a -> a.put("login", "octocat"))))
            .andExpect(status().isOk())
            .andExpect(content().string(containsString(">octocat<")));
}

Roles and authorities of an OAuth2 user come from the scopes and the claims you map, not from your database, so hasRole('ADMIN') is false until you map something to ROLE_ADMIN. The flows themselves, tokens, scopes, and logout at the provider are in the OAuth pages: start with OAuth and Spring Boot, then social login and federation and logout and session management. The application above did not run a real provider.

Testing with Spring Security

The tests on this page use four pieces of spring-security-test, which is on the classpath through spring-boot-starter-security-test:

  • @WithMockUser(username = "alice", roles = "USER") puts a user in the security context before the test, so the UserDetailsService and the database are not involved. It is right for rendering tests (this page’s sec:* checks) and wrong for tests of the login itself.

  • csrf() and csrf().asHeader() add a valid token to the request, as a parameter or a header. A test of a form post without them gets a 403, which is also a test that the protection is on.

  • formLogin() posts credentials through the real filter chain and the real UserDetailsService, with the assertions authenticated() and unauthenticated():

    @Test
    void formLoginWithUserDetailsServiceSucceedsAndFails() throws Exception {
        mvc.perform(formLogin("/login").user("alice").password("test-only-password"))
                .andExpect(authenticated().withUsername("alice").withRoles("USER"))
                .andExpect(redirectedUrl("/"));
        mvc.perform(formLogin("/login").user("alice").password("wrong"))
                .andExpect(unauthenticated())
                .andExpect(redirectedUrl("/login?error"));
    }
  • oauth2Login() builds an OAuth2 principal, as shown above.

The seed data of these tests (one book and one user whose password is hashed with the application’s encoder) is set up in the test:

@BeforeEach
void seed() {
    books.deleteAll();
    users.deleteAll();
    bookId = books.save(new Book("Dune", "Frank Herbert", "alice")).getId();
    // Test-only credentials, hashed the same way production code must do it.
    Acl granted = mock(Acl.class);
    when(granted.isGranted(any(), any(), anyBoolean())).thenReturn(true);
    when(aclService.readAclById(any(), any())).thenReturn(granted);
    users.save(new AppUser("alice", encoder.encode("test-only-password"), "USER"));
}

The password literal is in test code only, which is the one place a literal credential belongs.

Where the books are outdated

Taming Thymeleaf (Wim Deblauwe, consulted edition: PDF v1.1.1, 2021) builds the security of its application on Spring Security 5, and Modern frontends with htmx (v1.0.0, 2023) on Spring Boot 3.1. The concepts they teach hold: in-memory and database users, the DelegatingPasswordEncoder, URL rules by role and method, a 403 page, sec:authorize and sec:authentication, a custom login page with param.error and param.logout, logout through a form, CSRF with htmx and session expiry. The code needs these changes:

Table 3. Book approach and its current form
In the books Today (Boot 4.1, Security 7.1)

extends WebSecurityConfigurerAdapter, configure(HttpSecurity), configure(AuthenticationManagerBuilder)

A SecurityFilterChain bean with the lambda DSL, and a UserDetailsService bean.

antMatchers("/x").hasRole(...)

requestMatchers("/x").hasRole(...), with HttpMethod for method-specific rules.

@EnableGlobalMethodSecurity(securedEnabled = true) with @Secured

@EnableMethodSecurity and @PreAuthorize; @Secured is the legacy option and needs securedEnabled = true.

thymeleaf-extras-springsecurity5

thymeleaf-extras-springsecurity6, auto-registered by Boot.

javax.annotation.security.RolesAllowed, javax.*

jakarta.* (and jsr250Enabled = true if you want @RolesAllowed at all).

Seed users whose password is their first name

Never. Passwords are hashed with the DelegatingPasswordEncoder; seed from the environment or a one-time admin command.

CSRF token sent by hx-include of the hidden _csrf input (htmx book, with htmx 1)

A header, from meta tags or the hx: dialect: with htmx 2 the hx-include trick puts the token of a DELETE in the URL.

HxRefreshHeaderAuthenticationEntryPoint for an expired session (htmx book)

Still available; the newer HxRedirectToPageAuthenticationEntryPoint also drops the htmx request from the saved requests and covers a POST whose CSRF token expired with the session (see above).

A user’s name interpolated into an Alpine x-data JavaScript literal

Pass values through data-* attributes or th:inline="javascript", which escape them: an apostrophe in a name would otherwise break the page, and a crafted name would run script. See the template security page.

Pitfalls

Symptom Cause Fix

sec:authorize or sec:authentication is printed literally or has no effect

The extras module is missing or not registered, so the dialect never ran (the attribute is passed through to the output)

Add thymeleaf-extras-springsecurity6; with Boot nothing else is needed. Outside Boot add SpringSecurityDialect to the engine

Every form post is answered with 403

The form is not written with th:action, so no _csrf field was added; or a script posts without the header

Use th:action, add the hidden input by hand, or send X-CSRF-TOKEN from the meta tags. Do not disable CSRF

Login fails with 403 on the first submit

action="/login" (plain) instead of th:action="@{/login}" in a custom login page

th:action, or the hidden _csrf input

The custom 403 or 404 page is replaced by the login page for anonymous visitors

/error is not permitted, so the error dispatch is redirected to the login page

Add /error to permitAll, only that path

The stylesheet is missing on the login page

Static resources are not permitted

PathRequest.toStaticResources().atCommonLocations() in permitAll

The Admin link shows for everybody, or the delete button for everybody

The rule lives in @PreAuthorize and the template uses sec:authorize-url, which only reads the URL rules of the chain

Use sec:authorize="hasRole(...)" for controls protected by annotations; or add the same URL rule

hasRole('ADMIN') is true but hasAuthority('ADMIN') is false

hasRole adds the ROLE_ prefix, hasAuthority compares the exact string

Use hasRole('ADMIN') for roles and the full ROLE_ADMIN for hasAuthority

Hiding a link is treated as the security

sec:* removes markup only; the URL can still be called

Protect the URL in the filter chain or the method with @PreAuthorize, and also hide the control

GET /logout link does nothing

With CSRF protection logout is POST only

A form with th:action="@{/logout}" and method post

principal.username fails on an OAuth2 login

The principal is an OAuth2User, not a UserDetails

Use name, or principal.attributes['x']

#authorization.url('/x', 'POST') evaluates the wrong thing

The two-argument form is method first, URL second

#authorization.url('POST', '/x')

sec:authorize-acl throws at render time

No AclService bean, or the object has no id yet

Configure the ACL service from spring-security-acl, and only check saved objects

{noop} passwords in the repository

A tutorial’s in-memory user copied into production

Hash with the DelegatingPasswordEncoder; keep literals in test code only

Expired-session htmx requests do nothing or swap in the login form

Spring Security’s browser-oriented redirect and 403 reach an htmx request

HxRedirectToPageAuthenticationEntryPoint and HxRedirectToPageAccessDeniedHandler